authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-09-12 23:57:20-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-12 23:57:20-07:00
log0a91b9a116117dd5df619fb7c1248cba9cf6f43c
treee26abd2f08686538452004aeb3240267d0e17801
parent20a8f1de3668e0ff4da236d75258e2184a68d727
signature Signed by PGP key B5690EEEBB952194

feat(mobile): show startup crashes in Settings → Diagnostics (#11540)

Co-authored-by: Claude Code <noreply@anthropic.com>

8 files changed, 350 insertions(+), 0 deletions(-)

apps/mobile/src/Stack.tsx+8
...@@ -53,6 +53,7 @@ import { NewTaskFlowProvider } from "./features/threads/new-task-flow-provider";...@@ -53,6 +53,7 @@ import { NewTaskFlowProvider } from "./features/threads/new-task-flow-provider";
53import { NewTaskRouteScreen } from "./features/threads/NewTaskRouteScreen";53import { NewTaskRouteScreen } from "./features/threads/NewTaskRouteScreen";
54import { SettingsAppearanceRouteScreen } from "./features/settings/SettingsAppearanceRouteScreen";54import { SettingsAppearanceRouteScreen } from "./features/settings/SettingsAppearanceRouteScreen";
55import { SettingsClientStorageRouteScreen } from "./features/settings/SettingsClientStorageRouteScreen";55import { SettingsClientStorageRouteScreen } from "./features/settings/SettingsClientStorageRouteScreen";
56import { SettingsDiagnosticsRouteScreen } from "./features/diagnostics/SettingsDiagnosticsRouteScreen";
56import { SettingsAuthRouteScreen } from "./features/settings/SettingsAuthRouteScreen";57import { SettingsAuthRouteScreen } from "./features/settings/SettingsAuthRouteScreen";
57import { SettingsEnvironmentsRouteScreen } from "./features/settings/SettingsEnvironmentsRouteScreen";58import { SettingsEnvironmentsRouteScreen } from "./features/settings/SettingsEnvironmentsRouteScreen";
58import { SettingsLegalRouteScreen } from "./features/settings/SettingsLegalRouteScreen";59import { SettingsLegalRouteScreen } from "./features/settings/SettingsLegalRouteScreen";
...@@ -198,6 +199,13 @@ const SettingsContentStack = createNativeStackNavigator({...@@ -198,6 +199,13 @@ const SettingsContentStack = createNativeStackNavigator({
198 title: "Client Storage",199 title: "Client Storage",
199 },200 },
200 }),201 }),
202 SettingsDiagnostics: createNativeStackScreen({
203 screen: SettingsDiagnosticsRouteScreen,
204 linking: "diagnostics",
205 options: {
206 title: "Diagnostics",
207 },
208 }),
201 SettingsUsageAccount: createNativeStackScreen({209 SettingsUsageAccount: createNativeStackScreen({
202 screen: UsageLimitAccountScreen,210 screen: UsageLimitAccountScreen,
203 options: { title: "Account" },211 options: { title: "Account" },
apps/mobile/src/components/AppSymbol.tsx+2
...@@ -79,6 +79,7 @@ import IconServer from "@tabler/icons-react-native/IconServer";...@@ -79,6 +79,7 @@ import IconServer from "@tabler/icons-react-native/IconServer";
79import IconSettings from "@tabler/icons-react-native/IconSettings";79import IconSettings from "@tabler/icons-react-native/IconSettings";
80import IconSparkles from "@tabler/icons-react-native/IconSparkles";80import IconSparkles from "@tabler/icons-react-native/IconSparkles";
81import IconStack2 from "@tabler/icons-react-native/IconStack2";81import IconStack2 from "@tabler/icons-react-native/IconStack2";
82import IconStethoscope from "@tabler/icons-react-native/IconStethoscope";
82import IconSun from "@tabler/icons-react-native/IconSun";83import IconSun from "@tabler/icons-react-native/IconSun";
83import IconTerminal2 from "@tabler/icons-react-native/IconTerminal2";84import IconTerminal2 from "@tabler/icons-react-native/IconTerminal2";
84import IconTextDecrease from "@tabler/icons-react-native/IconTextDecrease";85import IconTextDecrease from "@tabler/icons-react-native/IconTextDecrease";
...@@ -164,6 +165,7 @@ const ANDROID_ICON_BY_SF_SYMBOL: Partial<Record<SFSymbol, Icon>> = {...@@ -164,6 +165,7 @@ const ANDROID_ICON_BY_SF_SYMBOL: Partial<Record<SFSymbol, Icon>> = {
164 "point.topleft.down.curvedto.point.bottomright.up": IconGitMerge,165 "point.topleft.down.curvedto.point.bottomright.up": IconGitMerge,
165 safari: IconExternalLink,166 safari: IconExternalLink,
166 "server.rack": IconServer,167 "server.rack": IconServer,
168 stethoscope: IconStethoscope,
167 "sidebar.left": IconLayoutSidebar,169 "sidebar.left": IconLayoutSidebar,
168 "sidebar.right": IconLayoutSidebarRight,170 "sidebar.right": IconLayoutSidebarRight,
169 "slider.horizontal.3": IconAdjustmentsHorizontal,171 "slider.horizontal.3": IconAdjustmentsHorizontal,
apps/mobile/src/features/diagnostics/SettingsDiagnosticsRouteScreen.tsx created+175
...@@ -0,0 +1,175 @@
1import Constants from "expo-constants";
2import * as Updates from "expo-updates";
3import { useEffect, useState } from "react";
4import { ActivityIndicator, Platform, Pressable, ScrollView, View } from "react-native";
5import { useSafeAreaInsets } from "react-native-safe-area-context";
6
7import { SymbolView } from "../../components/AppSymbol";
8import { AppText as Text } from "../../components/AppText";
9import { tryCopyTextWithHaptic } from "../../lib/copyTextWithHaptic";
10import { SettingsSection } from "../settings/components/SettingsSection";
11import {
12 formatStartupCrashReport,
13 parseStartupCrashRecords,
14 type StartupCrashRecord,
15} from "./crash-log-model";
16
17// expo-updates keeps its persistent log this long. Reading any further back
18// returns nothing, so this is the whole available window.
19const LOG_WINDOW_MS = 7 * 24 * 60 * 60 * 1000;
20
21type CrashLogState =
22 | { readonly status: "loading" }
23 | { readonly status: "unavailable" }
24 | { readonly status: "ready"; readonly records: ReadonlyArray<StartupCrashRecord> };
25
26function appIdentity() {
27 return {
28 version: Constants.expoConfig?.version ?? "0.0.0",
29 build:
30 (Platform.OS === "ios"
31 ? Constants.platform?.ios?.buildNumber
32 : Constants.platform?.android?.versionCode?.toString()) ?? "dev",
33 };
34}
35
36/**
37 * Startup crashes that TestFlight and the stores strip from their reports.
38 * expo-updates' ErrorRecovery writes the JS error and component stack to its
39 * own log before aborting the process, so the next launch can show it here.
40 */
41export function SettingsDiagnosticsRouteScreen() {
42 const insets = useSafeAreaInsets();
43 const [state, setState] = useState<CrashLogState>(() =>
44 Updates.isEnabled ? { status: "loading" } : { status: "unavailable" },
45 );
46 const [copied, setCopied] = useState(false);
47
48 useEffect(() => {
49 if (!Updates.isEnabled) return;
50 let cancelled = false;
51 Updates.readLogEntriesAsync(LOG_WINDOW_MS)
52 .then((entries) => {
53 if (cancelled) return;
54 setState({ status: "ready", records: parseStartupCrashRecords(entries) });
55 })
56 .catch((error: unknown) => {
57 console.warn("[diagnostics] could not read the expo-updates log", error);
58 if (!cancelled) setState({ status: "unavailable" });
59 });
60 return () => {
61 cancelled = true;
62 };
63 }, []);
64
65 const records = state.status === "ready" ? state.records : [];
66 const copyReport = async () => {
67 const ok = await tryCopyTextWithHaptic(formatStartupCrashReport(records, appIdentity()), {
68 target: "crash report",
69 });
70 if (ok) setCopied(true);
71 };
72
73 return (
74 <View collapsable={false} className="flex-1 bg-sheet">
75 <ScrollView
76 contentInsetAdjustmentBehavior="automatic"
77 contentInset={{ bottom: Math.max(insets.bottom, 18) }}
78 showsVerticalScrollIndicator={false}
79 className="flex-1"
80 contentContainerClassName="gap-6 px-5 pt-4 pb-[18px]"
81 >
82 <SettingsSection title="Startup crashes">
83 {state.status === "loading" ? (
84 <View className="items-center gap-3 px-6 py-8">
85 <ActivityIndicator />
86 <Text className="text-center text-sm text-foreground-muted">Reading crash log…</Text>
87 </View>
88 ) : state.status === "unavailable" ? (
89 <EmptyState
90 icon="exclamationmark.triangle"
91 title="Crash log unavailable"
92 detail="Startup crash records are only kept in store and TestFlight builds."
93 />
94 ) : records.length === 0 ? (
95 <EmptyState
96 icon="checkmark.circle"
97 title="No startup crashes"
98 detail="Nothing has taken the app down during launch in the last 7 days."
99 />
100 ) : (
101 records.map((record, index) => (
102 <CrashRow key={record.timestamp} record={record} first={index === 0} />
103 ))
104 )}
105 </SettingsSection>
106
107 <View className="gap-3">
108 <SettingsSection title="Actions">
109 <Pressable
110 accessibilityRole="button"
111 disabled={state.status !== "ready"}
112 onPress={() => void copyReport()}
113 className="flex-row items-center gap-4 p-4 disabled:opacity-40"
114 >
115 <SymbolView
116 name={copied ? "checkmark" : "doc.on.doc"}
117 size={22}
118 tintColorClassName={"accent-icon"}
119 type="monochrome"
120 weight="regular"
121 />
122 <Text className="flex-1 text-lg text-foreground">
123 {copied ? "Copied" : "Copy crash report"}
124 </Text>
125 </Pressable>
126 </SettingsSection>
127 <Text className="px-2 text-sm leading-normal text-foreground-muted">
128 Paste the report into a GitHub issue. It contains the app version, the JavaScript error
129 message, and the component stack. Error messages can quote values from the app, so read
130 it over before sharing.
131 </Text>
132 </View>
133 </ScrollView>
134 </View>
135 );
136}
137
138function EmptyState(props: {
139 readonly icon: "exclamationmark.triangle" | "checkmark.circle";
140 readonly title: string;
141 readonly detail: string;
142}) {
143 return (
144 <View className="items-center gap-2 px-6 py-8">
145 <SymbolView
146 name={props.icon}
147 size={28}
148 tintColorClassName={"accent-icon"}
149 type="monochrome"
150 weight="regular"
151 />
152 <Text className="text-center text-base text-foreground">{props.title}</Text>
153 <Text className="text-center text-sm text-foreground-muted">{props.detail}</Text>
154 </View>
155 );
156}
157
158function CrashRow(props: { readonly record: StartupCrashRecord; readonly first: boolean }) {
159 const { record } = props;
160 return (
161 <View className={props.first ? "gap-1.5 p-4" : "gap-1.5 border-t border-border-subtle p-4"}>
162 <Text className="text-xs text-foreground-muted">
163 {new Date(record.timestamp).toLocaleString()}
164 </Text>
165 <Text selectable className="text-base leading-snug text-danger-foreground">
166 {record.description}
167 </Text>
168 {record.frames.length > 0 ? (
169 <Text selectable className="font-mono text-xs leading-snug text-foreground-muted">
170 {record.frames.slice(0, 4).join("\n")}
171 </Text>
172 ) : null}
173 </View>
174 );
175}
apps/mobile/src/features/diagnostics/crash-log-model.test.ts created+74
...@@ -0,0 +1,74 @@
1import { describe, expect, it } from "vite-plus/test";
2
3import { formatStartupCrashReport, parseStartupCrashRecords } from "./crash-log-model";
4
5// Verbatim shape of the entry expo-updates wrote for the build 56 launch crash.
6const BUNDLE =
7 "/Users/expo/workingdir/build/apps/mobile/ios/build/Build/Intermediates.noindex/ArchiveIntermediates/T3Code/BuildProductsPath/Release-iphoneos/main.jsbundle";
8const FATAL = {
9 timestamp: 1789277752000,
10 level: "error",
11 code: "JSRuntimeError",
12 message: [
13 "ErrorRecovery fatal exception: Fatal error: Time: 1789277752033.127930",
14 "Domain: RCTErrorDomain",
15 "Code: 0",
16 "Description: Unhandled JS Exception: TypeError: Cannot read property 'defaultModelSelection' of null",
17 "",
18 "This error is located at:",
19 ` at NewTaskFlowProvider (${BUNDLE}:590585:51)`,
20 ` at NavigationProvider (${BUNDLE}:139067:3)`,
21 " at RNSSafeAreaView (<anonymous>)",
22 ].join("\n"),
23};
24
25describe("parseStartupCrashRecords", () => {
26 it("extracts the JS error and a compact component stack from a fatal entry", () => {
27 const [record] = parseStartupCrashRecords([FATAL]);
28 expect(record?.description).toBe(
29 "TypeError: Cannot read property 'defaultModelSelection' of null",
30 );
31 expect(record?.frames).toEqual([
32 "NewTaskFlowProvider (main.jsbundle:590585:51)",
33 "NavigationProvider (main.jsbundle:139067:3)",
34 "RNSSafeAreaView",
35 ]);
36 expect(record?.detail).toContain("Domain: RCTErrorDomain");
37 });
38
39 it("ignores update-check noise and orders crashes newest first", () => {
40 const records = parseStartupCrashRecords([
41 { timestamp: 1, level: "info", code: "NoUpdatesAvailable", message: "checked" },
42 { ...FATAL, timestamp: 10 },
43 { timestamp: 5, level: "error", code: "UpdateFailedToLoad", message: "nope" },
44 { ...FATAL, timestamp: 20 },
45 ]);
46 expect(records.map((record) => record.timestamp)).toEqual([20, 10]);
47 });
48
49 it("skips a runtime error entry without a description line", () => {
50 expect(
51 parseStartupCrashRecords([
52 { ...FATAL, message: "ErrorRecovery fatal exception: Fatal exception: Name: x" },
53 ]),
54 ).toEqual([]);
55 });
56});
57
58describe("formatStartupCrashReport", () => {
59 it("writes the app version and each crash's full detail", () => {
60 const report = formatStartupCrashReport(parseStartupCrashRecords([FATAL]), {
61 version: "1.1.1",
62 build: "56",
63 });
64 expect(report.startsWith("T3 Code 1.1.1 (56)\n")).toBe(true);
65 expect(report).toContain("2026-09-13T05:35:52.000Z");
66 expect(report).toContain("at NewTaskFlowProvider");
67 });
68
69 it("says so when nothing was recorded", () => {
70 expect(formatStartupCrashReport([], { version: "1.1.1", build: "56" })).toBe(
71 "T3 Code 1.1.1 (56)\nNo startup crashes recorded.",
72 );
73 });
74});
apps/mobile/src/features/diagnostics/crash-log-model.ts created+83
...@@ -0,0 +1,83 @@
1/**
2 * The shape of an expo-updates log entry we care about. Mirrors
3 * `UpdatesLogEntry` structurally so the model needs no native module at test
4 * time.
5 */
6export interface UpdatesLogEntryLike {
7 readonly timestamp: number;
8 readonly message: string;
9 readonly code: string;
10 readonly level: string;
11}
12
13/** One fatal JavaScript error that took the app down at startup. */
14export interface StartupCrashRecord {
15 readonly timestamp: number;
16 /** The `Description:` line, minus the "Unhandled JS Exception:" prefix. */
17 readonly description: string;
18 /** Component stack (`at Name (bundle:line:col)`), one frame per entry. */
19 readonly frames: ReadonlyArray<string>;
20 /** Everything after the message header, verbatim, for copying. */
21 readonly detail: string;
22}
23
24const FATAL_PREFIX = "ErrorRecovery fatal exception: ";
25const DESCRIPTION_PREFIX = "Description: ";
26const UNHANDLED_PREFIX = "Unhandled JS Exception: ";
27
28/**
29 * Keep only the JS runtime fatals expo-updates' ErrorRecovery wrote while
30 * aborting the process. Everything else in that log (update checks, asset
31 * loads) is noise for a "why did the app crash" question.
32 */
33export function parseStartupCrashRecords(
34 entries: ReadonlyArray<UpdatesLogEntryLike>,
35): ReadonlyArray<StartupCrashRecord> {
36 const records: StartupCrashRecord[] = [];
37 for (const entry of entries) {
38 if (entry.code !== "JSRuntimeError" || !entry.message.startsWith(FATAL_PREFIX)) continue;
39 const body = entry.message.slice(FATAL_PREFIX.length);
40 const lines = body.split("\n");
41 const descriptionLine = lines.find((line) => line.startsWith(DESCRIPTION_PREFIX));
42 if (descriptionLine === undefined) continue;
43 let description = descriptionLine.slice(DESCRIPTION_PREFIX.length).trim();
44 if (description.startsWith(UNHANDLED_PREFIX)) {
45 description = description.slice(UNHANDLED_PREFIX.length);
46 }
47 const frames = lines
48 .map((line) => line.trim())
49 .filter((line) => line.startsWith("at "))
50 .map(compactFrame);
51 records.push({ timestamp: entry.timestamp, description, frames, detail: body.trim() });
52 }
53 // Newest first; the log appends in time order.
54 return records.sort((left, right) => right.timestamp - left.timestamp);
55}
56
57/**
58 * `at NewTaskFlowProvider (/Users/expo/…/main.jsbundle:590585:51)` reads as
59 * `NewTaskFlowProvider (main.jsbundle:590585:51)`. The absolute build path is
60 * the same on every frame and says nothing.
61 */
62function compactFrame(line: string): string {
63 const match = /^at (.+?) \((.*)\)$/.exec(line);
64 if (!match) return line.slice("at ".length);
65 const [, name = "", location = ""] = match;
66 const file = location.slice(location.lastIndexOf("/") + 1);
67 return location === "<anonymous>" ? name : `${name} (${file})`;
68}
69
70/** The report a user pastes into an issue: every recorded crash, newest first. */
71export function formatStartupCrashReport(
72 records: ReadonlyArray<StartupCrashRecord>,
73 app: { readonly version: string; readonly build: string },
74): string {
75 const header = `T3 Code ${app.version} (${app.build})`;
76 if (records.length === 0) return `${header}\nNo startup crashes recorded.`;
77 return [
78 header,
79 ...records.map(
80 (record) => `\n--- ${new Date(record.timestamp).toISOString()} ---\n${record.detail}`,
81 ),
82 ].join("\n");
83}
apps/mobile/src/features/settings/SettingsRouteScreen.tsx+1
...@@ -821,6 +821,7 @@ function AppSettingsSection() {...@@ -821,6 +821,7 @@ function AppSettingsSection() {
821 return (821 return (
822 <SettingsSection title="App">822 <SettingsSection title="App">
823 <SettingsRow icon="internaldrive" label="Client Storage" target="SettingsClientStorage" />823 <SettingsRow icon="internaldrive" label="Client Storage" target="SettingsClientStorage" />
824 <SettingsRow icon="stethoscope" label="Diagnostics" target="SettingsDiagnostics" />
824 <SettingsRow825 <SettingsRow
825 icon="doc.on.doc"826 icon="doc.on.doc"
826 label="Open source licenses"827 label="Open source licenses"
apps/mobile/src/features/settings/components/settings-sheet-targets.ts+1
...@@ -4,6 +4,7 @@ export type SettingsSheetTarget =...@@ -4,6 +4,7 @@ export type SettingsSheetTarget =
4 | "SettingsAppearance"4 | "SettingsAppearance"
5 | "SettingsProjectGrouping"5 | "SettingsProjectGrouping"
6 | "SettingsClientStorage"6 | "SettingsClientStorage"
7 | "SettingsDiagnostics"
7 | "SettingsOpenSourceLicenses"8 | "SettingsOpenSourceLicenses"
8 | "SettingsUsage";9 | "SettingsUsage";
910
docs/user/install.md+6
...@@ -60,6 +60,12 @@ Install T3 Code from the...@@ -60,6 +60,12 @@ Install T3 Code from the
60The phone connects to a server on another machine. Follow60The phone connects to a server on another machine. Follow
61[remote access](./remote-access.md) to link it through T3 Connect or a pairing URL.61[remote access](./remote-access.md) to link it through T3 Connect or a pairing URL.
6262
63If the app crashes during launch, open Settings → Diagnostics on the next launch
64that succeeds. It lists startup crashes from the last 7 days with the error and
65component stack that store crash reports leave out. Copy the report and paste it
66into a GitHub issue. Error messages can quote values from the app, so read it over
67before sharing.
68
63## Providers69## Providers
6470
65Open **Settings → Providers** in the web or desktop app, select the environment,71Open **Settings → Providers** in the web or desktop app, select the environment,