fix(app): keep saved workspace tabs when a store changes before hydration (#7547)

On every page load the layout mode publisher sets the workspace layout store
before its stored state has been read. Zustand persist wrote the unhydrated
defaults, and hydration merges the stored layout into memory without writing
it back, so storage held no tabs until the next layout change. Leaving the
page inside that window lost every workspace's tabs. Validated persist storage
now drops writes that arrive while its read is in flight.
This commit is contained in:
Mohamed Boudra
2026-10-21 13:45:16 +07:00
committed by GitHub
parent 30464cf234
commit 2545250b2f
2 changed files with 65 additions and 15 deletions
@@ -13,29 +13,43 @@ export function createValidatedPersistStorage<State>(
});
const accepted = new Map<string, StorageValue<State>>();
const reading = new Set<string>();
async function read(name: string): Promise<StorageValue<State> | 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 &&
@@ -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<string, string>();
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<void>((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],
);
});