From 5e5f02c67d0c165dd93f8277a3815d51a540d996 Mon Sep 17 00:00:00 2001 From: Adam Malczewski Date: Sat, 5 Sep 2026 04:42:20 +0900 Subject: feat(layout): plan 03 — frontend plugin ABI + handshake + load lifecycle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/features/layout/logic/registry.test.ts | 68 ++++++++++++++++++++++++++++++ src/features/layout/logic/registry.ts | 32 ++++++++++++++ 2 files changed, 100 insertions(+) (limited to 'src/features/layout/logic') diff --git a/src/features/layout/logic/registry.test.ts b/src/features/layout/logic/registry.test.ts index 443e50d..e5e947c 100644 --- a/src/features/layout/logic/registry.test.ts +++ b/src/features/layout/logic/registry.test.ts @@ -4,8 +4,10 @@ import { getView, listPanelViews, listViews, + registerPluginView, registerView, unregisterView, + unregisterViewsByPlugin, type ViewRegistration, viewRegistry, } from "./registry"; @@ -78,3 +80,69 @@ describe("view registry", () => { expect(ids.indexOf("z-first")).toBeLessThan(ids.indexOf("a-second")); }); }); + +// ── Plugin-view registration (plan 03) — forced attribution + the sweep ───── + +describe("registerPluginView / unregisterViewsByPlugin (plan 03)", () => { + it("forces the importing plugin's pluginId attribution", () => { + const dispose = registerPluginView( + { id: "abi-view", label: "ABI View", component: fakeComponent }, + "my-plugin", + ); + expect(getView("abi-view")?.pluginId).toBe("my-plugin"); + dispose(); + expect(getView("abi-view")).toBeUndefined(); + }); + + it("returns a working per-view disposer (the plugin deactivate contract)", () => { + const dispose = registerPluginView( + { id: "abi-view-2", label: "ABI View", component: fakeComponent }, + "my-plugin", + ); + dispose(); + dispose(); // idempotent + expect(getView("abi-view-2")).toBeUndefined(); + }); + + it("the sweep removes exactly that plugin's views (registry attribution)", () => { + registerPluginView({ id: "p1-a", label: "A", component: fakeComponent }, "plugin-1"); + registerPluginView({ id: "p1-b", label: "B", component: fakeComponent }, "plugin-1"); + registerPluginView({ id: "p2-a", label: "C", component: fakeComponent }, "plugin-2"); + registerView(registration({ id: "builtin" })); // no pluginId — never swept + + unregisterViewsByPlugin("plugin-1"); + expect(getView("p1-a")).toBeUndefined(); + expect(getView("p1-b")).toBeUndefined(); + expect(getView("p2-a")).not.toBeUndefined(); + expect(getView("builtin")).not.toBeUndefined(); + + unregisterViewsByPlugin("plugin-2"); + expect(getView("p2-a")).toBeUndefined(); + expect(getView("builtin")).not.toBeUndefined(); + }); + + it("the sweep is idempotent and a no-op for an unknown plugin", () => { + unregisterViewsByPlugin("nobody"); + registerPluginView({ id: "p3-a", label: "A", component: fakeComponent }, "plugin-3"); + unregisterViewsByPlugin("plugin-3"); + expect(() => unregisterViewsByPlugin("plugin-3")).not.toThrow(); + }); + + it("a duplicate id from a plugin throws LOUDLY (the driver marks it failed)", () => { + registerView(registration({ id: "taken" })); + expect(() => + registerPluginView({ id: "taken", label: "Taken", component: fakeComponent }, "my-plugin"), + ).toThrow(/duplicate view id/); + }); + + it("sweep removals are visible to registry subscribers (reactive)", () => { + const seen: string[][] = []; + const unsubscribe = viewRegistry.subscribe((views) => { + seen.push(views.map((v) => v.id)); + }); + registerPluginView({ id: "reactive", label: "R", component: fakeComponent }, "p"); + unregisterViewsByPlugin("p"); + unsubscribe(); + expect(seen.at(-1)).not.toContain("reactive"); + }); +}); diff --git a/src/features/layout/logic/registry.ts b/src/features/layout/logic/registry.ts index b072d2f..3a86cb9 100644 --- a/src/features/layout/logic/registry.ts +++ b/src/features/layout/logic/registry.ts @@ -82,6 +82,38 @@ export function unregisterView(id: string): void { if (byId.delete(id)) publish(); } +/** + * Register a PLUGIN view (plan 03) — the ABI-facing registration: the + * `pluginId` attribution is FORCED to the importing plugin (plugins cannot + * attribute views to another plugin or to no plugin), which is what makes + * `unregisterViewsByPlugin`'s unload sweep exact. Returns the per-view + * disposer (the plugin's deactivate contract). A duplicate id throws + * LOUDLY — the driver catches it and marks the plugin's FE half failed. + */ +export function registerPluginView( + registration: Omit, + pluginId: string, +): () => void { + registerView({ ...registration, pluginId }); + return () => unregisterView(registration.id); +} + +/** + * Remove every view attributed to one plugin (the plan-03 unload sweep). + * Idempotent; the reactive registry re-renders open dropdowns, and tiles + * holding removed views fall back to `FallbackTile` (never a crash). + */ +export function unregisterViewsByPlugin(pluginId: string): void { + let changed = false; + for (const [id, view] of byId) { + if (view.pluginId === pluginId) { + byId.delete(id); + changed = true; + } + } + if (changed) publish(); +} + /** Look a view up by id (undefined → the renderer shows its fallback tile). */ export function getView(id: string): RegisteredView | undefined { return byId.get(id); -- cgit v1.2.3