From 3f1fbf39c8482fe8d6696d21f304b6c8e8d0a1a3 Mon Sep 17 00:00:00 2001 From: Devin Foley Date: Fri, 18 Sep 2026 17:58:00 -0700 Subject: [PATCH] fix(plugins): clear overlay account state on local board transitions Co-Authored-By: Paperclip --- doc/plugins/DISTRIBUTION-PLUGINS.md | 2 ++ ui/src/components/PluginAppShellOverlays.test.tsx | 12 ++++++++++-- ui/src/components/PluginAppShellOverlays.tsx | 5 ++++- 3 files changed, 16 insertions(+), 3 deletions(-) diff --git a/doc/plugins/DISTRIBUTION-PLUGINS.md b/doc/plugins/DISTRIBUTION-PLUGINS.md index 193e8d42ce..fea42368e5 100644 --- a/doc/plugins/DISTRIBUTION-PLUGINS.md +++ b/doc/plugins/DISTRIBUTION-PLUGINS.md @@ -71,6 +71,8 @@ The `appShellOverlay` slot requires `ui.action.register`. It receives the usual `PluginWidgetProps` context. It mounts once in both application shells and survives route navigation. It is disposed when the account or selected company changes, during onboarding, and on sign-out. It is not mounted on login pages. +Local-trusted mode has no login requirement: its sessionless board may mount +overlays, but transitions to or from an account still dispose the prior state. The host positions contributions above the mobile navigation and stacks them at the bottom right. Each plugin owns its launcher, panel, keyboard handling, diff --git a/ui/src/components/PluginAppShellOverlays.test.tsx b/ui/src/components/PluginAppShellOverlays.test.tsx index 1144ea053b..10ba863da6 100644 --- a/ui/src/components/PluginAppShellOverlays.test.tsx +++ b/ui/src/components/PluginAppShellOverlays.test.tsx @@ -19,9 +19,9 @@ vi.mock("@/plugins/slots", () => ({ })); let root: Root | undefined; let container: HTMLDivElement; -function render() { +function render(localTrusted = false) { if (!root) { container = document.createElement("div"); document.body.append(container); root = createRoot(container); } - flushSync(() => root!.render()); + flushSync(() => root!.render()); } afterEach(() => { if (root) flushSync(() => root!.unmount()); @@ -45,4 +45,12 @@ describe("persistent app-shell plugin lifecycle", () => { state.onboarding = true; render(); expect(state.mounts).toBe(0); state.onboarding = false; state.failed = true; render(); expect(container.textContent).toBe(""); }); + it("clears account state when returning to the sessionless local board", () => { + render(true); flushSync(() => container.querySelector("button")!.click()); + state.settled = false; render(true); expect(container.textContent).toBe(""); expect(state.disposals).toBe(1); + state.userId = null; state.settled = true; render(true); + expect(container.textContent).toBe("empty"); expect(state.mounts).toBe(2); + flushSync(() => container.querySelector("button")!.click()); + state.userId = "bob"; render(true); expect(container.textContent).toBe("empty"); expect(state.disposals).toBe(2); + }); }); diff --git a/ui/src/components/PluginAppShellOverlays.tsx b/ui/src/components/PluginAppShellOverlays.tsx index e69af101ce..eb7b31555f 100644 --- a/ui/src/components/PluginAppShellOverlays.tsx +++ b/ui/src/components/PluginAppShellOverlays.tsx @@ -34,7 +34,10 @@ export function PluginAppShellOverlays({ localTrusted = false }: { localTrusted? const { userId, settled } = useAccountIdentity(); const { selectedCompanyId, selectedCompany, loading } = useCompany(); const { onboardingOpen } = useDialogState(); - const identity = localTrusted ? "local-board" : settled ? userId : null; + // Local-trusted instances intentionally have no login requirement. Still + // prefer any real account and wait for identity resolution so account changes + // cannot reuse the prior account's in-memory plugin state. + const identity = settled ? userId ?? (localTrusted ? "local-board" : null) : null; if (!identity || loading || onboardingOpen) return null; return (