fix(plugins): honor route onboarding and complete overlay context

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Devin FoleyandPaperclip committed 2026-09-19 08:57:49 -07:00
1 parent 3f1fbf39c8
commit 34e13e5439
2 files changed
+30 -7

No files matched your search

@@ -5,13 +5,15 @@ import { flushSync } from "react-dom";
import { afterEach, describe, expect, it, vi } from "vitest";
import { PluginAppShellOverlays } from "./PluginAppShellOverlays";
const state = vi.hoisted(() => ({ userId: "alice" as string | null, settled: true, company: "first", onboarding: false, failed: false, mounts: 0, disposals: 0 }));
const state = vi.hoisted(() => ({ userId: "alice" as string | null, settled: true, company: "first", onboarding: false, dismissed: false, pathname: "/ACME/issues", context: {} as Record<string, unknown>, failed: false, mounts: 0, disposals: 0 }));
vi.mock("@/api/companies-query", () => ({ useAccountIdentity: () => ({ userId: state.userId, settled: state.settled }) }));
vi.mock("@/context/CompanyContext", () => ({ useCompany: () => ({ selectedCompanyId: state.company, selectedCompany: { issuePrefix: "ACME" }, loading: false }) }));
vi.mock("@/context/DialogContext", () => ({ useDialogState: () => ({ onboardingOpen: state.onboarding }) }));
vi.mock("@/context/DialogContext", () => ({ useDialogState: () => ({ onboardingOpen: state.onboarding, onboardingRouteDismissed: state.dismissed }) }));
vi.mock("@/lib/router", () => ({ useLocation: () => ({ pathname: state.pathname }) }));
vi.mock("@/plugins/slots", () => ({
usePluginSlots: () => ({ errorMessage: state.failed ? "unavailable" : null, slots: [{ pluginId: "fixture", pluginVersion: "1.0.0", id: "overlay" }] }),
PluginSlotMount: () => {
PluginSlotMount: ({ context }: { context: Record<string, unknown> }) => {
state.context = context;
const [draft, setDraft] = useState("");
useEffect(() => { state.mounts++; return () => { state.disposals++; }; }, []);
return <button onClick={() => setDraft("private draft")}>{draft || "empty"}</button>;
@@ -26,9 +28,19 @@ function render(localTrusted = false) {
afterEach(() => {
if (root) flushSync(() => root!.unmount());
root = undefined; container?.remove();
Object.assign(state, { userId: "alice", settled: true, company: "first", onboarding: false, failed: false, mounts: 0, disposals: 0 });
Object.assign(state, { userId: "alice", settled: true, company: "first", onboarding: false, dismissed: false, pathname: "/ACME/issues", context: {}, failed: false, mounts: 0, disposals: 0 });
});
describe("persistent app-shell plugin lifecycle", () => {
it("passes the complete host context promised by PluginWidgetProps", () => {
render();
expect(state.context).toEqual({ companyId: "first", companyPrefix: "ACME", projectId: null, entityId: null, entityType: null, parentEntityId: null, userId: "alice" });
});
it("disposes drafts on route-driven onboarding and remounts after dismissal", () => {
render(); flushSync(() => container.querySelector("button")!.click());
state.pathname = "/ACME/onboarding"; render();
expect(state.onboarding).toBe(false); expect(container.textContent).toBe(""); expect(state.disposals).toBe(1);
state.dismissed = true; render(); expect(container.textContent).toBe("empty");
});
it("keeps a draft during shell rerenders and clears it on account/company transitions", () => {
render(); flushSync(() => container.querySelector("button")!.click());
render(); expect(container.textContent).toBe("private draft"); expect(state.mounts).toBe(1);
+14 -3
View File
@@ -1,6 +1,9 @@
import { useAccountIdentity } from "@/api/companies-query";
import { useCompany } from "@/context/CompanyContext";
import { useDialogState } from "@/context/DialogContext";
import { useLocation } from "@/lib/router";
import { isOnboardingPath } from "@/lib/onboarding-route";
import type { PluginHostContext } from "@/plugins/bridge";
import { PluginSlotMount, usePluginSlots, type PluginSlotContext } from "@/plugins/slots";
function AppShellEntries({ context }: { context: PluginSlotContext }) {
@@ -33,16 +36,24 @@ function AppShellEntries({ context }: { context: PluginSlotContext }) {
export function PluginAppShellOverlays({ localTrusted = false }: { localTrusted?: boolean }) {
const { userId, settled } = useAccountIdentity();
const { selectedCompanyId, selectedCompany, loading } = useCompany();
const { onboardingOpen } = useDialogState();
const { onboardingOpen, onboardingRouteDismissed } = useDialogState();
const { pathname } = useLocation();
// 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;
const onboardingVisible = onboardingOpen || (!onboardingRouteDismissed && isOnboardingPath(pathname));
if (!identity || loading || onboardingVisible) return null;
const context: PluginSlotContext & PluginHostContext = {
companyId: selectedCompanyId,
companyPrefix: selectedCompany?.issuePrefix ?? null,
projectId: null, entityId: null, entityType: null, parentEntityId: null,
userId,
};
return (
<AppShellEntries
key={JSON.stringify([identity, selectedCompanyId])}
context={{ companyId: selectedCompanyId, companyPrefix: selectedCompany?.issuePrefix ?? null }}
context={context}
/>
);
}