From 3882df772bdf5743e486b46cfc4d44c785e84194 Mon Sep 17 00:00:00 2001 From: le king fu Date: Mon, 12 Oct 2026 20:18:18 -0400 Subject: [PATCH] fix(startup): clear the startup error when the profile opens after the delay The 10 s startup delay of App.tsx sets the error page while the profile is still opening; a connection that succeeded afterwards set dbReady but left the error, so the error page stayed. The startup logic moves to src/utils/startupConnection.ts (startProfileConnection), which clears the error on success and is tested with fake timers. Co-Authored-By: Claude Opus 5.5 --- src/App.tsx | 51 +++--------- src/utils/startupConnection.test.ts | 116 ++++++++++++++++++++++++++++ src/utils/startupConnection.ts | 66 ++++++++++++++++ 3 files changed, 191 insertions(+), 42 deletions(-) create mode 100644 src/utils/startupConnection.test.ts create mode 100644 src/utils/startupConnection.ts diff --git a/src/App.tsx b/src/App.tsx index 4b81f56..325b9f3 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,5 +1,5 @@ import { BrowserRouter, Routes, Route } from "react-router-dom"; -import { useEffect, useState, useRef } from "react"; +import { useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import { useProfile } from "./contexts/ProfileContext"; import AppShell from "./components/layout/AppShell"; @@ -30,55 +30,22 @@ import ChangelogPage from "./pages/ChangelogPage"; import ProfileSelectionPage from "./pages/ProfileSelectionPage"; import ErrorPage from "./components/shared/ErrorPage"; import RequireFeature from "./components/shared/RequireFeature"; - -const STARTUP_TIMEOUT_MS = 10_000; -const MAX_RETRIES = 3; -const RETRY_DELAY_MS = 1_000; +import { startProfileConnection } from "./utils/startupConnection"; export default function App() { const { t } = useTranslation(); const { activeProfile, isLoading, refreshKey, connectActiveProfile } = useProfile(); const [dbReady, setDbReady] = useState(false); const [startupError, setStartupError] = useState(null); - const timeoutRef = useRef | null>(null); - const cancelledRef = useRef(false); useEffect(() => { - if (activeProfile && !isLoading) { - setDbReady(false); - setStartupError(null); - cancelledRef.current = false; - - timeoutRef.current = setTimeout(() => { - setStartupError(t("error.startupTimeout")); - }, STARTUP_TIMEOUT_MS); - - const attemptConnect = async (attempt: number): Promise => { - try { - await connectActiveProfile(); - if (cancelledRef.current) return; - if (timeoutRef.current) clearTimeout(timeoutRef.current); - setDbReady(true); - } catch (err) { - if (cancelledRef.current) return; - console.error(`Failed to connect profile (attempt ${attempt}/${MAX_RETRIES}):`, err); - if (attempt < MAX_RETRIES) { - await new Promise((r) => setTimeout(r, RETRY_DELAY_MS)); - if (!cancelledRef.current) return attemptConnect(attempt + 1); - } else { - if (timeoutRef.current) clearTimeout(timeoutRef.current); - setStartupError(err instanceof Error ? err.message : String(err)); - } - } - }; - - attemptConnect(1); - } - - return () => { - cancelledRef.current = true; - if (timeoutRef.current) clearTimeout(timeoutRef.current); - }; + if (!activeProfile || isLoading) return; + return startProfileConnection({ + connect: connectActiveProfile, + setDbReady, + setStartupError, + timeoutMessage: t("error.startupTimeout"), + }); }, [activeProfile, isLoading, connectActiveProfile, t]); if (isLoading) { diff --git a/src/utils/startupConnection.test.ts b/src/utils/startupConnection.test.ts new file mode 100644 index 0000000..e5f8428 --- /dev/null +++ b/src/utils/startupConnection.test.ts @@ -0,0 +1,116 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { + MAX_RETRIES, + RETRY_DELAY_MS, + STARTUP_TIMEOUT_MS, + startProfileConnection, +} from "./startupConnection"; + +// The startup of App.tsx: what the error page and the app see while the +// active profile opens (#395). + +function deferred() { + let resolve: () => void = () => {}; + let reject: (e: unknown) => void = () => {}; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + +function setup(connect: () => Promise) { + const setDbReady = vi.fn(); + const setStartupError = vi.fn(); + const cleanup = startProfileConnection({ + connect, + setDbReady, + setStartupError, + timeoutMessage: "startup timeout", + }); + // The state React would hold: the last value each setter received. + const last = (fn: ReturnType) => fn.mock.calls[fn.mock.calls.length - 1]?.[0]; + return { + cleanup, + setDbReady, + setStartupError, + error: () => last(setStartupError), + ready: () => last(setDbReady), + }; +} + +beforeEach(() => { + vi.useFakeTimers(); + vi.spyOn(console, "error").mockImplementation(() => {}); +}); + +afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); +}); + +describe("startProfileConnection", () => { + it("shows the app once the profile opens in time", async () => { + const s = setup(async () => {}); + await vi.advanceTimersByTimeAsync(0); + + expect(s.ready()).toBe(true); + expect(s.error()).toBeNull(); + await vi.advanceTimersByTimeAsync(STARTUP_TIMEOUT_MS); + expect(s.error()).toBeNull(); + }); + + it("shows the error page after the delay while the profile is still opening", async () => { + const opening = deferred(); + const s = setup(() => opening.promise); + + await vi.advanceTimersByTimeAsync(STARTUP_TIMEOUT_MS - 1); + expect(s.error()).toBeNull(); + await vi.advanceTimersByTimeAsync(1); + expect(s.error()).toBe("startup timeout"); + expect(s.ready()).toBe(false); + }); + + it("leaves the error page when the profile opens after the delay (#395)", async () => { + // A catch-up of a large profile can outlast the delay (ADR 0021). + const opening = deferred(); + const s = setup(() => opening.promise); + await vi.advanceTimersByTimeAsync(STARTUP_TIMEOUT_MS + 500); + expect(s.error()).toBe("startup timeout"); + + opening.resolve(); + await vi.advanceTimersByTimeAsync(0); + + expect(s.error()).toBeNull(); + expect(s.ready()).toBe(true); + }); + + it("retries, then shows the last error", async () => { + const connect = vi.fn(async () => { + throw new Error("no database"); + }); + const s = setup(connect); + + await vi.advanceTimersByTimeAsync(RETRY_DELAY_MS * MAX_RETRIES); + + expect(connect).toHaveBeenCalledTimes(MAX_RETRIES); + expect(s.error()).toBe("no database"); + expect(s.ready()).toBe(false); + // The delay is disarmed: the real error stays. + await vi.advanceTimersByTimeAsync(STARTUP_TIMEOUT_MS); + expect(s.error()).toBe("no database"); + }); + + it("does nothing more once cleaned up", async () => { + const opening = deferred(); + const s = setup(() => opening.promise); + s.cleanup(); + + await vi.advanceTimersByTimeAsync(STARTUP_TIMEOUT_MS); + opening.resolve(); + await vi.advanceTimersByTimeAsync(0); + + expect(s.setStartupError.mock.calls).toEqual([[null]]); + expect(s.setDbReady.mock.calls).toEqual([[false]]); + }); +}); diff --git a/src/utils/startupConnection.ts b/src/utils/startupConnection.ts new file mode 100644 index 0000000..31ec533 --- /dev/null +++ b/src/utils/startupConnection.ts @@ -0,0 +1,66 @@ +// Opening the active profile at startup, out of App.tsx so it can be tested +// without rendering the router (#395). + +/** Delay after which the startup error page shows while the profile opens. */ +export const STARTUP_TIMEOUT_MS = 10_000; +export const MAX_RETRIES = 3; +export const RETRY_DELAY_MS = 1_000; + +export interface StartupConnection { + /** Opens the active profile (`connectActiveProfile`). */ + connect: () => Promise; + setDbReady: (ready: boolean) => void; + setStartupError: (error: string | null) => void; + /** Shown when the opening outlasts `STARTUP_TIMEOUT_MS`. */ + timeoutMessage: string; +} + +/** + * Open the profile, retrying up to `MAX_RETRIES` times, and show the error + * page after `STARTUP_TIMEOUT_MS` while it is still opening. The opening goes + * on past the delay (a catch-up of a large profile can, ADR 0021): when it + * then succeeds, the error is cleared and the app shows (#395). Returns the + * cleanup of the effect that calls it. + */ +export function startProfileConnection({ + connect, + setDbReady, + setStartupError, + timeoutMessage, +}: StartupConnection): () => void { + let cancelled = false; + setDbReady(false); + setStartupError(null); + + const timeout = setTimeout(() => { + setStartupError(timeoutMessage); + }, STARTUP_TIMEOUT_MS); + + const attemptConnect = async (attempt: number): Promise => { + try { + await connect(); + if (cancelled) return; + clearTimeout(timeout); + // The delay may have shown the error page already. + setStartupError(null); + setDbReady(true); + } catch (err) { + if (cancelled) return; + console.error(`Failed to connect profile (attempt ${attempt}/${MAX_RETRIES}):`, err); + if (attempt < MAX_RETRIES) { + await new Promise((r) => setTimeout(r, RETRY_DELAY_MS)); + if (!cancelled) return attemptConnect(attempt + 1); + } else { + clearTimeout(timeout); + setStartupError(err instanceof Error ? err.message : String(err)); + } + } + }; + + void attemptConnect(1); + + return () => { + cancelled = true; + clearTimeout(timeout); + }; +}