Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions apps/app/src/components/plugin/PluginPanelHeader.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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,
Expand Down
46 changes: 45 additions & 1 deletion apps/app/src/components/plugin/PluginSlotMount.test.tsx
Original file line number Diff line number Diff line change
@@ -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");
Expand All @@ -19,13 +21,15 @@ 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(() => {});
});

afterEach(() => {
cleanup();
resetPluginCssForTest();
vi.restoreAllMocks();
});

Expand All @@ -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(<div>portalled plugin content</div>, document.body);
}
const view = render(
<>
<PluginSlotMount pluginId="demo" slotKind="navPanel" slotId="main">
<Healthy />
</PluginSlotMount>
<PluginSlotMount
pluginId="demo"
slotKind="threadPanelAction"
slotId="details"
>
<PortalContent />
</PluginSlotMount>
</>,
);
const pluginSheets = () =>
document.head.querySelectorAll('link[data-bb-plugin-css="demo"]');
expect(pluginSheets()).toHaveLength(1);
expect(screen.getByText("portalled plugin content")).toBeDefined();

view.rerender(
<PluginSlotMount
pluginId="demo"
slotKind="threadPanelAction"
slotId="details"
>
<PortalContent />
</PluginSlotMount>,
);
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(
<PluginSlotMount pluginId="broken" slotKind="navPanel" slotId="board">
Expand Down
2 changes: 2 additions & 0 deletions apps/app/src/components/plugin/PluginSlotMount.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -223,6 +224,7 @@ export function PluginSlotMount({
instanceId,
onCrash,
}: PluginSlotMountProps) {
usePluginCss(pluginId);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚨 slopcop/review — Cover the provider icon slot

This hook covers normal plugin mounts. The experimental_providerIcon path renders directly through provider-icon.ts. It does not keep the stylesheet active or add a plugin scope root. An icon-only frontend can render while its imported CSS stays inactive.

Use a shared plugin render wrapper. It should own the CSS lease, scope attributes, context, and error boundary.

return (
<PluginContext.Provider value={pluginId}>
<PluginSlotBoundary
Expand Down
69 changes: 67 additions & 2 deletions apps/app/src/components/plugin/plugin-slot-mounts.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ import {
PluginPanelHeaderCenter,
} from "./PluginPanelHeader";
import { resetAllCrashedPluginSlotsForTest } from "./PluginSlotMount";
import { applyPluginCss, resetPluginCssForTest } from "@/lib/plugin-css";
import { ComposerActionsSlot } from "./PluginComposerActions";
import { PluginContext } from "./plugin-context";
import {
Expand Down Expand Up @@ -96,6 +97,7 @@ afterEach(() => {
resetPluginFrontendBootStateForTest();
window.localStorage.clear();
resetAllCrashedPluginSlotsForTest();
resetPluginCssForTest();
vi.restoreAllMocks();
});

Expand Down Expand Up @@ -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 (
<button type="button" onClick={() => navigate("/")}>
Leave panel
</button>
);
}
render(
<MemoryRouter initialEntries={["/plugins/demo/board"]}>
<Routes>
<Route
path={PLUGIN_PANEL_ROUTE_PATH}
element={
<>
<LeavePanel />
<PluginPanelView />
</>
}
/>
<Route path="/" element={<div>home</div>} />
</Routes>
</MemoryRouter>,
);
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",
Expand Down Expand Up @@ -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 <button type="button">Toggle sidebar</button>;
}
const panel = panelSlot({ headerContent: Accessory });
render(
applyPluginCss("demo", "/demo.css?h=header");
const view = render(
<>
<PluginPanelHeaderCenter chrome={panel} />
<PluginPanelHeaderActions panel={panel} subPath="notes/today.md" />
Expand All @@ -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", () => {
Expand Down
Loading
Loading