From a8e431f6416fccc6214dc18812e546e69981f348 Mon Sep 17 00:00:00 2001 From: Badcuban <108198679+badcuban@users.noreply.github.com> Date: Wed, 9 Sep 2026 15:52:03 -0400 Subject: [PATCH] fix(web): command palette stays open under StrictMode in dev The open palette dialog reset the store from its own effect cleanup. The app mounts under React StrictMode, which runs every new effect's cleanup once right after mount, so in dev the palette closed the instant it opened. Every entry point (Ctrl+K, the sidebar search, the first-run "Choose a folder" button) has been a dead end in dev since the cleanup landed on August 10. The reset now lives on the CommandPalette wrapper, which mounts once with the app shell: StrictMode's extra cleanup pass finds the palette closed and does nothing, while the wrapper's real teardown still resets the store. The ChatView browser harness gains a `strictMode` option and one test that opens the palette under StrictMode; it fails on the old code. --- apps/web/src/components/ChatView.browser.tsx | 38 +++++++++++++++++--- apps/web/src/components/CommandPalette.tsx | 28 +++++++++------ 2 files changed, 51 insertions(+), 15 deletions(-) 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(">");