diff --git a/CHANGELOG.md b/CHANGELOG.md index ea5e4525b..549e1f0ab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,17 @@ Notable API additions and breaking changes. For the full commit log, see [GitHub Releases](https://github.com/mirrorstack-ai/web-ui-kit/releases). +## 0.5.9 + +- Agent sidebar reload **clobber fix**: a mount-time mutation (the host bridge's + `setOpen`, a route-driven tab change) could `put` the empty, un-hydrated strip + before the mount GET settled, permanently overwriting the server's saved + state with `{tabs:[], placeholder width}`. Reloads then "reverted to default / + new chat" and stayed. `schedulePut` now refuses to persist until the strip has + hydrated (the flag is set even on GET failure, so saves never stall). This is + the live-reproduced root cause that 0.5.7/0.5.8 did not resolve. Regression + test included (a pre-hydration mutation must not `put`). + ## 0.5.8 - Agent sidebar reload reliability: the persisted drag-width and the restored diff --git a/package.json b/package.json index 24ee47f50..d2aeba8cf 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@mirrorstack-ai/web-ui-kit", "packageManager": "pnpm@10.29.3", - "version": "0.5.8", + "version": "0.5.9", "main": "./dist/index.js", "types": "./dist/index.d.ts", "exports": { diff --git a/src/hooks/agent-chat/useAgentTabs.test.ts b/src/hooks/agent-chat/useAgentTabs.test.ts index df2a46f3d..6cb231f2f 100644 --- a/src/hooks/agent-chat/useAgentTabs.test.ts +++ b/src/hooks/agent-chat/useAgentTabs.test.ts @@ -95,6 +95,45 @@ describe("useAgentTabs", () => { expect(put).not.toHaveBeenCalled(); }); + it("does NOT persist before hydration settles — a mount-time mutation must not clobber the server", async () => { + // Deferred GET: hydration is still in flight when a mount-time mutation + // fires (the host bridge's setOpen, a route-driven tab change). + let resolveGet!: (s: AgentSidebarState) => void; + const get = vi.fn().mockImplementation( + () => new Promise((r) => { resolveGet = r; }), + ); + const put = vi.fn().mockResolvedValue(undefined); + const persistence: AgentTabsPersistence = { get, put }; + const { result } = renderHook(() => useAgentTabs(persistence)); + + // Pre-fix this scheduled a PUT of the empty, un-hydrated strip (tabs:[], + // placeholder width), permanently clobbering the server's saved state — + // the "reload reverts to default / new chat" bug. The gate suppresses it. + act(() => { + result.current.openConversation("c-9"); + }); + await advance(5000); // well past the debounce window + expect(result.current.hydrated).toBe(false); + expect(put).not.toHaveBeenCalled(); + + // Hydration settles with the real saved state (tabs + a real width). + await act(async () => { + resolveGet(state({ width: 700 })); + }); + expect(result.current.hydrated).toBe(true); + expect(result.current.tabs).toEqual(["c-1", "c-2"]); // restored, not wiped + expect(put).not.toHaveBeenCalled(); // hydration itself never writes + + // A genuine post-hydrate mutation DOES persist, carrying the hydrated + // width — never a placeholder. + act(() => { + result.current.openConversation("c-3"); + }); + await advance(5000); + expect(put).toHaveBeenCalledTimes(1); + expect(put.mock.calls[0][0].width).toBe(700); + }); + it("debounces mutations into ONE trailing PUT carrying the final state", async () => { const { persistence, put } = fakePersistence(); const { result } = renderHook(() => useAgentTabs(persistence)); diff --git a/src/hooks/agent-chat/useAgentTabs.ts b/src/hooks/agent-chat/useAgentTabs.ts index cd0b4718f..09f92981c 100644 --- a/src/hooks/agent-chat/useAgentTabs.ts +++ b/src/hooks/agent-chat/useAgentTabs.ts @@ -383,7 +383,19 @@ export function useAgentTabs( const hasHydratedRef = useRef(false); const schedulePut = useCallback(() => { - if (!persistenceRef.current || !enabledRef.current) return; + // NEVER persist before the mount GET has settled. A mount-time mutation + // (the host bridge's setOpen, a route-driven newTab/selectTab) fires + // `mutate -> schedulePut` while the strip is still the empty, un-hydrated + // placeholder; without this gate that empty strip (tabs:[], placeholder + // width) gets PUT and permanently clobbers the server's real saved state + // — the reload "reverts to default / new chat" bug. hasHydratedRef flips + // true in reconcile's finally even on GET failure, so saves never stall. + if ( + !persistenceRef.current || + !enabledRef.current || + !hasHydratedRef.current + ) + return; pendingRef.current = true; if (timerRef.current) clearTimeout(timerRef.current); timerRef.current = setTimeout(() => {