From 6eb57685d4f1db76168a67e9fb8d8fea134ba7df Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Sat, 23 May 2026 21:35:15 -0400 Subject: [PATCH] Fix Quick Start flash when navigating to /runs with archived prefs The Runs nav link goes to /runs (no query string), so the route briefly rendered with default params (view=columns, archived=false) before a post-commit useEffect restored the URL from stored preferences. On repeat clicks the useAllRuns SWR cache for {includeArchived:false} returned zero rows immediately, flashing the Quick Start landing for users whose only runs are archived. Resolve workspace search params synchronously during render via resolveRunsWorkspaceSearchParams(), so the first frame already reflects stored prefs. The effect now just writes the URL back to match. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../app/routes/runs.preferences.test.tsx | 33 +++++++++++- apps/fabro-web/app/routes/runs.tsx | 53 +++++++++++-------- 2 files changed, 62 insertions(+), 24 deletions(-) diff --git a/apps/fabro-web/app/routes/runs.preferences.test.tsx b/apps/fabro-web/app/routes/runs.preferences.test.tsx index 7d69134b9..003e44ae1 100644 --- a/apps/fabro-web/app/routes/runs.preferences.test.tsx +++ b/apps/fabro-web/app/routes/runs.preferences.test.tsx @@ -72,9 +72,17 @@ const pageRuns: PaginatedRunList = { meta: { has_more: false, total: 1 }, }; +const queryCalls: Array<{ hook: string; args: unknown[] }> = []; + mock.module("../lib/queries", () => ({ - useAllRuns: () => ({ data: allRuns, isLoading: false }), - useRunsPage: () => ({ data: pageRuns, isLoading: false }), + useAllRuns: (...args: unknown[]) => { + queryCalls.push({ hook: "useAllRuns", args }); + return { data: allRuns, isLoading: false }; + }, + useRunsPage: (...args: unknown[]) => { + queryCalls.push({ hook: "useRunsPage", args }); + return { data: pageRuns, isLoading: false }; + }, useAuthConfig: () => ({ data: { methods: ["github"] } }), useSystemInfo: () => ({ data: { server_url: "http://127.0.0.1:32276" } }), })); @@ -166,6 +174,7 @@ beforeEach(() => { storage = new MemoryStorage(); teardownReactEnv = setupReactTestEnv(); installWindow(); + queryCalls.length = 0; }); afterEach(() => { @@ -191,6 +200,26 @@ describe("Runs workspace preference restoration", () => { expect(buttonByLabel(renderer, "List view").props["aria-pressed"]).toBe(true); }); + test("/runs applies stored list+archived prefs on the first render (no Quick Start flash)", async () => { + storage.setItem( + RUNS_PREFERENCES_STORAGE_KEY, + JSON.stringify({ version: 1, view: "list", archived: true }), + ); + + await renderRuns("/runs"); + + // The first frame the user sees must already reflect stored prefs. + // Before this was fixed, the route briefly rendered the columns view + // with includeArchived=false (default state) before a post-commit + // useEffect restored the URL, flashing the Quick Start empty state for + // users whose only runs were archived. + const firstAllRuns = queryCalls.find((c) => c.hook === "useAllRuns"); + const firstRunsPage = queryCalls.find((c) => c.hook === "useRunsPage"); + expect(firstAllRuns?.args).toEqual([{ includeArchived: true }, false]); + expect(firstRunsPage?.args[0]).toMatchObject({ includeArchived: true }); + expect(firstRunsPage?.args[1]).toBe(true); + }); + test("/runs?view=columns ignores stored list view", async () => { storage.setItem( RUNS_PREFERENCES_STORAGE_KEY, diff --git a/apps/fabro-web/app/routes/runs.tsx b/apps/fabro-web/app/routes/runs.tsx index 5287664d8..2388a8859 100644 --- a/apps/fabro-web/app/routes/runs.tsx +++ b/apps/fabro-web/app/routes/runs.tsx @@ -783,6 +783,20 @@ export function loadStoredRunsWorkspaceSearchParams( } } +// Resolve which search params should drive rendering. If the URL has no +// workspace params (e.g. the user clicked the Runs nav link, which goes to +// `/runs`), fall back to stored preferences so the first render already +// reflects the user's view/archived/etc. choice instead of route defaults. +// Without this, users whose only runs are archived briefly see the empty +// Quick Start landing before a post-commit effect restores `archived=1`. +export function resolveRunsWorkspaceSearchParams( + urlSearchParams: URLSearchParams, +): URLSearchParams { + if (hasRunsWorkspaceParams(urlSearchParams)) return urlSearchParams; + const stored = loadStoredRunsWorkspaceSearchParams(); + return stored.toString() === "" ? urlSearchParams : stored; +} + export function persistRunsWorkspaceSearchParams( searchParams: URLSearchParams, storage: Pick | null = runsPreferencesStorage(), @@ -1719,7 +1733,11 @@ function RunsLandingEmpty({ } export default function Runs() { - const [searchParams, setSearchParams] = useSearchParams(); + const [urlSearchParams, setSearchParams] = useSearchParams(); + const searchParams = useMemo( + () => resolveRunsWorkspaceSearchParams(urlSearchParams), + [urlSearchParams], + ); const query = searchParams.get("search") ?? ""; const repoFilter = searchParams.get("repo") ?? "all"; const workflowFilter = searchParams.get("workflow") ?? "all"; @@ -1737,21 +1755,16 @@ export default function Runs() { const updateParam = useCallback( (key: string, value: string | null) => { - setSearchParams( - (prev) => { - const next = new URLSearchParams(prev); - if (value == null || value === "") { - next.delete(key); - } else { - next.set(key, value); - } - persistRunsWorkspaceSearchParams(next); - return next; - }, - { replace: true }, - ); + const next = new URLSearchParams(searchParams); + if (value == null || value === "") { + next.delete(key); + } else { + next.set(key, value); + } + persistRunsWorkspaceSearchParams(next); + setSearchParams(next, { replace: true }); }, - [setSearchParams], + [searchParams, setSearchParams], ); const setQuery = (value: string) => updateParam("search", value || null); @@ -1789,13 +1802,9 @@ export default function Runs() { ); useEffect(() => { - if (hasRunsWorkspaceParams(searchParams)) return; - - const storedParams = loadStoredRunsWorkspaceSearchParams(); - if (storedParams.toString() === "") return; - - setSearchParams(storedParams, { replace: true }); - }, [searchParams, setSearchParams]); + if (searchParams === urlSearchParams) return; + setSearchParams(searchParams, { replace: true }); + }, [searchParams, urlSearchParams, setSearchParams]); const boardRuns = useAllRuns({ includeArchived }, view === "columns"); const listRunsPage = useRunsPage(