From 7e87279d869a2fc772ff9814eb678f5bdd917d4b Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Wed, 2 Sep 2026 13:55:46 +1000 Subject: [PATCH 1/3] fix(staged): hide the Rebase button while a rebase is queued or running MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The header Rebase button stayed visible after a rebase was requested — the parent is still ahead until the pipeline lands, so it kept inviting repeat clicks the backend can only dedupe. Pending timeline rows now carry their session's pipeline kind, since the row subject is a display label an agent-pushed ACP title can replace mid-pipeline. BranchCard withholds the header Rebase button while an active (queued or running) rebase row is present; commandPipelinePending already covers the window between the click and the timeline reload that surfaces the row. Co-Authored-By: Claude Fable 5 Signed-off-by: Matt Toohey --- apps/staged/src-tauri/src/lib.rs | 6 +++ apps/staged/src-tauri/src/timeline.rs | 53 +++++++++++++++---- apps/staged/src-tauri/src/web_server.rs | 1 + .../lib/features/branches/BranchCard.svelte | 9 +++- .../features/branches/rebaseInFlight.test.ts | 30 +++++++++++ .../lib/features/branches/rebaseInFlight.ts | 22 ++++++++ apps/staged/src/lib/types.ts | 7 +++ 7 files changed, 117 insertions(+), 11 deletions(-) create mode 100644 apps/staged/src/lib/features/branches/rebaseInFlight.test.ts create mode 100644 apps/staged/src/lib/features/branches/rebaseInFlight.ts diff --git a/apps/staged/src-tauri/src/lib.rs b/apps/staged/src-tauri/src/lib.rs index 9c70f3add..7abf1c448 100644 --- a/apps/staged/src-tauri/src/lib.rs +++ b/apps/staged/src-tauri/src/lib.rs @@ -162,6 +162,11 @@ pub struct CommitTimelineItem { pub session_id: Option, pub session_status: Option, pub completion_reason: Option, + /// Kind of the git pipeline behind this row's session. Only set on pending + /// rows: it is how the frontend tells a queued/running rebase or squash + /// apart from a plain commit session, since the subject is a display label + /// an agent-pushed ACP title can replace mid-pipeline. + pub pipeline_kind: Option, /// Whether this commit was authored by the current git user. pub is_own_commit: bool, } @@ -1175,6 +1180,7 @@ async fn get_repo_default_branch_timeline( session_id: None, session_status: None, completion_reason: None, + pipeline_kind: None, is_own_commit: false, }) }) diff --git a/apps/staged/src-tauri/src/timeline.rs b/apps/staged/src-tauri/src/timeline.rs index 809e75cac..f21446d49 100644 --- a/apps/staged/src-tauri/src/timeline.rs +++ b/apps/staged/src-tauri/src/timeline.rs @@ -202,6 +202,7 @@ fn parse_commit_lines( session_id: resolved.session_id, session_status: resolved.status, completion_reason: resolved.completion_reason, + pipeline_kind: None, is_own_commit: false, // set later by build_branch_timeline }); } @@ -268,6 +269,7 @@ fn map_local_commits( session_id: resolved.session_id, session_status: resolved.status, completion_reason: resolved.completion_reason, + pipeline_kind: None, is_own_commit: false, // set later by build_branch_timeline } }) @@ -465,21 +467,17 @@ fn build_branch_timeline(store: &Arc, branch_id: &str) -> Result, branch_id: &str) -> Result Result startBranchCommandPipeline('rebase')} rebaseDisabled={!!branchCommandDisabledReason} diff --git a/apps/staged/src/lib/features/branches/rebaseInFlight.test.ts b/apps/staged/src/lib/features/branches/rebaseInFlight.test.ts new file mode 100644 index 000000000..f033d6893 --- /dev/null +++ b/apps/staged/src/lib/features/branches/rebaseInFlight.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from 'vitest'; +import { rebaseInFlight } from './rebaseInFlight'; + +describe('rebaseInFlight', () => { + it('reports a queued rebase pipeline', () => { + expect(rebaseInFlight([{ pipelineKind: 'rebase', sessionStatus: 'queued' }])).toBe(true); + }); + + it('reports a running rebase pipeline', () => { + expect(rebaseInFlight([{ pipelineKind: 'rebase', sessionStatus: 'running' }])).toBe(true); + }); + + it('ignores a rebase that already finished', () => { + expect(rebaseInFlight([{ pipelineKind: 'rebase', sessionStatus: 'completed' }])).toBe(false); + }); + + it('ignores other active pipelines and plain commit sessions', () => { + expect( + rebaseInFlight([ + { pipelineKind: 'squash', sessionStatus: 'running' }, + { pipelineKind: null, sessionStatus: 'running' }, + { sessionStatus: 'queued' }, + ]) + ).toBe(false); + }); + + it('handles a missing timeline', () => { + expect(rebaseInFlight(undefined)).toBe(false); + }); +}); diff --git a/apps/staged/src/lib/features/branches/rebaseInFlight.ts b/apps/staged/src/lib/features/branches/rebaseInFlight.ts new file mode 100644 index 000000000..41e169904 --- /dev/null +++ b/apps/staged/src/lib/features/branches/rebaseInFlight.ts @@ -0,0 +1,22 @@ +import type { CommitTimelineItem } from '../../types'; +import { isSessionActive } from '../../shared/sessionStatus'; + +/** + * Whether a rebase pipeline session is already queued or running on the branch. + * + * Both paths leave a pending commit row in the timeline that carries the + * pipeline kind, so this reads that structured field rather than the row's + * subject — the subject is a display label an agent-pushed ACP title can + * replace once a conflicted rebase hands off to an agent. + * + * The header Rebase button hides while this is true: clicking it again would + * only re-request work the backend dedupes anyway, and the timeline already + * shows the rebase row. Rendering nothing beats a disabled control here — the + * button comes back on its own when the rebase finishes and the parent is + * still ahead. + */ +export function rebaseInFlight( + commits: Pick[] | undefined +): boolean { + return !!commits?.some((c) => c.pipelineKind === 'rebase' && isSessionActive(c.sessionStatus)); +} diff --git a/apps/staged/src/lib/types.ts b/apps/staged/src/lib/types.ts index 9977c37d1..2cb7db0ca 100644 --- a/apps/staged/src/lib/types.ts +++ b/apps/staged/src/lib/types.ts @@ -168,6 +168,13 @@ export interface CommitTimelineItem { sessionId: string | null; sessionStatus: string | null; completionReason: string | null; + /** + * Kind of the git pipeline behind this row's session. Only set on pending + * rows: it is how a queued/running rebase or squash is told apart from a + * plain commit session, since the subject is a display label an agent-pushed + * ACP title can replace mid-pipeline. + */ + pipelineKind?: PipelineKind | null; /** Whether this commit was authored by the current git user. */ isOwnCommit: boolean; } From 4638df6f8c49f400c0a0f17d817f901bc6f15801 Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Wed, 2 Sep 2026 14:34:11 +1000 Subject: [PATCH 2/3] fix(staged): close the menu path to a duplicate rebase, restore the button's disabled state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review of the previous commit found two loose ends and one wrong rationale. The `…` menu's Rebase item was still gated only on branchCommandDisabledReason, so it stayed clickable while a rebase ran and could enqueue a duplicate. It now takes rebaseAlreadyInFlight as a separate prop from rebaseSquashDisabled, so Squash — which queues behind the rebase harmlessly — is unaffected. BranchCardHeaderInfo's rebaseDisabled prop was unreachable: the button only renders under `onRebase`, which the caller withheld for the same conditions the prop was meant to disable. The header now hides the button only for an in-flight rebase and passes the reason string through rebaseDisabledReason (matching TimelineRow's convention), so a wrong branch or a command in progress surfaces as a disabled button with a tooltip instead of one that silently vanishes. The rebaseInFlight comment claimed the backend dedupes a repeat click anyway. That holds only for the queued case: queue_commit_pipeline_locked matches against find_queued_pipeline, which reads queued sessions only, so a second request against a *running* rebase inserts a fresh queued one that runs after the first lands. Corrected, so nobody reads the guard as cosmetic and drops it. Co-Authored-By: Claude Opus 5 Signed-off-by: Matt Toohey --- .../src/lib/features/branches/BranchCard.svelte | 14 +++++++------- .../features/branches/BranchCardActionsBar.svelte | 10 +++++++++- .../features/branches/BranchCardHeaderInfo.svelte | 11 +++++++---- .../src/lib/features/branches/rebaseInFlight.ts | 15 ++++++++++----- 4 files changed, 33 insertions(+), 17 deletions(-) diff --git a/apps/staged/src/lib/features/branches/BranchCard.svelte b/apps/staged/src/lib/features/branches/BranchCard.svelte index ee1caa31d..df08ab998 100644 --- a/apps/staged/src/lib/features/branches/BranchCard.svelte +++ b/apps/staged/src/lib/features/branches/BranchCard.svelte @@ -688,9 +688,10 @@ branchIdentityWarning ?? (commandPipelinePending ? 'Command in progress' : null) ); /** - * A rebase is already queued or running, so the header Rebase button hides — - * see `rebaseInFlight`. `commandPipelinePending` covers the window between - * the click and the timeline reload that surfaces the pipeline's pending row. + * A rebase is already queued or running, so the header Rebase button hides + * and the menu's Rebase item disables — see `rebaseInFlight`. + * `commandPipelinePending` covers the window between the click and the + * timeline reload that surfaces the pipeline's pending row. */ let rebaseAlreadyInFlight = $derived(rebaseInFlight(timeline?.commits)); /** @@ -1919,10 +1920,8 @@ ? (branch.workspaceName ?? formatBaseBranch(branch.baseBranch)) : formatBaseBranch(branch.baseBranch)} parentAheadCount={timeline?.gitState?.base.commitsSinceFork ?? 0} - onRebase={branchCommandDisabledReason || rebaseAlreadyInFlight - ? undefined - : () => startBranchCommandPipeline('rebase')} - rebaseDisabled={!!branchCommandDisabledReason} + onRebase={rebaseAlreadyInFlight ? undefined : () => startBranchCommandPipeline('rebase')} + rebaseDisabledReason={branchCommandDisabledReason} warning={branchIdentityWarning} {refreshingGitState} fetchError={timeline?.gitState?.fetch.error ?? null} @@ -1945,6 +1944,7 @@ onRebaseBranch={() => startBranchCommandPipeline('rebase')} onSquashCommits={() => startBranchCommandPipeline('squash')} rebaseSquashDisabled={!!branchCommandDisabledReason} + {rebaseAlreadyInFlight} {commitCount} /> diff --git a/apps/staged/src/lib/features/branches/BranchCardActionsBar.svelte b/apps/staged/src/lib/features/branches/BranchCardActionsBar.svelte index 4054c0903..04013f65a 100644 --- a/apps/staged/src/lib/features/branches/BranchCardActionsBar.svelte +++ b/apps/staged/src/lib/features/branches/BranchCardActionsBar.svelte @@ -50,6 +50,10 @@ /** Rebase/Squash queue behind running sessions, so this covers only the * cases where they can't run at all (detached HEAD, wrong branch). */ rebaseSquashDisabled?: boolean; + /** A rebase is already queued or running, which disables only Rebase: a + * second request wouldn't dedupe against a *running* rebase, it would land + * a redundant one behind it. Squash still queues normally. */ + rebaseAlreadyInFlight?: boolean; commitCount?: number; } @@ -67,6 +71,7 @@ onRebaseBranch, onSquashCommits, rebaseSquashDisabled = false, + rebaseAlreadyInFlight = false, commitCount = 0, }: Props = $props(); @@ -294,7 +299,10 @@ Move to Project… {/if} - onRebaseBranch?.()}> + onRebaseBranch?.()} + > Rebase Branch {#if commitCount >= 2} diff --git a/apps/staged/src/lib/features/branches/BranchCardHeaderInfo.svelte b/apps/staged/src/lib/features/branches/BranchCardHeaderInfo.svelte index 25d06e62c..acd68b295 100644 --- a/apps/staged/src/lib/features/branches/BranchCardHeaderInfo.svelte +++ b/apps/staged/src/lib/features/branches/BranchCardHeaderInfo.svelte @@ -15,7 +15,10 @@ baseBranch?: string | null; parentAheadCount?: number; onRebase?: () => void; - rebaseDisabled?: boolean; + /** Why the visible Rebase button can't run right now, shown as its tooltip. + * Callers withhold `onRebase` instead when the button shouldn't be offered + * at all. */ + rebaseDisabledReason?: string | null; warning?: string | null; refreshingGitState?: boolean; fetchError?: string | null; @@ -28,7 +31,7 @@ baseBranch = null, parentAheadCount = 0, onRebase, - rebaseDisabled = false, + rebaseDisabledReason = null, warning = null, refreshingGitState = false, fetchError = null, @@ -67,13 +70,13 @@ {#if parentAheadCount > 0 && onRebase} diff --git a/apps/staged/src/lib/features/branches/rebaseInFlight.ts b/apps/staged/src/lib/features/branches/rebaseInFlight.ts index 41e169904..ce5cb8132 100644 --- a/apps/staged/src/lib/features/branches/rebaseInFlight.ts +++ b/apps/staged/src/lib/features/branches/rebaseInFlight.ts @@ -9,11 +9,16 @@ import { isSessionActive } from '../../shared/sessionStatus'; * subject — the subject is a display label an agent-pushed ACP title can * replace once a conflicted rebase hands off to an agent. * - * The header Rebase button hides while this is true: clicking it again would - * only re-request work the backend dedupes anyway, and the timeline already - * shows the rebase row. Rendering nothing beats a disabled control here — the - * button comes back on its own when the rebase finishes and the parent is - * still ahead. + * The header Rebase button hides while this is true, and the `…` menu's Rebase + * item disables. The backend only dedupes the *queued* case + * (`queue_commit_pipeline_locked` matches against `find_queued_pipeline`, which + * reads queued sessions only), so against a rebase that is already **running** a + * second request inserts a fresh queued rebase that runs once the first lands. + * This guard is what stops that redundant second rebase, not a cosmetic tidy-up. + * + * The header renders nothing rather than a disabled control — it is a transient + * state with the rebase row already visible in the timeline, and the button + * comes back on its own if the rebase finishes with the parent still ahead. */ export function rebaseInFlight( commits: Pick[] | undefined From 41cd37936ceef6e8bcca3463c70dcd44c35e4698 Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Thu, 3 Sep 2026 15:15:17 +1000 Subject: [PATCH 3/3] fix(staged): version the caches that outlive a deploy against the pipelineKind field MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `pipelineKind` was added to `CommitTimelineItem` earlier on this branch to drive the `rebaseInFlight` guard, but neither web-mode layer that persists timeline responses across a deploy was versioned for it. After an upgrade that lands mid-rebase, cached pending rows read `pipelineKind === undefined`, the guard reads false, and both Rebase entry points come back while a rebase is queued or running — the duplicate-enqueue hole the guard exists to close, since the backend only dedupes *queued* rebases. CACHE_SCHEMA_VERSION goes to 2, so IndexedDB entries written by the previous build fail the version check and read as misses instead of short-circuiting the fetch inside their 30s TTL. The `staged:boot:timelines` localStorage snapshot was unversioned, and it is the worse of the two: it seeds the in-memory cache at module init, and an entry under TIMELINE_FRESH_MS makes getBranchTimelineWithRevalidation return `fresh: null` — no fetch at all to correct the shape. Its payload is now stamped with the same constant and discarded (and cleared) on mismatch; a pre-versioning snapshot is a bare record with no `schemaVersion`, so it fails the same check. Sharing one constant across both layers means the next cached-shape change needs exactly one bump. Tests cover both layers: a previous-version IDB entry is a miss in `cachedInvoke` and `cachedCommand`, an unversioned or mismatched snapshot is discarded and its key freed, and persist-then-seed round-trips under the current version. The snapshot tests mock the constant to an arbitrary value so they track the shared export rather than its current number. Co-Authored-By: Claude Opus 5 Signed-off-by: Matt Toohey --- apps/staged/src/lib/cache.test.ts | 42 +++++++++- apps/staged/src/lib/cache.ts | 17 +++- apps/staged/src/lib/commands.test.ts | 112 +++++++++++++++++++++++++++ apps/staged/src/lib/commands.ts | 41 ++++++++-- 4 files changed, 202 insertions(+), 10 deletions(-) diff --git a/apps/staged/src/lib/cache.test.ts b/apps/staged/src/lib/cache.test.ts index 421678846..3025887ed 100644 --- a/apps/staged/src/lib/cache.test.ts +++ b/apps/staged/src/lib/cache.test.ts @@ -1,5 +1,6 @@ import 'fake-indexeddb/auto'; import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { createStore, set } from 'idb-keyval'; // Mock transport — web mode (isTauri = false) with controllable invokeCommand const mockInvoke = vi.fn(); @@ -16,8 +17,8 @@ import { invalidateCacheByCommand, markAllStale, clearAllCache, + CACHE_SCHEMA_VERSION, _cacheKey, - _CACHE_SCHEMA_VERSION, _MAX_CACHE_ENTRIES, _evictIfNeeded, } from './cache'; @@ -51,6 +52,45 @@ describe('cacheKey', () => { }); }); +describe('schema version', () => { + /** Write an entry the way a build one schema version behind would have. */ + async function writePreviousVersionEntry(command: string, data: unknown): Promise { + const key = _cacheKey(command); + await set( + key, + { key, data, fetchedAt: Date.now(), schemaVersion: CACHE_SCHEMA_VERSION - 1 }, + createStore('staged-cache', 'responses') + ); + } + + it('never yields an entry written under a previous schema version', async () => { + await writePreviousVersionEntry('cmd', 'old-shape'); + mockInvoke.mockResolvedValue('new-shape'); + + const results = []; + for await (const r of cachedInvoke('cmd', undefined, { ttl: 60_000 })) { + results.push(r); + } + + // A within-TTL entry would normally short-circuit the fetch entirely; the + // version mismatch has to demote it to a miss, or the deploy that changed + // the payload serves the old shape with no network correction. + expect(results).toEqual([ + { data: 'new-shape', source: 'network', fetchedAt: expect.any(Number) }, + ]); + }); + + it('treats a previous-version entry as a miss in cachedCommand', async () => { + await writePreviousVersionEntry('cmd', 'old-shape'); + mockInvoke.mockResolvedValue('new-shape'); + + await expect(cachedCommand('cmd', undefined, { ttl: 60_000 })).resolves.toEqual({ + data: 'new-shape', + revalidating: null, + }); + }); +}); + describe('cachedInvoke', () => { it('yields only network result on cache miss', async () => { mockInvoke.mockResolvedValue({ items: [1, 2] }); diff --git a/apps/staged/src/lib/cache.ts b/apps/staged/src/lib/cache.ts index 5abea2c9d..8b84efffd 100644 --- a/apps/staged/src/lib/cache.ts +++ b/apps/staged/src/lib/cache.ts @@ -1,7 +1,21 @@ import { get, set, del, keys, entries, clear, createStore } from 'idb-keyval'; import { invokeCommand, isTauri } from './transport'; -const CACHE_SCHEMA_VERSION = 1; +/** + * Stamped on every persisted cache entry and checked on read. Bump it whenever + * the shape of any cached command response changes — otherwise the first load + * after a deploy serves entries written by the previous build, and a field the + * new UI depends on reads as `undefined` (e.g. `CommitTimelineItem.pipelineKind`, + * whose absence silently re-enables the Rebase button mid-rebase). + * + * Entries that fail the check read as misses and sit inert in IndexedDB until + * they're overwritten or LRU-evicted. The cost of a bump is one cold-cache boot + * per client. + * + * The timeline boot snapshot in `commands.ts` is versioned by this same + * constant, so one bump covers both layers that survive a deploy. + */ +export const CACHE_SCHEMA_VERSION = 2; const MAX_CACHE_ENTRIES = 200; /** @@ -369,7 +383,6 @@ export async function clearAllCache(): Promise { // Exported for testing export { cacheKey as _cacheKey, - CACHE_SCHEMA_VERSION as _CACHE_SCHEMA_VERSION, MAX_CACHE_ENTRIES as _MAX_CACHE_ENTRIES, evictIfNeeded as _evictIfNeeded, }; diff --git a/apps/staged/src/lib/commands.test.ts b/apps/staged/src/lib/commands.test.ts index d1ede235c..63922e1b9 100644 --- a/apps/staged/src/lib/commands.test.ts +++ b/apps/staged/src/lib/commands.test.ts @@ -399,6 +399,7 @@ describe('cached mutation command wrappers', () => { invokeCommand, })); vi.doMock('./cache', () => ({ + CACHE_SCHEMA_VERSION: 1, cachedCommand, cachedInvoke: vi.fn(), invalidateCache, @@ -632,3 +633,114 @@ describe('cached mutation command wrappers', () => { }); }); }); + +describe('timeline boot snapshot', () => { + /** Deliberately not the real value — the snapshot must track whatever the shared constant says. */ + const SCHEMA_VERSION = 7; + + const stale = { + commits: [{ sha: 'pending-1', subject: 'Rebase onto main', pipelineKind: 'rebase' }], + notes: [], + reviews: [], + images: [], + }; + const fetched = { + commits: [{ sha: 'abc123', subject: 'Fix the thing' }], + notes: [], + reviews: [], + images: [], + }; + + let storage: Record; + let cachedInvoke: ReturnType; + let snapshotKey: string; + + beforeEach(async () => { + vi.resetModules(); + storage = {}; + vi.stubGlobal('localStorage', { + getItem: (key: string) => storage[key] ?? null, + setItem: (key: string, value: string) => { + storage[key] = value; + }, + removeItem: (key: string) => { + delete storage[key]; + }, + }); + cachedInvoke = vi.fn(async function* () { + yield { data: fetched, source: 'network', fetchedAt: Date.now() }; + }); + + vi.doMock('./transport', () => ({ isTauri: false, invokeCommand: vi.fn() })); + vi.doMock('./cache', () => ({ + CACHE_SCHEMA_VERSION: SCHEMA_VERSION, + cachedCommand: vi.fn(), + cachedInvoke, + invalidateCache: vi.fn(), + invalidateCacheByCommand: vi.fn(), + invalidateCacheByArgs: vi.fn(), + })); + + ({ + SNAPSHOT_KEYS: { timelines: snapshotKey }, + } = await import('./shared/webSnapshot')); + }); + + afterEach(() => { + vi.doUnmock('./transport'); + vi.doUnmock('./cache'); + vi.unstubAllGlobals(); + }); + + it('discards a snapshot written before the payload carried a version', async () => { + // Pre-versioning format: a bare branchId -> entry record. + storage[snapshotKey] = JSON.stringify({ + 'branch-1': { timeline: stale, fetchedAt: Date.now() }, + }); + + const { getBranchTimelineWithRevalidation } = await import('./commands'); + const { cached, fresh } = getBranchTimelineWithRevalidation('branch-1'); + + expect(cached).toBeNull(); + await expect(fresh).resolves.toEqual(fetched); + expect(storage[snapshotKey]).toBeUndefined(); + }); + + it('discards a snapshot stamped by a previous build', async () => { + storage[snapshotKey] = JSON.stringify({ + schemaVersion: SCHEMA_VERSION - 1, + timelines: { 'branch-1': { timeline: stale, fetchedAt: Date.now() } }, + }); + + const { getBranchTimelineWithRevalidation } = await import('./commands'); + const { cached, fresh } = getBranchTimelineWithRevalidation('branch-1'); + + expect(cached).toBeNull(); + await expect(fresh).resolves.toEqual(fetched); + expect(storage[snapshotKey]).toBeUndefined(); + }); + + it('round-trips the timeline cache through a snapshot on the current version', async () => { + const beforeReload = await import('./commands'); + await expect(beforeReload.getBranchTimeline('branch-1')).resolves.toEqual(fetched); + beforeReload.persistTimelineSnapshot(); + + expect(JSON.parse(storage[snapshotKey])).toEqual({ + schemaVersion: SCHEMA_VERSION, + timelines: { 'branch-1': { timeline: fetched, fetchedAt: expect.any(Number) } }, + }); + + cachedInvoke.mockClear(); + vi.resetModules(); + const afterReload = await import('./commands'); + + // A just-seeded entry is inside TIMELINE_FRESH_MS, so this read is served + // with no fetch behind it — the window a mismatched payload shape would + // otherwise reach the UI through uncorrected. + expect(afterReload.getBranchTimelineWithRevalidation('branch-1')).toEqual({ + cached: fetched, + fresh: null, + }); + expect(cachedInvoke).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/staged/src/lib/commands.ts b/apps/staged/src/lib/commands.ts index bb0612bb1..9976df790 100644 --- a/apps/staged/src/lib/commands.ts +++ b/apps/staged/src/lib/commands.ts @@ -10,9 +10,10 @@ import { cachedInvoke, invalidateCacheByCommand, invalidateCache, + CACHE_SCHEMA_VERSION, type SwrResult, } from './cache'; -import { readSnapshot, writeSnapshot, SNAPSHOT_KEYS } from './shared/webSnapshot'; +import { readSnapshot, writeSnapshot, clearSnapshot, SNAPSHOT_KEYS } from './shared/webSnapshot'; import type { Project, ProjectRepo, @@ -465,19 +466,42 @@ const MAX_SNAPSHOT_TIMELINES = 40; type TimelineCacheEntry = { timeline: BranchTimeline; fetchedAt: number }; +/** + * Persisted shape of the boot snapshot. Stamped with the shared + * `CACHE_SCHEMA_VERSION` because this snapshot outlives a deploy just like the + * IndexedDB cache does, and seeds the same timelines the UI reads fields off. + */ +type TimelineSnapshot = { + schemaVersion: number; + timelines: Record; +}; + /** * Seed the in-memory timeline cache synchronously from the previous session's * localStorage snapshot. This runs at module init (before any BranchCard mounts) * so cached timelines can paint on the first frame of a cold iOS reload, instead * of each card awaiting its own asynchronous IndexedDB read. IndexedDB remains * the source of truth; the snapshot is only a paint-on-first-frame accelerator. - * The seeded entries carry their original `fetchedAt`, so they read as stale and - * `getBranchTimelineWithRevalidation` still kicks off a fresh fetch. + * The seeded entries carry their original `fetchedAt`, so an older snapshot + * reads as stale and `getBranchTimelineWithRevalidation` still kicks off a + * fresh fetch. + * + * Entries a previous build wrote are dropped: one restored inside + * `TIMELINE_FRESH_MS` suppresses revalidation entirely, so seeding a stale + * payload shape would hand the UI missing fields with no fetch to correct them. + * A pre-versioning snapshot is a bare record with no `schemaVersion`, so it + * fails the same check. */ function seedTimelineCacheFromSnapshot(): void { - const snapshot = readSnapshot>(SNAPSHOT_KEYS.timelines); + const snapshot = readSnapshot(SNAPSHOT_KEYS.timelines); if (!snapshot) return; - for (const [branchId, entry] of Object.entries(snapshot)) { + if (snapshot.schemaVersion !== CACHE_SCHEMA_VERSION || !snapshot.timelines) { + // Clear rather than leave it: it can never be read again, and it's the + // largest thing we put in localStorage. + clearSnapshot(SNAPSHOT_KEYS.timelines); + return; + } + for (const [branchId, entry] of Object.entries(snapshot.timelines)) { if (entry?.timeline && !timelineCache.has(branchId)) { timelineCache.set(branchId, entry); } @@ -494,12 +518,15 @@ seedTimelineCacheFromSnapshot(); */ export function persistTimelineSnapshot(): void { if (isTauri || timelineCache.size === 0) return; - const snapshot: Record = Object.fromEntries( + const timelines: Record = Object.fromEntries( [...timelineCache.entries()] .sort((a, b) => b[1].fetchedAt - a[1].fetchedAt) .slice(0, MAX_SNAPSHOT_TIMELINES) ); - writeSnapshot(SNAPSHOT_KEYS.timelines, snapshot); + writeSnapshot(SNAPSHOT_KEYS.timelines, { + schemaVersion: CACHE_SCHEMA_VERSION, + timelines, + }); } /**