From 42effc0503af944962db22fadd0037d77523a196 Mon Sep 17 00:00:00 2001 From: Michael Yong Date: Wed, 19 Aug 2026 21:38:40 -0700 Subject: [PATCH] perf(app): activate plugin styles only while mounted --- .../components/plugin/PluginPanelHeader.tsx | 2 + .../plugin/PluginSlotMount.test.tsx | 46 ++- .../src/components/plugin/PluginSlotMount.tsx | 2 + .../plugin/plugin-slot-mounts.test.tsx | 69 ++++- apps/app/src/lib/plugin-css.ts | 222 +++++++++++++++ .../lib/plugin-frontend-load-order.test.ts | 58 +--- .../src/lib/plugin-frontend-reload.test.ts | 268 +++++++++++++++++- apps/app/src/lib/plugin-frontend.ts | 105 +++---- .../bb-plugin-authoring/SKILL.md | 22 +- examples/plugins/content-script/README.md | 10 +- packages/domain/src/plugin-sdk-version.ts | 2 +- packages/plugin-sdk/README.md | 9 +- packages/plugin-sdk/package.json | 2 +- packages/plugin-sdk/src/app-contract.ts | 5 +- 14 files changed, 665 insertions(+), 157 deletions(-) create mode 100644 apps/app/src/lib/plugin-css.ts diff --git a/apps/app/src/components/plugin/PluginPanelHeader.tsx b/apps/app/src/components/plugin/PluginPanelHeader.tsx index ded47814fa..4f55483f23 100644 --- a/apps/app/src/components/plugin/PluginPanelHeader.tsx +++ b/apps/app/src/components/plugin/PluginPanelHeader.tsx @@ -1,6 +1,7 @@ import { Component, type ReactNode } from "react"; import type { PluginNavPanelChrome } from "@/lib/plugin-nav-panel-chrome"; import type { PluginNavPanelSlot } from "@/lib/plugin-slots"; +import { usePluginCss } from "@/lib/plugin-css"; import { PluginIcon } from "./PluginIcon"; import { PluginContext } from "./plugin-context"; import { useOptionalPaneContext } from "@/views/thread-detail/PaneContext"; @@ -78,6 +79,7 @@ export function PluginPanelHeaderActions({ }) { const paneContext = useOptionalPaneContext(); const HeaderContent = panel.headerContent; + usePluginCss(HeaderContent === undefined ? null : panel.pluginId); const panelStateId = getPluginPagePanelStateId({ panelPath: panel.path, paneId: paneId ?? paneContext?.paneId, diff --git a/apps/app/src/components/plugin/PluginSlotMount.test.tsx b/apps/app/src/components/plugin/PluginSlotMount.test.tsx index ca70ad3bcd..6cac4f15f8 100644 --- a/apps/app/src/components/plugin/PluginSlotMount.test.tsx +++ b/apps/app/src/components/plugin/PluginSlotMount.test.tsx @@ -1,12 +1,14 @@ // @vitest-environment jsdom -import { cleanup, render, screen } from "@testing-library/react"; +import { act, cleanup, render, screen } from "@testing-library/react"; +import { createPortal } from "react-dom"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { PluginSlotMount, resetAllCrashedPluginSlotsForTest, resetCrashedPluginSlots, } from "./PluginSlotMount"; +import { applyPluginCss, resetPluginCssForTest } from "@/lib/plugin-css"; function Bomb(): never { throw new Error("kaboom"); @@ -19,6 +21,7 @@ function Healthy() { describe("PluginSlotMount", () => { beforeEach(() => { resetAllCrashedPluginSlotsForTest(); + resetPluginCssForTest(); // React logs boundary-caught errors; keep test output quiet. vi.spyOn(console, "error").mockImplementation(() => {}); vi.spyOn(console, "warn").mockImplementation(() => {}); @@ -26,6 +29,7 @@ describe("PluginSlotMount", () => { afterEach(() => { cleanup(); + resetPluginCssForTest(); vi.restoreAllMocks(); }); @@ -49,6 +53,46 @@ describe("PluginSlotMount", () => { expect(screen.getByText("healthy slot")).toBeDefined(); }); + it("keeps one sheet through simultaneous mounts and a portal until the final route unmount", async () => { + applyPluginCss("demo", "/demo.css?h=v1"); + function PortalContent() { + return createPortal(
portalled plugin content
, document.body); + } + const view = render( + <> + + + + + + + , + ); + const pluginSheets = () => + document.head.querySelectorAll('link[data-bb-plugin-css="demo"]'); + expect(pluginSheets()).toHaveLength(1); + expect(screen.getByText("portalled plugin content")).toBeDefined(); + + view.rerender( + + + , + ); + expect(pluginSheets()).toHaveLength(1); + + view.unmount(); + await act(async () => {}); + expect(pluginSheets()).toHaveLength(0); + }); + it("keeps a crashed slot instance disabled for the session across remounts", () => { const first = render( diff --git a/apps/app/src/components/plugin/PluginSlotMount.tsx b/apps/app/src/components/plugin/PluginSlotMount.tsx index 084bfd555f..1b5123bca7 100644 --- a/apps/app/src/components/plugin/PluginSlotMount.tsx +++ b/apps/app/src/components/plugin/PluginSlotMount.tsx @@ -1,5 +1,6 @@ import { Component, type ErrorInfo, type ReactNode } from "react"; import { Pill } from "@bb/shared-ui/pill"; +import { usePluginCss } from "@/lib/plugin-css"; import { PluginContext, PluginSlotOwnershipContext, @@ -223,6 +224,7 @@ export function PluginSlotMount({ instanceId, onCrash, }: PluginSlotMountProps) { + usePluginCss(pluginId); return ( { resetPluginFrontendBootStateForTest(); window.localStorage.clear(); resetAllCrashedPluginSlotsForTest(); + resetPluginCssForTest(); vi.restoreAllMocks(); }); @@ -1311,6 +1313,58 @@ describe("PluginNavSidebarItems + PluginPanelView", () => { expect(screen.getByText("board panel body")).toBeDefined(); }); + it("releases the plugin stylesheet when navigation unmounts the panel route", async () => { + setPluginSlotRegistrations( + "demo", + registrationSet({ + navPanels: [ + { + id: "board", + title: "Demo board", + icon: "columns", + path: "board", + component: Board, + }, + ], + }), + ); + applyPluginCss("demo", "/demo.css?h=route"); + function LeavePanel() { + const navigate = useNavigate(); + return ( + + ); + } + render( + + + + + + + } + /> + home} /> + + , + ); + expect( + document.head.querySelector('link[data-bb-plugin-css="demo"]'), + ).not.toBeNull(); + + fireEvent.click(screen.getByRole("button", { name: "Leave panel" })); + await act(async () => {}); + expect(screen.getByText("home")).toBeDefined(); + expect( + document.head.querySelector('link[data-bb-plugin-css="demo"]'), + ).toBeNull(); + }); + it("shows a plugin panel's position when it is open in a split", () => { setPluginSlotRegistrations( "demo", @@ -1534,12 +1588,13 @@ describe("plugin panel shared title bar and full-bleed body", () => { expect(screen.queryByText(/plugin demo crashed/)).toBeNull(); }); - it("always renders the shared title and headerContent", () => { + it("gives headerContent independent CSS ownership without a mounted panel body", async () => { function Accessory() { return ; } const panel = panelSlot({ headerContent: Accessory }); - render( + applyPluginCss("demo", "/demo.css?h=header"); + const view = render( <> @@ -1549,6 +1604,16 @@ describe("plugin panel shared title bar and full-bleed body", () => { expect( screen.getByRole("button", { name: "Toggle sidebar" }), ).toBeDefined(); + expect( + document.head.querySelector('link[data-bb-plugin-css="demo"]'), + ).not.toBeNull(); + expect(screen.queryByTestId("plugin-panel-body")).toBeNull(); + + view.unmount(); + await act(async () => {}); + expect( + document.head.querySelector('link[data-bb-plugin-css="demo"]'), + ).toBeNull(); }); it("keys the right-panel toggle target to its owning pane", () => { diff --git a/apps/app/src/lib/plugin-css.ts b/apps/app/src/lib/plugin-css.ts new file mode 100644 index 0000000000..b86e3f6cff --- /dev/null +++ b/apps/app/src/lib/plugin-css.ts @@ -0,0 +1,222 @@ +import { useInsertionEffect } from "react"; + +const CSS_MARKER = "data-bb-plugin-css"; +const CSS_PRELOAD_MARKER = "data-bb-plugin-css-preload"; + +interface PluginCssRecord { + consumers: number; + cleanupEpoch: number; + loadedUrl: string | null; + pendingStylesheet: HTMLLinkElement | null; + preload: HTMLLinkElement | null; + stylesheet: HTMLLinkElement | null; + url: string | null; +} + +const recordsByPluginId = new Map(); + +function recordFor(pluginId: string): PluginCssRecord { + const existing = recordsByPluginId.get(pluginId); + if (existing !== undefined) return existing; + const created: PluginCssRecord = { + consumers: 0, + cleanupEpoch: 0, + loadedUrl: null, + pendingStylesheet: null, + preload: null, + stylesheet: null, + url: null, + }; + recordsByPluginId.set(pluginId, created); + return created; +} + +function linkUrl(link: HTMLLinkElement | null): string | null { + return link?.getAttribute("href") ?? null; +} + +function removeLink(link: HTMLLinkElement | null): void { + link?.remove(); +} + +function warnLoadFailure(pluginId: string, url: string): void { + console.warn(`bb plugin "${pluginId}": failed to load stylesheet ${url}`); +} + +function startPreload( + pluginId: string, + record: PluginCssRecord, + url: string, +): void { + if (record.loadedUrl === url || linkUrl(record.preload) === url) return; + removeLink(record.preload); + const link = document.createElement("link"); + link.rel = "preload"; + link.as = "style"; + link.fetchPriority = "low"; + link.href = url; + link.setAttribute(CSS_PRELOAD_MARKER, pluginId); + record.preload = link; + link.onload = () => { + link.remove(); + if (record.preload === link) record.preload = null; + if (record.url !== url) return; + record.loadedUrl = url; + if (record.consumers > 0) activateStylesheet(pluginId, record, url); + }; + link.onerror = () => { + link.remove(); + if (record.preload === link) record.preload = null; + if (record.url === url) warnLoadFailure(pluginId, url); + }; + document.head.appendChild(link); +} + +function activateStylesheet( + pluginId: string, + record: PluginCssRecord, + url: string, +): void { + if (linkUrl(record.pendingStylesheet) === url) return; + if (linkUrl(record.stylesheet) === url) { + record.loadedUrl = url; + removeLink(record.preload); + record.preload = null; + return; + } + + removeLink(record.preload); + record.preload = null; + removeLink(record.pendingStylesheet); + + const previous = record.stylesheet; + const link = document.createElement("link"); + link.rel = "stylesheet"; + link.href = url; + link.setAttribute(CSS_MARKER, pluginId); + record.pendingStylesheet = link; + link.onload = () => { + if ( + record.pendingStylesheet !== link || + record.url !== url || + record.consumers === 0 + ) { + link.remove(); + return; + } + previous?.remove(); + record.stylesheet = link; + record.pendingStylesheet = null; + record.loadedUrl = url; + }; + link.onerror = () => { + link.remove(); + if (record.pendingStylesheet === link) record.pendingStylesheet = null; + if (record.url === url) warnLoadFailure(pluginId, url); + }; + document.head.appendChild(link); +} + +function deactivateStylesheet(record: PluginCssRecord): void { + removeLink(record.pendingStylesheet); + removeLink(record.stylesheet); + record.pendingStylesheet = null; + record.stylesheet = null; +} + +function deactivateAfterFinalRelease( + pluginId: string, + record: PluginCssRecord, +): void { + const cleanupEpoch = ++record.cleanupEpoch; + queueMicrotask(() => { + if (record.cleanupEpoch !== cleanupEpoch || record.consumers > 0) return; + deactivateStylesheet(record); + if (record.url === null) { + recordsByPluginId.delete(pluginId); + return; + } + startPreload(pluginId, record, record.url); + }); +} + +/** + * Publish the stylesheet URL for the current frontend generation. + * + * An inactive bundle warms its immutable response with a low-priority preload + * and removes that link after it settles. Mounted plugin UI owns a real + * stylesheet through {@link retainPluginCss}; the final release removes it. + * A changed URL loads beside the active sheet and replaces it only after the + * new response succeeds, so a failed live reload leaves the old CSS usable. + */ +export function applyPluginCss(pluginId: string, url: string | null): void { + const record = recordFor(pluginId); + if (url === null) { + record.cleanupEpoch += 1; + record.url = null; + record.loadedUrl = null; + removeLink(record.preload); + record.preload = null; + deactivateStylesheet(record); + if (record.consumers === 0) recordsByPluginId.delete(pluginId); + return; + } + + if (record.url === url) { + if (record.consumers > 0) activateStylesheet(pluginId, record, url); + else startPreload(pluginId, record, url); + return; + } + + record.cleanupEpoch += 1; + record.url = url; + record.loadedUrl = linkUrl(record.stylesheet) === url ? url : null; + removeLink(record.preload); + record.preload = null; + if (record.consumers > 0) { + activateStylesheet(pluginId, record, url); + return; + } + deactivateStylesheet(record); + startPreload(pluginId, record, url); +} + +/** Keep one plugin stylesheet active until the returned release is called. */ +export function retainPluginCss(pluginId: string): () => void { + const record = recordFor(pluginId); + record.cleanupEpoch += 1; + record.consumers += 1; + if (record.url !== null) activateStylesheet(pluginId, record, record.url); + let released = false; + return () => { + if (released) return; + released = true; + record.consumers = Math.max(0, record.consumers - 1); + if (record.consumers === 0) { + deactivateAfterFinalRelease(pluginId, record); + } + }; +} + +/** Activate cached plugin CSS before React lays out or paints a scoped slot. */ +export function usePluginCss(pluginId: string | null): void { + useInsertionEffect( + () => (pluginId === null ? undefined : retainPluginCss(pluginId)), + [pluginId], + ); +} + +/** Test-only. */ +export function resetPluginCssForTest(): void { + for (const record of recordsByPluginId.values()) { + record.cleanupEpoch += 1; + removeLink(record.preload); + deactivateStylesheet(record); + } + recordsByPluginId.clear(); + for (const link of document.head.querySelectorAll( + `link[${CSS_MARKER}], link[${CSS_PRELOAD_MARKER}]`, + )) { + link.remove(); + } +} diff --git a/apps/app/src/lib/plugin-frontend-load-order.test.ts b/apps/app/src/lib/plugin-frontend-load-order.test.ts index f12f1c79f5..f67a158717 100644 --- a/apps/app/src/lib/plugin-frontend-load-order.test.ts +++ b/apps/app/src/lib/plugin-frontend-load-order.test.ts @@ -3,7 +3,6 @@ import { describe, expect, it, vi } from "vitest"; import { definePluginApp } from "./plugin-app-definition"; import { - createBatchedPluginCssApplier, createPluginFrontendReconcileState, orderPluginFrontendCandidates, PLUGIN_FRONTEND_LOAD_CONCURRENCY, @@ -63,6 +62,7 @@ function makeDeps( fetchCandidates: async () => candidates, importModule: async () => pluginModule(), applyCss: vi.fn(), + retainCss: vi.fn(() => vi.fn()), resetCrashedSlots: vi.fn(), setRegistrations: vi.fn(), removeRegistrations: vi.fn(), @@ -165,59 +165,3 @@ describe("reconcilePluginFrontends load scheduling", () => { expect(state.records.get("also")?.status).toBe("loaded"); }); }); - -describe("createBatchedPluginCssApplier", () => { - it("coalesces insertions that land before the next frame into one flush", () => { - const apply = vi.fn(); - const frames: Array<() => void> = []; - const applyCss = createBatchedPluginCssApplier({ - apply, - requestFrame: (callback) => { - frames.push(callback); - }, - }); - - applyCss("a", "/a.css"); - applyCss("b", "/b.css"); - applyCss("a", "/a2.css"); // newer URL for the same plugin replaces - expect(apply).not.toHaveBeenCalled(); - expect(frames).toHaveLength(1); - - frames[0]!(); - expect(apply.mock.calls).toEqual([ - ["a", "/a2.css"], - ["b", "/b.css"], - ]); - - // A later insertion requests a fresh frame rather than being dropped. - applyCss("c", "/c.css"); - expect(frames).toHaveLength(2); - frames[1]!(); - expect(apply).toHaveBeenLastCalledWith("c", "/c.css"); - }); - - it("removes synchronously and cancels a pending insertion for that plugin", () => { - const apply = vi.fn(); - const frames: Array<() => void> = []; - const applyCss = createBatchedPluginCssApplier({ - apply, - requestFrame: (callback) => { - frames.push(callback); - }, - }); - - applyCss("a", "/a.css"); - applyCss("b", "/b.css"); - applyCss("a", null); - // Teardown/disposal must not leave a sheet behind, so removal does not - // wait for the frame. - expect(apply.mock.calls).toEqual([["a", null]]); - - frames[0]!(); - // The pending insertion for "a" was cancelled by the removal. - expect(apply.mock.calls).toEqual([ - ["a", null], - ["b", "/b.css"], - ]); - }); -}); diff --git a/apps/app/src/lib/plugin-frontend-reload.test.ts b/apps/app/src/lib/plugin-frontend-reload.test.ts index bbdaefb61e..50ad80459f 100644 --- a/apps/app/src/lib/plugin-frontend-reload.test.ts +++ b/apps/app/src/lib/plugin-frontend-reload.test.ts @@ -3,8 +3,9 @@ import type { PluginComposerThreadRowStatus } from "@get-bb/plugin-sdk"; import { createElement } from "react"; import { createRoot } from "react-dom/client"; +import { MemoryRouter, Route, Routes } from "react-router-dom"; import { act } from "@testing-library/react"; -import { afterEach, describe, expect, it, vi } from "vitest"; +import { afterEach, describe, expect, it, type Mock, vi } from "vitest"; import { installForeignDomMutationGuard, uninstallForeignDomMutationGuardForTest, @@ -19,16 +20,22 @@ import { type PluginFrontendCandidate, type PluginFrontendReconcileDeps, } from "./plugin-frontend"; +import { resetPluginCssForTest, retainPluginCss } from "./plugin-css"; import { getPluginSlotSnapshot, removePluginSlotRegistrations, resetPluginSlotStoreForTest, setPluginSlotRegistrations, + usePluginSlots, } from "./plugin-slots"; import { getPluginThreadRowStatus, resetPluginThreadRowStatusesForTest, } from "./plugin-thread-row-status"; +import { PluginSlotMount } from "@/components/plugin/PluginSlotMount"; +import { PLUGIN_PANEL_ROUTE_PATH } from "./route-paths"; +import { applyAppThemeCss } from "./themes"; +import { PluginPanelView } from "@/views/PluginPanelView"; function candidate( pluginId: string, @@ -71,10 +78,36 @@ function contentScriptModule( afterEach(() => { resetPluginThreadRowStatusesForTest(); + resetPluginSlotStoreForTest(); + resetPluginCssForTest(); uninstallForeignDomMutationGuardForTest(); }); -function makeDeps(initial: PluginFrontendCandidate[] = []) { +function MountedHomepageSections() { + const { homepageSections } = usePluginSlots(); + return createElement( + "div", + null, + ...homepageSections.map((section) => + createElement(PluginSlotMount, { + key: `${section.pluginId}/${section.id}/${section.generation}`, + pluginId: section.pluginId, + slotKind: "homepageSection", + slotId: section.id, + children: createElement(section.component, { projectId: null }), + }), + ), + ); +} + +interface TestReconcileDeps extends PluginFrontendReconcileDeps { + fetchCandidates: Mock<() => Promise>; + importModule: Mock<(url: string) => Promise>; + removeRegistrations: Mock; + setRegistrations: Mock; +} + +function makeDeps(initial: PluginFrontendCandidate[] = []): TestReconcileDeps { return { fetchCandidates: vi.fn( async (): Promise => initial, @@ -83,6 +116,7 @@ function makeDeps(initial: PluginFrontendCandidate[] = []) { async (_url: string): Promise => pluginModule("hello"), ), applyCss: vi.fn(), + retainCss: vi.fn(() => vi.fn()), resetCrashedSlots: vi.fn(), setRegistrations: vi.fn(), removeRegistrations: vi.fn(), @@ -90,7 +124,7 @@ function makeDeps(initial: PluginFrontendCandidate[] = []) { warn: vi.fn(), routePluginId: () => null, mountTimeoutMs: undefined as number | undefined, - } satisfies PluginFrontendReconcileDeps; + }; } describe("reconcilePluginFrontends", () => { @@ -152,6 +186,7 @@ describe("reconcilePluginFrontends", () => { fetchCandidates, importModule: async () => pluginModule("hello"), applyCss: vi.fn(), + retainCss: vi.fn(() => vi.fn()), resetCrashedSlots: vi.fn(), setRegistrations: setPluginSlotRegistrations, removeRegistrations: removePluginSlotRegistrations, @@ -177,6 +212,171 @@ describe("reconcilePluginFrontends", () => { resetPluginSlotStoreForTest(); }); + it("publishes CSS before a cold deep-link panel registration can render", async () => { + const state = createPluginFrontendReconcileState(); + const preparedDuringRender = vi.fn(); + const deps = makeDeps([candidate("hello", "cold")]); + deps.applyCss = applyPluginCss; + deps.retainCss = retainPluginCss; + deps.setRegistrations = vi.fn(setPluginSlotRegistrations); + deps.removeRegistrations = vi.fn(removePluginSlotRegistrations); + deps.importModule.mockResolvedValue({ + default: definePluginApp((app) => { + app.slots.navPanel({ + id: "panel", + icon: "PanelTop", + path: "panel", + title: "Cold panel", + component: ({ subPath }) => { + const prepared = document.head.querySelector( + 'link[data-bb-plugin-css-preload="hello"], link[data-bb-plugin-css="hello"]', + ); + preparedDuringRender(prepared?.getAttribute("href") ?? null); + return createElement("div", null, `cold panel body:${subPath}`); + }, + }); + }), + }); + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + act(() => { + root.render( + createElement( + MemoryRouter, + { initialEntries: ["/plugins/hello/panel/notes/today.md"] }, + createElement( + Routes, + null, + createElement(Route, { + path: PLUGIN_PANEL_ROUTE_PATH, + element: createElement(PluginPanelView), + }), + ), + ), + ); + }); + + await act(async () => { + await reconcilePluginFrontends(state, deps); + }); + + expect(preparedDuringRender).toHaveBeenCalledWith( + "/api/v1/plugins/hello/assets/app.css?h=cold", + ); + expect(container.textContent).toContain("cold panel body:notes/today.md"); + expect( + document.head.querySelector('link[data-bb-plugin-css="hello"]'), + ).not.toBeNull(); + + act(() => root.unmount()); + container.remove(); + }); + + it("retains CSS for a content script's whole generation, including cleanup", async () => { + const state = createPluginFrontendReconcileState(); + const deps = makeDeps([candidate("shell-owner", "v1")]); + const events: string[] = []; + const stylesheetIsActive = () => + document.head.querySelector('link[data-bb-plugin-css="shell-owner"]') !== + null; + deps.applyCss = applyPluginCss; + deps.retainCss = retainPluginCss; + deps.importModule.mockResolvedValue( + contentScriptModule((app) => { + app.contentScripts.register({ + id: "shell-dom", + mount() { + events.push(`mount:${stylesheetIsActive()}`); + return () => { + events.push(`dispose:${stylesheetIsActive()}`); + }; + }, + }); + }), + ); + + await reconcilePluginFrontends(state, deps); + expect(events).toEqual(["mount:true"]); + expect(stylesheetIsActive()).toBe(true); + + deps.fetchCandidates.mockResolvedValue([]); + await reconcilePluginFrontends(state, deps); + expect(events).toEqual(["mount:true", "dispose:true"]); + expect(stylesheetIsActive()).toBe(false); + }); + + it("keeps the active sheet through a real generation reload and a failed CSS replacement", async () => { + const state = createPluginFrontendReconcileState(); + const deps = makeDeps([candidate("hello", "v1")]); + deps.applyCss = applyPluginCss; + deps.retainCss = retainPluginCss; + deps.setRegistrations = vi.fn(setPluginSlotRegistrations); + deps.removeRegistrations = vi.fn(removePluginSlotRegistrations); + deps.importModule.mockImplementation(async (url: string) => { + const version = /[?&]h=([^&]+)/.exec(url)?.[1] ?? "unknown"; + return { + default: definePluginApp((app) => { + app.slots.homepageSection({ + id: "section", + title: version, + component: () => + createElement("div", null, `generation ${version}`), + }); + }), + }; + }); + const links = () => [ + ...document.head.querySelectorAll( + 'link[data-bb-plugin-css="hello"]', + ), + ]; + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + act(() => root.render(createElement(MountedHomepageSections))); + + await act(async () => { + await reconcilePluginFrontends(state, deps); + }); + expect(container.textContent).toContain("generation v1"); + links()[0]?.dispatchEvent(new Event("load")); + + deps.fetchCandidates.mockResolvedValue([candidate("hello", "v2")]); + await act(async () => { + await reconcilePluginFrontends(state, deps); + }); + expect(container.textContent).toContain("generation v2"); + expect(links().map((link) => link.getAttribute("href"))).toEqual([ + "/api/v1/plugins/hello/assets/app.css?h=v1", + "/api/v1/plugins/hello/assets/app.css?h=v2", + ]); + + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + links()[1]?.dispatchEvent(new Event("error")); + expect(links().map((link) => link.getAttribute("href"))).toEqual([ + "/api/v1/plugins/hello/assets/app.css?h=v1", + ]); + expect(container.textContent).toContain("generation v2"); + warn.mockRestore(); + + deps.fetchCandidates.mockResolvedValue([candidate("hello", "v3")]); + await act(async () => { + await reconcilePluginFrontends(state, deps); + }); + expect(links().map((link) => link.getAttribute("href"))).toEqual([ + "/api/v1/plugins/hello/assets/app.css?h=v1", + "/api/v1/plugins/hello/assets/app.css?h=v3", + ]); + links()[1]?.dispatchEvent(new Event("load")); + expect(links().map((link) => link.getAttribute("href"))).toEqual([ + "/api/v1/plugins/hello/assets/app.css?h=v3", + ]); + + act(() => root.unmount()); + container.remove(); + }); + it("drops registrations, CSS, and record when a plugin disappears from the inventory", async () => { const state = createPluginFrontendReconcileState(); const deps = makeDeps([candidate("hello", "v1")]); @@ -809,11 +1009,7 @@ describe("reconcilePluginFrontends", () => { describe("applyPluginCss", () => { afterEach(() => { - for (const link of [ - ...document.head.querySelectorAll("link[data-bb-plugin-css]"), - ]) { - link.remove(); - } + resetPluginCssForTest(); }); function links(pluginId: string): HTMLLinkElement[] { @@ -824,9 +1020,19 @@ describe("applyPluginCss", () => { ]; } + function preloads(pluginId: string): HTMLLinkElement[] { + return [ + ...document.head.querySelectorAll( + `link[data-bb-plugin-css-preload="${pluginId}"]`, + ), + ]; + } + it("keeps the old link until the new one loads, then removes it (no unstyled flash)", () => { + retainPluginCss("hello"); applyPluginCss("hello", "/assets/app.css?h=aaa"); expect(links("hello")).toHaveLength(1); + links("hello")[0]?.dispatchEvent(new Event("load")); applyPluginCss("hello", "/assets/app.css?h=bbb"); // Both links coexist while the fresh sheet is still loading. @@ -843,7 +1049,9 @@ describe("applyPluginCss", () => { }); it("on load error, drops the new link and keeps the old sheet working", () => { + retainPluginCss("hello"); applyPluginCss("hello", "/assets/app.css?h=aaa"); + links("hello")[0]?.dispatchEvent(new Event("load")); applyPluginCss("hello", "/assets/app.css?h=bbb"); const fresh = links("hello")[1]; @@ -857,6 +1065,7 @@ describe("applyPluginCss", () => { }); it("keeps the same element for an unchanged URL and removes it on null", () => { + retainPluginCss("hello"); applyPluginCss("hello", "/assets/app.css?h=aaa"); const first = links("hello")[0]; applyPluginCss("hello", "/assets/app.css?h=aaa"); @@ -865,6 +1074,49 @@ describe("applyPluginCss", () => { applyPluginCss("hello", null); expect(links("hello")).toHaveLength(0); }); + + it("preloads inactive CSS and removes the sheet only after its final consumer releases", async () => { + applyPluginCss("hello", "/assets/app.css?h=aaa"); + expect(preloads("hello")).toHaveLength(1); + expect(preloads("hello")[0]?.fetchPriority).toBe("low"); + expect(links("hello")).toHaveLength(0); + + preloads("hello")[0]?.dispatchEvent(new Event("load")); + expect(preloads("hello")).toHaveLength(0); + const releaseFirst = retainPluginCss("hello"); + const releaseSecond = retainPluginCss("hello"); + expect(links("hello")).toHaveLength(1); + + releaseFirst(); + await Promise.resolve(); + expect(links("hello")).toHaveLength(1); + releaseSecond(); + await Promise.resolve(); + expect(links("hello")).toHaveLength(0); + }); + + it("never ties app-wide bb.themes palette CSS to plugin UI mounts", async () => { + const paletteCss = ":root { --canvas: rebeccapurple; }"; + applyAppThemeCss(paletteCss); + const palette = document.getElementById("bb-app-theme"); + expect(palette?.textContent).toBe(paletteCss); + + applyPluginCss("palette-owner", "/assets/app.css?h=palette-owner"); + preloads("palette-owner")[0]?.dispatchEvent(new Event("load")); + expect(links("palette-owner")).toHaveLength(0); + expect(document.getElementById("bb-app-theme")).toBe(palette); + expect(palette?.textContent).toBe(paletteCss); + + const release = retainPluginCss("palette-owner"); + release(); + await Promise.resolve(); + expect(links("palette-owner")).toHaveLength(0); + expect(document.getElementById("bb-app-theme")).toBe(palette); + expect(palette?.textContent).toBe(paletteCss); + + applyAppThemeCss(""); + palette?.remove(); + }); }); describe("createPluginFrontendReconcileScheduler", () => { diff --git a/apps/app/src/lib/plugin-frontend.ts b/apps/app/src/lib/plugin-frontend.ts index 0b1e2a2653..ac61e5a7c2 100644 --- a/apps/app/src/lib/plugin-frontend.ts +++ b/apps/app/src/lib/plugin-frontend.ts @@ -42,6 +42,7 @@ import type { import { normalizePluginThreadRowStatus } from "@get-bb/plugin-sdk/internal/composer-customization-validation"; import { resetCrashedPluginSlots } from "@/components/plugin/PluginSlotMount"; import { runWithPluginDomIsolationAsync } from "./foreign-dom-mutation-guard"; +import { applyPluginCss, retainPluginCss } from "./plugin-css"; import { collectPluginAppRegistrations, isPluginAppDefinition, @@ -346,69 +347,7 @@ async function fetchFrontendCandidates(): Promise { return candidates; } -/** - * Point a plugin's stylesheet `` at `url`, - * or remove it (`url: null`). A changed URL swaps in a fresh element (the - * new sheet loads, then the old element is removed) rather than mutating - * `href`, so a reload never flashes unstyled plugin UI. If the fresh sheet - * fails to load, it is dropped and the old sheet stays in place. - */ -export function applyPluginCss(pluginId: string, url: string | null): void { - const marker = "data-bb-plugin-css"; - const existing = [ - ...document.head.querySelectorAll(`link[${marker}="${pluginId}"]`), - ]; - if (url === null) { - for (const link of existing) link.remove(); - return; - } - if (existing.some((link) => link.getAttribute("href") === url)) return; - const link = document.createElement("link"); - link.rel = "stylesheet"; - link.href = url; - link.setAttribute(marker, pluginId); - link.onload = () => { - for (const old of existing) old.remove(); - }; - link.onerror = () => { - link.remove(); - console.warn(`bb plugin "${pluginId}": failed to load stylesheet ${url}`); - }; - document.head.appendChild(link); -} - -/** - * Coalesce plugin stylesheet insertions into one animation frame. Each - * `` append invalidates document-wide style; bundles - * finish importing at scattered moments, so without this every plugin costs - * its own recalc. Removals (`url: null`) stay synchronous — teardown and - * disposal must not leave a sheet behind — and cancel a pending insertion. - * A newer URL for the same plugin replaces the pending one. - */ -export function createBatchedPluginCssApplier(args: { - apply: (pluginId: string, url: string | null) => void; - requestFrame: (callback: () => void) => void; -}): (pluginId: string, url: string | null) => void { - const pending = new Map(); - let frameRequested = false; - const flush = () => { - frameRequested = false; - const batch = [...pending]; - pending.clear(); - for (const [pluginId, url] of batch) args.apply(pluginId, url); - }; - return (pluginId, url) => { - if (url === null) { - pending.delete(pluginId); - args.apply(pluginId, null); - return; - } - pending.set(pluginId, url); - if (frameRequested) return; - frameRequested = true; - args.requestFrame(flush); - }; -} +export { applyPluginCss } from "./plugin-css"; /** How many plugin bundles import at once during a reconcile pass. */ export const PLUGIN_FRONTEND_LOAD_CONCURRENCY = 3; @@ -485,8 +424,10 @@ export function createPluginFrontendReconcileState(): PluginFrontendReconcileSta export interface PluginFrontendReconcileDeps { fetchCandidates: () => Promise; importModule: (url: string) => Promise; - /** Replace (string) or remove (null) the plugin's CSS ``. */ + /** Synchronously publish (string) or remove (null) the generation's CSS URL. */ applyCss: (pluginId: string, url: string | null) => void; + /** Retain the published CSS through one non-React consumer's lifetime. */ + retainCss: (pluginId: string) => () => void; resetCrashedSlots: (pluginId: string) => void; setRegistrations: ( pluginId: string, @@ -521,6 +462,7 @@ interface ActivePluginFrontendGeneration { controller: AbortController; statusOwner: symbol; scripts: MountedContentScript[]; + cssRelease: (() => void) | null; disposed: boolean; } @@ -586,6 +528,8 @@ async function disposeGeneration( ); if (failure !== null) failures.push(failure); } + activation.cssRelease?.(); + activation.cssRelease = null; clearPluginThreadRowStatusesByOwner(activation.statusOwner); return failures; } @@ -594,6 +538,7 @@ async function deactivateCommittedGeneration( pluginId: string, state: PluginFrontendReconcileState, deps: PluginFrontendReconcileDeps, + removePublishedUi = true, ): Promise { const active = state.activeGenerations.get(pluginId); if (active === undefined) { @@ -604,8 +549,10 @@ async function deactivateCommittedGeneration( clearPluginThreadRowStatuses(pluginId); state.activeGenerations.delete(pluginId); state.appliedHashes.delete(pluginId); - deps.removeRegistrations(pluginId); - deps.applyCss(pluginId, null); + if (removePublishedUi) { + deps.removeRegistrations(pluginId); + deps.applyCss(pluginId, null); + } return failures; } @@ -708,6 +655,7 @@ async function activateContentScripts( registrations: readonly PluginContentScriptRegistration[], controller: AbortController, statusOwner: symbol, + cssRelease: (() => void) | null, deps: PluginFrontendReconcileDeps, ): Promise< | { ok: true; activation: ActivePluginFrontendGeneration } @@ -719,6 +667,7 @@ async function activateContentScripts( controller, statusOwner, scripts: [], + cssRelease, disposed: false, }; try { @@ -897,10 +846,20 @@ async function reconcileCandidates( const generation = (state.generationByPluginId.get(pluginId) ?? 0) + 1; state.generationByPluginId.set(pluginId, generation); + // Publish the URL before either non-React scripts mount or slot-store + // notifications can render plugin code. Inactive plugins only preload; + // an already-mounted generation starts a safe side-by-side replacement. + deps.applyCss(pluginId, candidate.bundle.cssUrl); + const cssRelease = + collected.contentScripts.length > 0 ? deps.retainCss(pluginId) : null; const disposeFailures = await deactivateCommittedGeneration( pluginId, state, deps, + // Keep the old registration mounted until candidate content scripts + // succeed. The final setRegistrations call replaces it atomically, so + // an old UI consumer holds the active sheet through the CSS handoff. + false, ); const controller = new AbortController(); const statusOwner = Symbol( @@ -915,6 +874,7 @@ async function reconcileCandidates( collected.contentScripts, controller, statusOwner, + cssRelease, deps, ); state.pendingControllers.delete(pluginId); @@ -923,9 +883,13 @@ async function reconcileCandidates( if (activationResult.ok) { await disposeGeneration(pluginId, activationResult.activation, deps); } + deps.removeRegistrations(pluginId); + deps.applyCss(pluginId, null); return; } if (!activationResult.ok) { + deps.removeRegistrations(pluginId); + deps.applyCss(pluginId, null); const failed: PluginFrontendRecord = { pluginId, status: "failed", @@ -943,7 +907,6 @@ async function reconcileCandidates( state.activeGenerations.set(pluginId, activationResult.activation); deps.setRegistrations(pluginId, collected); - deps.applyCss(pluginId, candidate.bundle.cssUrl); state.records.set(pluginId, record); state.appliedHashes.set(pluginId, candidate.bundle.hash); publishDiagnostic(state, deps, { @@ -1060,12 +1023,8 @@ const PLUGIN_SLOT_BATCH_MAX_HOLD_MS = 150; const browserReconcileDeps: PluginFrontendReconcileDeps = { fetchCandidates: fetchFrontendCandidates, importModule: (url) => import(/* @vite-ignore */ url), - applyCss: createBatchedPluginCssApplier({ - apply: applyPluginCss, - requestFrame: (callback) => { - window.requestAnimationFrame(callback); - }, - }), + applyCss: applyPluginCss, + retainCss: retainPluginCss, routePluginId: () => getPluginPanelRoutePluginId(window.location.pathname), resetCrashedSlots: resetCrashedPluginSlots, setRegistrations: setPluginSlotRegistrations, diff --git a/apps/server/src/services/skills/builtin-skills/bb-plugin-authoring/SKILL.md b/apps/server/src/services/skills/builtin-skills/bb-plugin-authoring/SKILL.md index 6ed8dc424e..679c9cb429 100644 --- a/apps/server/src/services/skills/builtin-skills/bb-plugin-authoring/SKILL.md +++ b/apps/server/src/services/skills/builtin-skills/bb-plugin-authoring/SKILL.md @@ -1522,13 +1522,21 @@ window's last load/setup/mount/dispose failure appears on the plugin Settings detail page. The host cannot catch a detached promise that plugin code creates and never returns, so detached work must handle its own errors. -Prefer the existing imported `app.css` pipeline for static styles. A content -script may create DOM or `