diff --git a/packages/app/src/storage/validated-persist-storage.ts b/packages/app/src/storage/validated-persist-storage.ts index e9a438daf9..fae43edbc9 100644 --- a/packages/app/src/storage/validated-persist-storage.ts +++ b/packages/app/src/storage/validated-persist-storage.ts @@ -13,29 +13,43 @@ export function createValidatedPersistStorage( }); const accepted = new Map>(); + const reading = new Set(); + + async function read(name: string): Promise | null> { + const raw = await backingStorage.getItem(name); + if (raw === null) return null; + + let decoded: unknown; + try { + decoded = JSON.parse(raw); + } catch { + await backingStorage.removeItem(name); + return null; + } + + const result = envelopeSchema.safeParse(decoded); + if (!result.success) { + await backingStorage.removeItem(name); + return null; + } + return result.data; + } return { getItem: async (name) => { accepted.delete(name); - const raw = await backingStorage.getItem(name); - if (raw === null) return null; - - let decoded: unknown; + reading.add(name); try { - decoded = JSON.parse(raw); - } catch { - await backingStorage.removeItem(name); - return null; + return await read(name); + } finally { + reading.delete(name); } - - const result = envelopeSchema.safeParse(decoded); - if (!result.success) { - await backingStorage.removeItem(name); - return null; - } - return result.data; }, setItem: async (name, value) => { + // A store that changes before hydration settles writes the defaults it started with. + // Hydration then merges the stored state in memory without writing it back, so that + // write would replace the stored state until the next change. + if (reading.has(name)) return; const previous = accepted.get(name); if ( options.skipUnchanged && diff --git a/packages/app/src/stores/workspace-layout-persistence.test.ts b/packages/app/src/stores/workspace-layout-persistence.test.ts index d386a40298..3a54204598 100644 --- a/packages/app/src/stores/workspace-layout-persistence.test.ts +++ b/packages/app/src/stores/workspace-layout-persistence.test.ts @@ -52,3 +52,39 @@ it("skips unchanged reconciliations and immediately saves real layout changes", store.getState().layoutByWorkspace[workspaceKey], ); }); + +it("keeps the saved layout when the shell publishes its layout mode before hydration", async () => { + const values = new Map(); + const storage: StateStorage = { + getItem: (name) => values.get(name) ?? null, + setItem: (name, value) => { + values.set(name, value); + }, + removeItem: (name) => { + values.delete(name); + }, + }; + const workspaceKey = "server:workspace"; + const saved = createWorkspaceLayoutStore(undefined, storage); + await saved.persist.rehydrate(); + saved.getState().openTab({ + workspaceKey, + target: { kind: "file", path: "notes.md" }, + intent: "reveal", + }); + + // A page load: the shell publishes its mode from the first commit, before the read settles. + const loaded = createWorkspaceLayoutStore(undefined, storage); + loaded.getState().setLayoutMode("single-pane"); + await new Promise((resolve) => loaded.persist.onFinishHydration(() => resolve())); + expect(loaded.getState().layoutByWorkspace[workspaceKey]).toEqual( + saved.getState().layoutByWorkspace[workspaceKey], + ); + + // Leaving the page before any other layout change must not lose the tabs. + const reloaded = createWorkspaceLayoutStore(undefined, storage); + await reloaded.persist.rehydrate(); + expect(reloaded.getState().layoutByWorkspace[workspaceKey]).toEqual( + saved.getState().layoutByWorkspace[workspaceKey], + ); +});