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 {
4848 NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED,
4949} from "../layout/native-mail-search-toolbar";
5050import { WorkspaceSidebarToolbar } from "../layout/workspace-sidebar-toolbar";
51import { ReviewHighlighterProvider } from "../review/ReviewHighlighterProvider";
5251import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";
5352import { ThreadRouteScreen } from "../threads/ThreadRouteScreen";
5453import { FileMarkdownPreview } from "./FileMarkdownPreview";
......@@ -823,123 +822,121 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) {
823822 : [projectName, parentDir].filter(Boolean).join(" · ");
824823
825824 return (
826 <ReviewHighlighterProvider>
827 <View className="flex-1 bg-sheet">
828 <NativeStackScreenOptions
829 options={{
830 // Static header config lives in Stack.tsx (SOLID_HEADER_OPTIONS: solid
831 // sheet-colored header — this route's content scrolls internally, so
832 // there is nothing for glass to sample). Only dynamic values here.
833 headerShown: !isAndroid,
834 headerTintColor: iconColor,
835 headerTitle: basename(relativePath),
836 title: basename(relativePath),
837 unstable_headerSubtitle:
838 Platform.OS === "ios" && headerSubtitle.length > 0 ? headerSubtitle : undefined,
839 }}
825 <View className="flex-1 bg-sheet">
826 <NativeStackScreenOptions
827 options={{
828 // Static header config lives in Stack.tsx (SOLID_HEADER_OPTIONS: solid
829 // sheet-colored header — this route's content scrolls internally, so
830 // there is nothing for glass to sample). Only dynamic values here.
831 headerShown: !isAndroid,
832 headerTintColor: iconColor,
833 headerTitle: basename(relativePath),
834 title: basename(relativePath),
835 unstable_headerSubtitle:
836 Platform.OS === "ios" && headerSubtitle.length > 0 ? headerSubtitle : undefined,
837 }}
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 }
840865 />
841 {isAndroid ? (
842 <AndroidScreenHeader
843 title={basename(relativePath)}
844 subtitle={headerSubtitle}
845 onBack={handleBack}
846 trailing={
847 <>
848 {fileInspector.supported ? (
849 <AndroidHeaderIconButton
850 accessibilityLabel={
851 panes.auxiliaryPaneVisible ? "Hide file navigator" : "Show file navigator"
852 }
853 icon="sidebar.right"
854 onPress={toggleAuxiliaryPane}
855 />
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 ) : null}
867 <WorkspaceSidebarToolbar>
868 {fileInspector.supported ? (
869 <NativeHeaderToolbar.Button
870 accessibilityLabel="Return to chat"
871 icon="chevron.left"
872 onPress={handleReturnToThread}
873 />
874 ) : null}
875 </WorkspaceSidebarToolbar>
876 <NativeHeaderToolbar placement="right">
877 {fileInspector.supported ? (
878 <NativeHeaderToolbar.Button
879 accessibilityLabel={
880 panes.auxiliaryPaneVisible ? "Hide file navigator" : "Show file navigator"
866881 }
882 icon="sidebar.right"
883 onPress={toggleAuxiliaryPane}
884 separateBackground
867885 />
868886 ) : null}
869 <WorkspaceSidebarToolbar>
870 {fileInspector.supported ? (
871 <NativeHeaderToolbar.Button
872 accessibilityLabel="Return to chat"
873 icon="chevron.left"
874 onPress={handleReturnToThread}
875 />
876 ) : null}
877 </WorkspaceSidebarToolbar>
878 <NativeHeaderToolbar placement="right">
879 {fileInspector.supported ? (
880 <NativeHeaderToolbar.Button
881 accessibilityLabel={
882 panes.auxiliaryPaneVisible ? "Hide file navigator" : "Show file navigator"
883 }
884 icon="sidebar.right"
885 onPress={toggleAuxiliaryPane}
886 separateBackground
887 />
887 <NativeHeaderToolbar.Menu accessibilityLabel="File actions" icon="ellipsis">
888 {fileMenuActions.some(({ inline }) => inline) ? (
889 <NativeHeaderToolbar.Menu inline>
890 {fileMenuActions
891 .filter(({ inline }) => inline)
892 .map((action) => (
893 <NativeHeaderToolbar.MenuAction
894 key={action.id}
895 icon={action.icon}
896 isOn={action.id === resolvedActiveMode}
897 onPress={action.onPress}
898 >
899 {action.title}
900 </NativeHeaderToolbar.MenuAction>
901 ))}
902 </NativeHeaderToolbar.Menu>
888903 ) : null}
889 <NativeHeaderToolbar.Menu accessibilityLabel="File actions" icon="ellipsis">
890 {fileMenuActions.some(({ inline }) => inline) ? (
891 <NativeHeaderToolbar.Menu inline>
892 {fileMenuActions
893 .filter(({ inline }) => inline)
894 .map((action) => (
895 <NativeHeaderToolbar.MenuAction
896 key={action.id}
897 icon={action.icon}
898 isOn={action.id === resolvedActiveMode}
899 onPress={action.onPress}
900 >
901 {action.title}
902 </NativeHeaderToolbar.MenuAction>
903 ))}
904 </NativeHeaderToolbar.Menu>
905 ) : null}
906 {fileMenuActions
907 .filter(({ inline }) => !inline)
908 .map((action) => (
909 <NativeHeaderToolbar.MenuAction
910 key={action.id}
911 icon={action.icon}
912 onPress={action.onPress}
913 >
914 {action.title}
915 </NativeHeaderToolbar.MenuAction>
916 ))}
917 </NativeHeaderToolbar.Menu>
918 </NativeHeaderToolbar>
919 <FileContent
920 key={previewKey}
921 activeMode={resolvedActiveMode}
922 cwd={cwd}
923 environmentId={environmentId}
924 previewUri={previewUri}
925 previewFailure={assetPreview._tag === "Failure" ? assetPreview.reason : null}
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>
904 {fileMenuActions
905 .filter(({ inline }) => !inline)
906 .map((action) => (
907 <NativeHeaderToolbar.MenuAction
908 key={action.id}
909 icon={action.icon}
910 onPress={action.onPress}
911 >
912 {action.title}
913 </NativeHeaderToolbar.MenuAction>
914 ))}
915 </NativeHeaderToolbar.Menu>
916 </NativeHeaderToolbar>
917 <FileContent
918 key={previewKey}
919 activeMode={resolvedActiveMode}
920 cwd={cwd}
921 environmentId={environmentId}
922 previewUri={previewUri}
923 previewFailure={assetPreview._tag === "Failure" ? assetPreview.reason : null}
924 onRetryPreview={handleRetryPreview}
925 videoSource={videoSource}
926 mediaSource={mediaSource}
927 resolveVideoUri={assetPreview.refresh}
928 fileContents={fileData?.contents ?? null}
929 fileError={fileQuery.error}
930 initialLine={targetLine}
931 relativePath={relativePath}
932 threadId={threadId}
933 truncated={fileData?.truncated ?? false}
934 onRefresh={() => fileQuery.refresh()}
935 />
936 <FilePreviewModal
937 source={fullScreenPreview}
938 onRequestClose={() => setFullScreenPreview(null)}
939 />
940 </View>
944941 );
945942}
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 @@
1import { describe, expect, it } from "vite-plus/test";
1import { describe, expect, it, vi } from "vite-plus/test";
22
33import type { ReviewRenderableFile } from "./reviewModel";
44import { highlightCodeSnippet, highlightReviewFile } from "./shikiReviewHighlighter";
......@@ -138,3 +138,28 @@ describe("highlightCodeSnippet", () => {
138138 expect(highlighted.flat().some((token) => token.color !== null)).toBe(true);
139139 });
140140});
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});