diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index 42658b0a..15cd06dd 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -30,6 +30,7 @@ import { import { scopedThreadKey, scopeThreadRef } from "@threadlines/client-runtime"; import { createModelCapabilities, createModelSelection } from "@threadlines/shared/model"; import { RouterProvider, createMemoryHistory } from "@tanstack/react-router"; +import { StrictMode } from "react"; import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import { HttpResponse, http, ws } from "msw"; @@ -2376,6 +2377,8 @@ async function mountChatView(options: { resolveRpc?: (body: NormalizedWsRpcRequestBody) => unknown | undefined; initialPath?: string; waitForBootstrap?: boolean; + /** Mount under StrictMode, as main.tsx does, so mount-time effect cleanups run. */ + strictMode?: boolean; }): Promise { fixture = buildFixture(options.snapshot); options.configureFixture?.(fixture); @@ -2401,14 +2404,14 @@ async function mountChatView(options: { }), ); - const screen = await render( + const app = ( - , - { - container: host, - }, + ); + const screen = await render(options.strictMode ? {app} : app, { + container: host, + }); await waitForWsClient(); if (options.waitForBootstrap !== false) { @@ -7835,6 +7838,31 @@ describe("ChatView timeline estimator parity (full app)", () => { } }); + it("stays open under StrictMode", async () => { + // The app mounts under StrictMode, which runs every new effect's cleanup + // once right after mount. An unmount reset on the open dialog turned that + // into a palette that closed the instant it opened, in dev only. + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot: createSnapshotForTargetUser({ + targetMessageId: "msg-user-command-palette-strict-mode" as MessageId, + targetText: "command palette strict mode", + }), + strictMode: true, + }); + + try { + await openCommandPaletteFromTrigger(); + await waitForLayout(); + await waitForLayout(); + + expect(useCommandPaletteStore.getState().open).toBe(true); + await expect.element(page.getByTestId("command-palette")).toBeInTheDocument(); + } finally { + await mounted.cleanup(); + } + }); + it("filters command palette results as the user types", async () => { const mounted = await mountChatView({ viewport: DEFAULT_VIEWPORT, diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index 7d91df36..66721a5b 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -450,6 +450,19 @@ export function CommandPalette({ children }: { children: ReactNode }) { return () => window.removeEventListener("keydown", onKeyDown); }, [keybindings, terminalOpen, toggleOpen]); + // The store outlives this tree, so a palette left open when the shell goes + // away (auth gate, pairing route, a test harness unmounting) must not greet + // the next mount already open. This wrapper mounts once with the shell, so + // its cleanup is the real teardown -- unlike the open dialog's, which + // StrictMode also runs right after mount. + useEffect(() => { + return () => { + if (useCommandPaletteStore.getState().open) { + setOpen(false); + } + }; + }, [setOpen]); + return ( @@ -479,17 +492,12 @@ function OpenCommandPaletteDialog() { const composerHandleRef = useComposerHandleContext(); // This component mounts once per palette session, so its mount-time // generation identifies the session every deferred close below belongs to. - // Async flows and the unmount reset close via `closeIfGeneration`: if the - // user closed or reopened the palette while a request was in flight (or - // React deferred the unmount cleanup past a new session), the stale close - // is a no-op instead of slamming a palette it does not own. + // Async flows close via `closeIfGeneration`: if the user closed or reopened + // the palette while a request was in flight, the stale close is a no-op + // instead of slamming a palette it does not own. There is deliberately no + // unmount cleanup here: React StrictMode runs a new effect's cleanup once + // right after mount, which closed the palette the instant it opened in dev. const [sessionGeneration] = useState(() => useCommandPaletteStore.getState().openGeneration); - - useEffect(() => { - return () => { - closeIfGeneration(sessionGeneration); - }; - }, [closeIfGeneration, sessionGeneration]); const [query, setQuery] = useState(""); const deferredQuery = useDeferredValue(query); const isActionsOnly = deferredQuery.startsWith(">");