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 <noreply@anthropic.com>
This commit is contained in:
le king fu 2026-10-12 20:18:18 -04:00
parent 2bd347b4a0
commit 3882df772b
3 changed files with 191 additions and 42 deletions

View file

@ -1,5 +1,5 @@
import { BrowserRouter, Routes, Route } from "react-router-dom"; 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 { useTranslation } from "react-i18next";
import { useProfile } from "./contexts/ProfileContext"; import { useProfile } from "./contexts/ProfileContext";
import AppShell from "./components/layout/AppShell"; import AppShell from "./components/layout/AppShell";
@ -30,55 +30,22 @@ import ChangelogPage from "./pages/ChangelogPage";
import ProfileSelectionPage from "./pages/ProfileSelectionPage"; import ProfileSelectionPage from "./pages/ProfileSelectionPage";
import ErrorPage from "./components/shared/ErrorPage"; import ErrorPage from "./components/shared/ErrorPage";
import RequireFeature from "./components/shared/RequireFeature"; import RequireFeature from "./components/shared/RequireFeature";
import { startProfileConnection } from "./utils/startupConnection";
const STARTUP_TIMEOUT_MS = 10_000;
const MAX_RETRIES = 3;
const RETRY_DELAY_MS = 1_000;
export default function App() { export default function App() {
const { t } = useTranslation(); const { t } = useTranslation();
const { activeProfile, isLoading, refreshKey, connectActiveProfile } = useProfile(); const { activeProfile, isLoading, refreshKey, connectActiveProfile } = useProfile();
const [dbReady, setDbReady] = useState(false); const [dbReady, setDbReady] = useState(false);
const [startupError, setStartupError] = useState<string | null>(null); const [startupError, setStartupError] = useState<string | null>(null);
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const cancelledRef = useRef(false);
useEffect(() => { useEffect(() => {
if (activeProfile && !isLoading) { if (!activeProfile || isLoading) return;
setDbReady(false); return startProfileConnection({
setStartupError(null); connect: connectActiveProfile,
cancelledRef.current = false; setDbReady,
setStartupError,
timeoutRef.current = setTimeout(() => { timeoutMessage: t("error.startupTimeout"),
setStartupError(t("error.startupTimeout")); });
}, STARTUP_TIMEOUT_MS);
const attemptConnect = async (attempt: number): Promise<void> => {
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);
};
}, [activeProfile, isLoading, connectActiveProfile, t]); }, [activeProfile, isLoading, connectActiveProfile, t]);
if (isLoading) { if (isLoading) {

View file

@ -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<void>((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
}
function setup(connect: () => Promise<void>) {
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<typeof vi.fn>) => 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]]);
});
});

View file

@ -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<void>;
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<void> => {
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);
};
}