authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-09-04 12:07:23-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-04 12:07:23-07:00
log77b655c47b82c9b2aad09e447117b7618a16e21d
tree7cb24b456eb805bde59b3dd722b836908cf5ce6f
parentd7cf8aaa8d4fbcbdd523b4f4bc86fda5c47b4a70
signature Signed by PGP key B5690EEEBB952194

perf(mobile): defer file preview highlighter startup (#9752)


5 files changed, 137 insertions(+), 404 deletions(-)

apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx+111-114
...@@ -48,7 +48,6 @@ import {...@@ -48,7 +48,6 @@ import {
48 NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED,48 NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED,
49} from "../layout/native-mail-search-toolbar";49} from "../layout/native-mail-search-toolbar";
50import { WorkspaceSidebarToolbar } from "../layout/workspace-sidebar-toolbar";50import { WorkspaceSidebarToolbar } from "../layout/workspace-sidebar-toolbar";
51import { ReviewHighlighterProvider } from "../review/ReviewHighlighterProvider";
52import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";51import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";
53import { ThreadRouteScreen } from "../threads/ThreadRouteScreen";52import { ThreadRouteScreen } from "../threads/ThreadRouteScreen";
54import { FileMarkdownPreview } from "./FileMarkdownPreview";53import { FileMarkdownPreview } from "./FileMarkdownPreview";
...@@ -823,123 +822,121 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) {...@@ -823,123 +822,121 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) {
823 : [projectName, parentDir].filter(Boolean).join(" · ");822 : [projectName, parentDir].filter(Boolean).join(" · ");
824823
825 return (824 return (
826 <ReviewHighlighterProvider>825 <View className="flex-1 bg-sheet">
827 <View className="flex-1 bg-sheet">826 <NativeStackScreenOptions
828 <NativeStackScreenOptions827 options={{
829 options={{828 // Static header config lives in Stack.tsx (SOLID_HEADER_OPTIONS: solid
830 // Static header config lives in Stack.tsx (SOLID_HEADER_OPTIONS: solid829 // sheet-colored header — this route's content scrolls internally, so
831 // sheet-colored header — this route's content scrolls internally, so830 // there is nothing for glass to sample). Only dynamic values here.
832 // there is nothing for glass to sample). Only dynamic values here.831 headerShown: !isAndroid,
833 headerShown: !isAndroid,832 headerTintColor: iconColor,
834 headerTintColor: iconColor,833 headerTitle: basename(relativePath),
835 headerTitle: basename(relativePath),834 title: basename(relativePath),
836 title: basename(relativePath),835 unstable_headerSubtitle:
837 unstable_headerSubtitle:836 Platform.OS === "ios" && headerSubtitle.length > 0 ? headerSubtitle : undefined,
838 Platform.OS === "ios" && headerSubtitle.length > 0 ? headerSubtitle : undefined,837 }}
839 }}838 />
839 {isAndroid ? (
840 <AndroidScreenHeader
841 title={basename(relativePath)}
842 subtitle={headerSubtitle}
843 onBack={handleBack}
844 trailing={
845 <>
846 {fileInspector.supported ? (
847 <AndroidHeaderIconButton
848 accessibilityLabel={
849 panes.auxiliaryPaneVisible ? "Hide file navigator" : "Show file navigator"
850 }
851 icon="sidebar.right"
852 onPress={toggleAuxiliaryPane}
853 />
854 ) : null}
855 <ControlPillMenu
856 actions={androidFileMenuActions}
857 isAnchoredToRight
858 title="File actions"
859 onPressAction={handleAndroidFileMenuAction}
860 >
861 <AndroidHeaderIconButton accessibilityLabel="File actions" icon="ellipsis" />
862 </ControlPillMenu>
863 </>
864 }
840 />865 />
841 {isAndroid ? (866 ) : null}
842 <AndroidScreenHeader867 <WorkspaceSidebarToolbar>
843 title={basename(relativePath)}868 {fileInspector.supported ? (
844 subtitle={headerSubtitle}869 <NativeHeaderToolbar.Button
845 onBack={handleBack}870 accessibilityLabel="Return to chat"
846 trailing={871 icon="chevron.left"
847 <>872 onPress={handleReturnToThread}
848 {fileInspector.supported ? (873 />
849 <AndroidHeaderIconButton874 ) : null}
850 accessibilityLabel={875 </WorkspaceSidebarToolbar>
851 panes.auxiliaryPaneVisible ? "Hide file navigator" : "Show file navigator"876 <NativeHeaderToolbar placement="right">
852 }877 {fileInspector.supported ? (
853 icon="sidebar.right"878 <NativeHeaderToolbar.Button
854 onPress={toggleAuxiliaryPane}879 accessibilityLabel={
855 />880 panes.auxiliaryPaneVisible ? "Hide file navigator" : "Show file navigator"
856 ) : null}
857 <ControlPillMenu
858 actions={androidFileMenuActions}
859 isAnchoredToRight
860 title="File actions"
861 onPressAction={handleAndroidFileMenuAction}
862 >
863 <AndroidHeaderIconButton accessibilityLabel="File actions" icon="ellipsis" />
864 </ControlPillMenu>
865 </>
866 }881 }
882 icon="sidebar.right"
883 onPress={toggleAuxiliaryPane}
884 separateBackground
867 />885 />
868 ) : null}886 ) : null}
869 <WorkspaceSidebarToolbar>887 <NativeHeaderToolbar.Menu accessibilityLabel="File actions" icon="ellipsis">
870 {fileInspector.supported ? (888 {fileMenuActions.some(({ inline }) => inline) ? (
871 <NativeHeaderToolbar.Button889 <NativeHeaderToolbar.Menu inline>
872 accessibilityLabel="Return to chat"890 {fileMenuActions
873 icon="chevron.left"891 .filter(({ inline }) => inline)
874 onPress={handleReturnToThread}892 .map((action) => (
875 />893 <NativeHeaderToolbar.MenuAction
876 ) : null}894 key={action.id}
877 </WorkspaceSidebarToolbar>895 icon={action.icon}
878 <NativeHeaderToolbar placement="right">896 isOn={action.id === resolvedActiveMode}
879 {fileInspector.supported ? (897 onPress={action.onPress}
880 <NativeHeaderToolbar.Button898 >
881 accessibilityLabel={899 {action.title}
882 panes.auxiliaryPaneVisible ? "Hide file navigator" : "Show file navigator"900 </NativeHeaderToolbar.MenuAction>
883 }901 ))}
884 icon="sidebar.right"902 </NativeHeaderToolbar.Menu>
885 onPress={toggleAuxiliaryPane}
886 separateBackground
887 />
888 ) : null}903 ) : null}
889 <NativeHeaderToolbar.Menu accessibilityLabel="File actions" icon="ellipsis">904 {fileMenuActions
890 {fileMenuActions.some(({ inline }) => inline) ? (905 .filter(({ inline }) => !inline)
891 <NativeHeaderToolbar.Menu inline>906 .map((action) => (
892 {fileMenuActions907 <NativeHeaderToolbar.MenuAction
893 .filter(({ inline }) => inline)908 key={action.id}
894 .map((action) => (909 icon={action.icon}
895 <NativeHeaderToolbar.MenuAction910 onPress={action.onPress}
896 key={action.id}911 >
897 icon={action.icon}912 {action.title}
898 isOn={action.id === resolvedActiveMode}913 </NativeHeaderToolbar.MenuAction>
899 onPress={action.onPress}914 ))}
900 >915 </NativeHeaderToolbar.Menu>
901 {action.title}916 </NativeHeaderToolbar>
902 </NativeHeaderToolbar.MenuAction>917 <FileContent
903 ))}918 key={previewKey}
904 </NativeHeaderToolbar.Menu>919 activeMode={resolvedActiveMode}
905 ) : null}920 cwd={cwd}
906 {fileMenuActions921 environmentId={environmentId}
907 .filter(({ inline }) => !inline)922 previewUri={previewUri}
908 .map((action) => (923 previewFailure={assetPreview._tag === "Failure" ? assetPreview.reason : null}
909 <NativeHeaderToolbar.MenuAction924 onRetryPreview={handleRetryPreview}
910 key={action.id}925 videoSource={videoSource}
911 icon={action.icon}926 mediaSource={mediaSource}
912 onPress={action.onPress}927 resolveVideoUri={assetPreview.refresh}
913 >928 fileContents={fileData?.contents ?? null}
914 {action.title}929 fileError={fileQuery.error}
915 </NativeHeaderToolbar.MenuAction>930 initialLine={targetLine}
916 ))}931 relativePath={relativePath}
917 </NativeHeaderToolbar.Menu>932 threadId={threadId}
918 </NativeHeaderToolbar>933 truncated={fileData?.truncated ?? false}
919 <FileContent934 onRefresh={() => fileQuery.refresh()}
920 key={previewKey}935 />
921 activeMode={resolvedActiveMode}936 <FilePreviewModal
922 cwd={cwd}937 source={fullScreenPreview}
923 environmentId={environmentId}938 onRequestClose={() => setFullScreenPreview(null)}
924 previewUri={previewUri}939 />
925 previewFailure={assetPreview._tag === "Failure" ? assetPreview.reason : null}940 </View>
926 onRetryPreview={handleRetryPreview}
927 videoSource={videoSource}
928 mediaSource={mediaSource}
929 resolveVideoUri={assetPreview.refresh}
930 fileContents={fileData?.contents ?? null}
931 fileError={fileQuery.error}
932 initialLine={targetLine}
933 relativePath={relativePath}
934 threadId={threadId}
935 truncated={fileData?.truncated ?? false}
936 onRefresh={() => fileQuery.refresh()}
937 />
938 <FilePreviewModal
939 source={fullScreenPreview}
940 onRequestClose={() => setFullScreenPreview(null)}
941 />
942 </View>
943 </ReviewHighlighterProvider>
944 );941 );
945}942}
apps/mobile/src/features/review/ReviewHighlighterProvider.tsx deleted-20
...@@ -1,20 +0,0 @@
1import { createContext, type ReactNode, useMemo } from "react";
2
3import { type ReviewHighlighterState, useReviewHighlighterState } from "./reviewHighlighterState";
4
5const ReviewHighlighterContext = createContext<ReviewHighlighterState>({
6 engine: null,
7 error: null,
8 status: "idle",
9});
10
11export function ReviewHighlighterProvider(props: { readonly children: ReactNode }) {
12 const value = useReviewHighlighterState();
13 const contextValue = useMemo(() => value, [value]);
14
15 return (
16 <ReviewHighlighterContext.Provider value={contextValue}>
17 {props.children}
18 </ReviewHighlighterContext.Provider>
19 );
20}
apps/mobile/src/features/review/reviewHighlighterState.test.ts deleted-90
...@@ -1,90 +0,0 @@
1import { assert, beforeEach, it } from "vite-plus/test";
2import { AtomRegistry } from "effect/unstable/reactivity";
3
4import {
5 createReviewHighlighterManager,
6 IDLE_REVIEW_HIGHLIGHTER_STATE,
7} from "./reviewHighlighterState";
8
9let registry = AtomRegistry.make();
10
11beforeEach(() => {
12 registry.dispose();
13 registry = AtomRegistry.make();
14});
15
16function flushAsyncWork(): Promise<void> {
17 return Promise.resolve().then(() => undefined);
18}
19
20it("initializes review highlighter state once", async () => {
21 let prepareCalls = 0;
22 let languageCalls = 0;
23 let engineCalls = 0;
24 const manager = createReviewHighlighterManager({
25 getRegistry: () => registry,
26 loader: {
27 prepare: async () => {
28 prepareCalls += 1;
29 },
30 prepareLanguages: async () => {
31 languageCalls += 1;
32 },
33 getEngine: async () => {
34 engineCalls += 1;
35 return "javascript";
36 },
37 },
38 });
39
40 assert.deepStrictEqual(manager.getSnapshot(), IDLE_REVIEW_HIGHLIGHTER_STATE);
41
42 await Promise.all([manager.initialize(), manager.initialize()]);
43 await manager.initialize();
44
45 assert.strictEqual(prepareCalls, 1);
46 assert.strictEqual(languageCalls, 1);
47 assert.strictEqual(engineCalls, 1);
48 assert.deepStrictEqual(manager.getSnapshot(), {
49 engine: "javascript",
50 error: null,
51 status: "ready",
52 });
53});
54
55it("stores initialization failures in atom state", async () => {
56 const cause = new Error("load failed");
57 const manager = createReviewHighlighterManager({
58 getRegistry: () => registry,
59 loader: {
60 prepare: async () => {
61 throw cause;
62 },
63 prepareLanguages: async () => undefined,
64 getEngine: async () => "javascript",
65 },
66 });
67
68 void manager.initialize();
69 await flushAsyncWork();
70
71 const snapshot = manager.getSnapshot();
72 assert.strictEqual(snapshot.engine, null);
73 assert.strictEqual(snapshot.status, "error");
74 assert.strictEqual(snapshot.error?._tag, "ReviewHighlighterManagerError");
75 assert.strictEqual(snapshot.error?.operation, "prepare");
76 assert.deepStrictEqual(snapshot.error?.languages, [
77 "typescript",
78 "tsx",
79 "javascript",
80 "jsx",
81 "json",
82 "yaml",
83 "bash",
84 ]);
85 assert.strictEqual(snapshot.error?.cause, cause);
86 assert.strictEqual(
87 snapshot.error?.message,
88 "Review highlighter operation prepare failed for languages typescript, tsx, javascript, jsx, json, yaml, bash.",
89 );
90});
apps/mobile/src/features/review/reviewHighlighterState.ts deleted-179
...@@ -1,179 +0,0 @@
1import { useAtomValue } from "@effect/atom-react";
2import * as Schema from "effect/Schema";
3import { Atom, type AtomRegistry } from "effect/unstable/reactivity";
4import { useEffect } from "react";
5
6import { appAtomRegistry } from "../../state/atom-registry";
7import {
8 getActiveReviewHighlighterEngine,
9 prepareReviewHighlighter,
10 prepareReviewHighlighterLanguages,
11 type ReviewHighlighterEngine,
12} from "./shikiReviewHighlighter";
13
14export type ReviewHighlighterStatus = "idle" | "initializing" | "ready" | "error";
15
16export class ReviewHighlighterManagerError extends Schema.TaggedErrorClass<ReviewHighlighterManagerError>()(
17 "ReviewHighlighterManagerError",
18 {
19 operation: Schema.Literals(["prepare", "prepare-languages", "resolve-engine"]),
20 languages: Schema.Array(Schema.String),
21 cause: Schema.Defect(),
22 },
23) {
24 override get message(): string {
25 return `Review highlighter operation ${this.operation} failed for languages ${this.languages.join(", ")}.`;
26 }
27}
28
29export interface ReviewHighlighterState {
30 readonly engine: ReviewHighlighterEngine | null;
31 readonly error: ReviewHighlighterManagerError | null;
32 readonly status: ReviewHighlighterStatus;
33}
34
35export interface ReviewHighlighterLoader {
36 readonly prepare: () => Promise<void>;
37 readonly prepareLanguages: (languages: ReadonlyArray<string>) => Promise<void>;
38 readonly getEngine: () => Promise<ReviewHighlighterEngine>;
39}
40
41const REVIEW_INITIAL_LANGUAGES = [
42 "typescript",
43 "tsx",
44 "javascript",
45 "jsx",
46 "json",
47 "yaml",
48 "bash",
49] as const;
50
51export const IDLE_REVIEW_HIGHLIGHTER_STATE = Object.freeze<ReviewHighlighterState>({
52 engine: null,
53 error: null,
54 status: "idle",
55});
56
57const INITIALIZING_REVIEW_HIGHLIGHTER_STATE = Object.freeze<ReviewHighlighterState>({
58 engine: null,
59 error: null,
60 status: "initializing",
61});
62
63export const reviewHighlighterStateAtom = Atom.make(IDLE_REVIEW_HIGHLIGHTER_STATE).pipe(
64 Atom.keepAlive,
65 Atom.withLabel("mobile:review-highlighter"),
66);
67
68function isReviewHighlighterProviderDebugLoggingEnabled(): boolean {
69 return typeof __DEV__ !== "undefined" ? __DEV__ : false;
70}
71
72function logReviewHighlighterProviderDiagnostic(
73 message: string,
74 details?: Record<string, unknown>,
75): void {
76 if (!isReviewHighlighterProviderDebugLoggingEnabled()) {
77 return;
78 }
79
80 if (details) {
81 console.log(`[review-highlighter-provider] ${message}`, details);
82 return;
83 }
84
85 console.log(`[review-highlighter-provider] ${message}`);
86}
87
88export function createReviewHighlighterManager(config: {
89 readonly getRegistry: () => AtomRegistry.AtomRegistry;
90 readonly loader: ReviewHighlighterLoader;
91 readonly languages?: ReadonlyArray<string>;
92}) {
93 let started = false;
94 let inFlight: Promise<void> | null = null;
95
96 function getSnapshot(): ReviewHighlighterState {
97 return config.getRegistry().get(reviewHighlighterStateAtom);
98 }
99
100 function setState(state: ReviewHighlighterState): void {
101 config.getRegistry().set(reviewHighlighterStateAtom, state);
102 }
103
104 function initialize(): Promise<void> {
105 if (inFlight) {
106 return inFlight;
107 }
108
109 if (started && getSnapshot().status === "ready") {
110 return Promise.resolve();
111 }
112
113 started = true;
114 setState(INITIALIZING_REVIEW_HIGHLIGHTER_STATE);
115
116 inFlight = (async () => {
117 const startedAt = performance.now();
118 const languages = config.languages ?? REVIEW_INITIAL_LANGUAGES;
119 let operation: ReviewHighlighterManagerError["operation"] = "prepare";
120 let engine: ReviewHighlighterEngine;
121 try {
122 await config.loader.prepare();
123 operation = "prepare-languages";
124 await config.loader.prepareLanguages(languages);
125 operation = "resolve-engine";
126 engine = await config.loader.getEngine();
127 } catch (cause) {
128 const error = new ReviewHighlighterManagerError({
129 operation,
130 languages,
131 cause,
132 });
133 logReviewHighlighterProviderDiagnostic("initialization failed", { error });
134 setState({ engine: null, error, status: "error" });
135 return;
136 }
137
138 const durationMs = Math.round(performance.now() - startedAt);
139 logReviewHighlighterProviderDiagnostic("initialized", {
140 durationMs,
141 engine,
142 });
143 setState({ engine, error: null, status: "ready" });
144 })().finally(() => {
145 inFlight = null;
146 });
147
148 return inFlight;
149 }
150
151 function reset(): void {
152 started = false;
153 inFlight = null;
154 setState(IDLE_REVIEW_HIGHLIGHTER_STATE);
155 }
156
157 return {
158 getSnapshot,
159 initialize,
160 reset,
161 };
162}
163
164const reviewHighlighterManager = createReviewHighlighterManager({
165 getRegistry: () => appAtomRegistry,
166 loader: {
167 prepare: prepareReviewHighlighter,
168 prepareLanguages: prepareReviewHighlighterLanguages,
169 getEngine: getActiveReviewHighlighterEngine,
170 },
171});
172
173export function useReviewHighlighterState(): ReviewHighlighterState {
174 useEffect(() => {
175 void reviewHighlighterManager.initialize();
176 }, []);
177
178 return useAtomValue(reviewHighlighterStateAtom);
179}
apps/mobile/src/features/review/shikiReviewHighlighter.test.ts+26-1
...@@ -1,4 +1,4 @@...@@ -1,4 +1,4 @@
1import { describe, expect, it } from "vite-plus/test";1import { describe, expect, it, vi } from "vite-plus/test";
22
3import type { ReviewRenderableFile } from "./reviewModel";3import type { ReviewRenderableFile } from "./reviewModel";
4import { highlightCodeSnippet, highlightReviewFile } from "./shikiReviewHighlighter";4import { highlightCodeSnippet, highlightReviewFile } from "./shikiReviewHighlighter";
...@@ -138,3 +138,28 @@ describe("highlightCodeSnippet", () => {...@@ -138,3 +138,28 @@ describe("highlightCodeSnippet", () => {
138 expect(highlighted.flat().some((token) => token.color !== null)).toBe(true);138 expect(highlighted.flat().some((token) => token.color !== null)).toBe(true);
139 });139 });
140});140});
141
142describe("highlightSourceFile", () => {
143 it("initializes source and snippet highlighting without a warmup", async () => {
144 vi.resetModules();
145 const highlighter = await import("./shikiReviewHighlighter");
146 const source = "const answer: number = 42;";
147
148 const highlighted = await highlighter.highlightSourceFile({
149 path: "example.ts",
150 contents: source,
151 theme: "dark",
152 });
153
154 expect(
155 highlighted
156 .flat()
157 .map((token) => token.content)
158 .join(""),
159 ).toBe(source);
160 expect(highlighted.flat().some((token) => token.color !== null)).toBe(true);
161 expect(
162 await highlighter.highlightCodeSnippet({ code: source, language: "ts", theme: "dark" }),
163 ).toEqual(highlighted);
164 });
165});