From b1965901ff953182dbeb78b2d826d19774c356ba Mon Sep 17 00:00:00 2001 From: krow <59503057+copypasteitworks@users.noreply.github.com> Date: Thu, 12 Mar 2026 08:47:54 +0100 Subject: [PATCH] fix(web): surface fatal bootstrap snapshot failure --- apps/web/src/routes/-__root.browser.tsx | 360 ++++++++++++++++++++++++ apps/web/src/routes/__root.tsx | 154 +++++++++- apps/web/vitest.browser.config.ts | 1 + 3 files changed, 505 insertions(+), 10 deletions(-) create mode 100644 apps/web/src/routes/-__root.browser.tsx diff --git a/apps/web/src/routes/-__root.browser.tsx b/apps/web/src/routes/-__root.browser.tsx new file mode 100644 index 000000000..2aa0a0773 --- /dev/null +++ b/apps/web/src/routes/-__root.browser.tsx @@ -0,0 +1,360 @@ +import "../index.css"; + +import { + ORCHESTRATION_WS_METHODS, + type MessageId, + type OrchestrationReadModel, + type ProjectId, + type ServerConfig, + type ThreadId, + type WsWelcomePayload, + WS_CHANNELS, + WS_METHODS, +} from "@t3tools/contracts"; +import { RouterProvider, createMemoryHistory } from "@tanstack/react-router"; +import { HttpResponse, http, ws } from "msw"; +import { setupWorker } from "msw/browser"; +import type { ReactNode } from "react"; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { render } from "vitest-browser-react"; + +import { useComposerDraftStore } from "../composerDraftStore"; +import { getRouter } from "../router"; +import { useStore } from "../store"; + +vi.mock("../components/DiffWorkerPoolProvider", () => ({ + DiffWorkerPoolProvider: ({ children }: { children?: ReactNode }) => children ?? null, +})); + +const THREAD_ID = "thread-bootstrap-recovery-test" as ThreadId; +const PROJECT_ID = "project-1" as ProjectId; +const NOW_ISO = "2026-03-04T12:00:00.000Z"; +const SNAPSHOT_ERROR_MESSAGE = "Projection snapshot failed: malformed persisted state."; + +interface TestFixture { + snapshot: OrchestrationReadModel; + serverConfig: ServerConfig; + welcome: WsWelcomePayload; +} + +let fixture: TestFixture; +let pushSequence = 1; +let snapshotResponses: Array<"error" | "success"> = []; + +const wsLink = ws.link(/ws(s)?:\/\/.*/); + +function createBaseServerConfig(): ServerConfig { + return { + cwd: "/repo/project", + keybindingsConfigPath: "/repo/project/.t3code-keybindings.json", + keybindings: [], + issues: [], + providers: [ + { + provider: "codex", + status: "ready", + available: true, + authStatus: "authenticated", + checkedAt: NOW_ISO, + }, + ], + availableEditors: [], + }; +} + +function createMinimalSnapshot(): OrchestrationReadModel { + return { + snapshotSequence: 1, + projects: [ + { + id: PROJECT_ID, + title: "Project", + workspaceRoot: "/repo/project", + defaultModel: "gpt-5", + scripts: [], + createdAt: NOW_ISO, + updatedAt: NOW_ISO, + deletedAt: null, + }, + ], + threads: [ + { + id: THREAD_ID, + projectId: PROJECT_ID, + title: "Test thread", + model: "gpt-5", + interactionMode: "default", + runtimeMode: "full-access", + branch: "main", + worktreePath: null, + latestTurn: null, + createdAt: NOW_ISO, + updatedAt: NOW_ISO, + deletedAt: null, + messages: [ + { + id: "msg-1" as MessageId, + role: "user", + text: "hello", + turnId: null, + streaming: false, + createdAt: NOW_ISO, + updatedAt: NOW_ISO, + }, + ], + activities: [], + proposedPlans: [], + checkpoints: [], + session: { + threadId: THREAD_ID, + status: "ready", + providerName: "codex", + runtimeMode: "full-access", + activeTurnId: null, + lastError: null, + updatedAt: NOW_ISO, + }, + }, + ], + updatedAt: NOW_ISO, + }; +} + +function buildFixture(): TestFixture { + return { + snapshot: createMinimalSnapshot(), + serverConfig: createBaseServerConfig(), + welcome: { + cwd: "/repo/project", + projectName: "Project", + bootstrapProjectId: PROJECT_ID, + bootstrapThreadId: THREAD_ID, + }, + }; +} + +function resolveWsRpc(tag: string): unknown { + if (tag === WS_METHODS.serverGetConfig) { + return fixture.serverConfig; + } + if (tag === WS_METHODS.gitListBranches) { + return { + isRepo: true, + hasOriginRemote: true, + branches: [{ name: "main", current: true, isDefault: true, worktreePath: null }], + }; + } + if (tag === WS_METHODS.gitStatus) { + return { + branch: "main", + hasWorkingTreeChanges: false, + workingTree: { files: [], insertions: 0, deletions: 0 }, + hasUpstream: true, + aheadCount: 0, + behindCount: 0, + pr: null, + }; + } + if (tag === WS_METHODS.projectsSearchEntries) { + return { entries: [], truncated: false }; + } + return {}; +} + +const worker = setupWorker( + wsLink.addEventListener("connection", ({ client }) => { + pushSequence = 1; + client.send( + JSON.stringify({ + type: "push", + sequence: pushSequence++, + channel: WS_CHANNELS.serverWelcome, + data: fixture.welcome, + }), + ); + client.addEventListener("message", (event) => { + const rawData = event.data; + if (typeof rawData !== "string") return; + let request: { id: string; body: { _tag: string; [key: string]: unknown } }; + try { + request = JSON.parse(rawData); + } catch { + return; + } + const method = request.body?._tag; + if (typeof method !== "string") return; + + if (method === ORCHESTRATION_WS_METHODS.getSnapshot) { + const responseMode = snapshotResponses.shift() ?? "success"; + client.send( + JSON.stringify( + responseMode === "error" + ? { + id: request.id, + error: { + message: SNAPSHOT_ERROR_MESSAGE, + }, + } + : { + id: request.id, + result: fixture.snapshot, + }, + ), + ); + return; + } + + client.send( + JSON.stringify({ + id: request.id, + result: resolveWsRpc(method), + }), + ); + }); + }), + http.get("*/attachments/:attachmentId", () => new HttpResponse(null, { status: 204 })), + http.get("*/api/project-favicon", () => new HttpResponse(null, { status: 204 })), +); + +async function waitForElement( + query: () => T | null, + errorMessage: string, +): Promise { + let element: T | null = null; + await vi.waitFor( + () => { + element = query(); + expect(element, errorMessage).toBeTruthy(); + }, + { timeout: 8_000, interval: 16 }, + ); + return element!; +} + +async function waitForComposerEditor(): Promise { + return waitForElement( + () => document.querySelector('[data-testid="composer-editor"]'), + "App should render composer editor", + ); +} + +async function waitForNoComposerEditor(): Promise { + await vi.waitFor( + () => { + expect(document.querySelector('[data-testid="composer-editor"]')).toBeNull(); + }, + { timeout: 4_000, interval: 16 }, + ); +} + +async function waitForRecoveryView(): Promise { + return waitForElement( + () => document.querySelector('[data-testid="initial-snapshot-recovery"]'), + "Expected initial snapshot recovery view", + ); +} + +async function waitForButton(label: string): Promise { + return waitForElement( + () => + Array.from(document.querySelectorAll("button")).find((button) => + button.textContent?.includes(label), + ) ?? null, + `Expected button "${label}"`, + ); +} + +async function mountApp(): Promise<{ cleanup: () => Promise }> { + const host = document.createElement("div"); + host.style.position = "fixed"; + host.style.inset = "0"; + host.style.width = "100vw"; + host.style.height = "100vh"; + host.style.display = "grid"; + host.style.overflow = "hidden"; + document.body.append(host); + + const router = getRouter(createMemoryHistory({ initialEntries: [`/${THREAD_ID}`] })); + const screen = await render(, { container: host }); + + return { + cleanup: async () => { + await screen.unmount(); + host.remove(); + }, + }; +} + +describe("Initial snapshot recovery", () => { + beforeAll(async () => { + fixture = buildFixture(); + await worker.start({ + onUnhandledRequest: "bypass", + quiet: true, + serviceWorker: { url: "/mockServiceWorker.js" }, + }); + }); + + afterAll(async () => { + await worker.stop(); + }); + + beforeEach(() => { + localStorage.clear(); + document.body.innerHTML = ""; + pushSequence = 1; + snapshotResponses = []; + useComposerDraftStore.setState({ + draftsByThreadId: {}, + draftThreadsByThreadId: {}, + projectDraftThreadIdByProjectId: {}, + }); + useStore.setState({ + projects: [], + threads: [], + threadsHydrated: false, + }); + }); + + afterEach(() => { + document.body.innerHTML = ""; + }); + + it("renders a blocking recovery view when the initial snapshot fails", async () => { + snapshotResponses = ["error"]; + const mounted = await mountApp(); + + try { + const recoveryView = await waitForRecoveryView(); + expect(recoveryView.textContent).toContain("Couldn't load app state."); + expect(recoveryView.textContent).toContain("T3CODE_STATE_DIR"); + expect(recoveryView.textContent).toContain("CODEX_HOME"); + expect(recoveryView.textContent).toContain(SNAPSHOT_ERROR_MESSAGE); + await waitForButton("Retry snapshot"); + await waitForNoComposerEditor(); + } finally { + await mounted.cleanup(); + } + }); + + it("recovers after retrying a failed initial snapshot", async () => { + snapshotResponses = ["error", "success"]; + const mounted = await mountApp(); + + try { + await waitForRecoveryView(); + const retryButton = await waitForButton("Retry snapshot"); + retryButton.click(); + + await waitForComposerEditor(); + await vi.waitFor( + () => { + expect(document.querySelector('[data-testid="initial-snapshot-recovery"]')).toBeNull(); + }, + { timeout: 8_000, interval: 16 }, + ); + } finally { + await mounted.cleanup(); + } + }); +}); diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index 2fe07505f..20218dfb1 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -6,7 +6,7 @@ import { useNavigate, useRouterState, } from "@tanstack/react-router"; -import { useEffect, useRef } from "react"; +import { useEffect, useRef, useState, type MutableRefObject } from "react"; import { QueryClient, useQueryClient } from "@tanstack/react-query"; import { Throttler } from "@tanstack/react-pacer"; @@ -36,6 +36,11 @@ export const Route = createRootRouteWithContext<{ }); function RootRouteView() { + const [hasLoadedInitialSnapshot, setHasLoadedInitialSnapshot] = useState(false); + const [initialSnapshotError, setInitialSnapshotError] = useState(null); + const [isRetryingInitialSnapshot, setIsRetryingInitialSnapshot] = useState(false); + const retryInitialSnapshotRef = useRef<(() => Promise) | null>(null); + if (!readNativeApi()) { return (
@@ -51,14 +56,107 @@ function RootRouteView() { return ( - - - + + {initialSnapshotError ? ( + { + const retryInitialSnapshot = retryInitialSnapshotRef.current; + if (!retryInitialSnapshot) { + return Promise.resolve(); + } + + setIsRetryingInitialSnapshot(true); + return retryInitialSnapshot() + .then(() => undefined) + .finally(() => { + setIsRetryingInitialSnapshot(false); + }); + }} + /> + ) : ( + <> + + + + )} ); } +function InitialSnapshotRecoveryView(props: { + error: unknown; + isRetrying: boolean; + onRetry: () => Promise; +}) { + const message = errorMessage(props.error); + const details = errorDetails(props.error); + + return ( +
+
+
+
+
+ +
+

+ {APP_DISPLAY_NAME} +

+

+ Couldn't load app state. +

+

+ T3 Code could not load its initial state from persisted data. This can happen if + `T3CODE_STATE_DIR`, `CODEX_HOME`, or other saved app state has become invalid or + corrupted. +

+

{message}

+

+ Retry the snapshot load first. If the problem keeps happening, reload the app and retry + with a fresh `T3CODE_STATE_DIR`. If provider runtime state may be involved, also retry + with a fresh `CODEX_HOME`. +

+ +
+ + +
+ +
+ + Show error details + Hide error details + +
+            {details}
+          
+
+
+
+ ); +} + function RootRouteErrorView({ error, reset }: ErrorComponentProps) { const message = errorMessage(error); const details = errorDetails(error); @@ -130,7 +228,17 @@ function errorDetails(error: unknown): string { } } -function EventRouter() { +function EventRouter({ + hasLoadedInitialSnapshot, + setHasLoadedInitialSnapshot, + setInitialSnapshotError, + retryInitialSnapshotRef, +}: { + hasLoadedInitialSnapshot: boolean; + setHasLoadedInitialSnapshot: (loaded: boolean) => void; + setInitialSnapshotError: (error: unknown | null) => void; + retryInitialSnapshotRef: MutableRefObject<(() => Promise) | null>; +}) { const syncServerReadModel = useStore((store) => store.syncServerReadModel); const setProjectExpanded = useStore((store) => store.setProjectExpanded); const removeOrphanedTerminalStates = useTerminalStateStore( @@ -141,8 +249,10 @@ function EventRouter() { const pathname = useRouterState({ select: (state) => state.location.pathname }); const pathnameRef = useRef(pathname); const handledBootstrapThreadIdRef = useRef(null); + const hasLoadedInitialSnapshotRef = useRef(hasLoadedInitialSnapshot); pathnameRef.current = pathname; + hasLoadedInitialSnapshotRef.current = hasLoadedInitialSnapshot; useEffect(() => { const api = readNativeApi(); @@ -156,6 +266,9 @@ function EventRouter() { const flushSnapshotSync = async (): Promise => { const snapshot = await api.orchestration.getSnapshot(); if (disposed) return; + hasLoadedInitialSnapshotRef.current = true; + setHasLoadedInitialSnapshot(true); + setInitialSnapshotError(null); latestSequence = Math.max(latestSequence, snapshot.snapshotSequence); syncServerReadModel(snapshot); clearPromotedDraftThreads(new Set(snapshot.threads.map((t) => t.id))); @@ -173,19 +286,32 @@ function EventRouter() { } }; - const syncSnapshot = async () => { + const syncSnapshot = async (): Promise => { if (syncing) { pending = true; - return; + return hasLoadedInitialSnapshotRef.current; } syncing = true; pending = false; try { await flushSnapshotSync(); - } catch { + return true; + } catch (error) { + if (!hasLoadedInitialSnapshotRef.current) { + setInitialSnapshotError(error); + return false; + } + // Keep prior state and wait for next domain event to trigger a resync. + return true; + } finally { + syncing = false; } - syncing = false; + }; + + retryInitialSnapshotRef.current = syncSnapshot; + const clearRetryInitialSnapshot = () => { + retryInitialSnapshotRef.current = null; }; const domainEventFlushThrottler = new Throttler( @@ -231,10 +357,13 @@ function EventRouter() { }); const unsubWelcome = onServerWelcome((payload) => { void (async () => { - await syncSnapshot(); + const synced = await syncSnapshot(); if (disposed) { return; } + if (!synced) { + return; + } if (!payload.bootstrapProjectId || !payload.bootstrapThreadId) { return; @@ -305,11 +434,16 @@ function EventRouter() { unsubTerminalEvent(); unsubWelcome(); unsubServerConfigUpdated(); + clearRetryInitialSnapshot(); }; }, [ + hasLoadedInitialSnapshot, navigate, queryClient, removeOrphanedTerminalStates, + retryInitialSnapshotRef, + setHasLoadedInitialSnapshot, + setInitialSnapshotError, setProjectExpanded, syncServerReadModel, ]); diff --git a/apps/web/vitest.browser.config.ts b/apps/web/vitest.browser.config.ts index c67fdfbe9..aae0a07e6 100644 --- a/apps/web/vitest.browser.config.ts +++ b/apps/web/vitest.browser.config.ts @@ -18,6 +18,7 @@ export default mergeConfig( include: [ "src/components/ChatView.browser.tsx", "src/components/KeybindingsToast.browser.tsx", + "src/routes/-__root.browser.tsx", ], browser: { enabled: true,