fix: preserve plugin detail-tab deep links and bridge the real JSX runtime (#11826)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - Plugins can contribute detail tabs to project pages, and plugin UI
bundles render through a host-provided React bridge
> - A cold load of a plugin-tab deep link redirected to the Issues tab,
because the plugin-slots query is disabled until the project's company
resolves and a disabled query reports `isLoading: false`
> - The redirect made shareable plugin-tab URLs and browser
reload/back/forward unreliable for every detail-tab plugin
> - Separately, the bridge shim rebuilt `jsx`/`jsxs` on top of
`createElement(type, { children })`, which drops React's static-children
marking and floods the dev console with "unique key" warnings from
plugin components
> - This pull request holds the tab decision on a skeleton until
contributions actually load, and exposes the host's real
`react/jsx-runtime` on the plugin bridge with the old shim as fallback
> - The benefit is that plugin detail tabs survive direct open, reload,
back, and forward, and plugin surfaces stop emitting spurious React key
warnings

## Linked Issues or Issue Description

**What happened**

Opening a project URL with `?tab=plugin:<publisher>.<plugin>:<tab>`
directly, or reloading while on such a tab, redirected to the project's
Issues tab even though the plugin contribution was registered and
available. The dev console also showed "each child in a list should have
a unique key" warnings pointing at plugin-rendered components.

**Expected behavior**

Direct open, reload, back, and forward keep the requested plugin tab
when its contribution is available. When the contribution truly does not
exist, the page still falls back safely to the Issues tab. Plugin
components with static multi-child JSX render without React key
warnings.

**Steps to reproduce**

1. Install a plugin that contributes a project detail tab.
2. Open the tab, copy the URL, and open it in a new browser tab (or
press reload).
3. Observe the redirect to `/projects/<ref>/issues` before the
plugin-slots query has ever run.
4. With React in development mode, open any plugin tab that renders
sibling elements and observe key warnings in the console.

**Version or commit**

Reproduced on `master` at `733ffbf7c`.

**Deployment mode**

Local development instance (managed runtime).

## What Changed

- `ui/src/pages/ProjectDetail.tsx`: the plugin-tab fallback now waits
until the company is resolved and the plugin-slots query has finished
before it decides. While the decision is pending it renders the detail
`PageSkeleton` instead of navigating away. Loading a project that fails
to load still shows the error state.
- `ui/src/plugins/bridge-init.ts`: the plugin bridge registry now
exposes the host's real `react/jsx-runtime` module as `reactJsxRuntime`.
- `ui/src/plugins/slots.tsx`: the `react/jsx-runtime` shim served to
plugin bundles prefers the bridged runtime's `jsx`/`jsxs`/`Fragment` and
keeps the previous `createElement`-based implementation as a fallback.
- `ui/src/pages/ProjectDetail.test.tsx`: new tests for cold deep links —
pending slots query, disabled query before company resolution,
registered tab render, and fallback when the tab is not contributed.
- `ui/src/plugins/bridge.test.ts`: new tests that the bridge exposes the
real runtime and that unkeyed static children render through it without
key warnings.

## Verification

- `npx vitest run src/pages/ProjectDetail.test.tsx
src/plugins/bridge.test.ts` in `ui/` — 2 files, 18 tests, all pass.
- `pnpm check:token-gates` — all gates clean.
- Manual: open a project with a plugin detail tab, switch to the tab,
reload the browser, use back/forward, and open the URL in a fresh tab.
The tab persists. Remove the plugin and open the same URL. The page
falls back to the Issues tab.

## Risks

- Low risk. The fallback redirect still happens whenever contributions
finish loading without the requested tab; the change only defers it
until the answer is known, so an unavailable contribution cannot get
stuck on the skeleton.
- The jsx-runtime shim keeps the old `createElement` path as a fallback
when the bridged runtime is absent, so older bridge registries keep
working.

## Model Used

- Claude (Anthropic) — `claude-fable-5`, extended thinking enabled,
agentic tool use (file edits, shell, test execution) via Claude Code.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or similar PRs and linked
related PRs above where they exist
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [ ] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
— *the branch predates this rule; squash-merge keeps the branch name off
`master`*
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
(code-level docs; no user-facing docs affected)
- [x] I have considered and documented any risks above
- [ ] All Paperclip CI gates are green — *pending first CI run on this
PR*
- [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups —
*pending first review*
- [x] I will address all Greptile and reviewer comments before
requesting merge

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Michael Nguyen 2026-08-20 20:22:49 -10:00 committed by GitHub
parent 16149a75fd
commit 773a9dade6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 176 additions and 11 deletions

View File

@ -33,6 +33,16 @@ const mockNavigate = vi.hoisted(() => vi.fn());
const mockSetBreadcrumbs = vi.hoisted(() => vi.fn());
const mockIssuesList = vi.hoisted(() => vi.fn());
const mockSummarySlotCard = vi.hoisted(() => vi.fn());
const mockLocation = vi.hoisted(() => ({
pathname: "/projects/project-1/plugin-operations",
search: "",
}));
const mockCompanyContext = vi.hoisted(() => ({
companies: [{ id: "company-1", issuePrefix: "PAP" }] as Array<{ id: string; issuePrefix: string }>,
selectedCompanyId: "company-1" as string | null,
}));
const mockUsePluginSlots = vi.hoisted(() => vi.fn(() => ({ slots: [] as unknown[], isLoading: false })));
const mockPluginSlotMount = vi.hoisted(() => vi.fn());
vi.mock("../api/projects", () => ({ projectsApi: mockProjectsApi }));
vi.mock("../api/issues", () => ({ issuesApi: mockIssuesApi }));
@ -47,15 +57,15 @@ vi.mock("../api/resourceMemberships", () => ({ resourceMembershipsApi: mockResou
vi.mock("@/lib/router", () => ({
Link: ({ children, to }: { children?: ReactNode; to: string }) => <a href={to}>{children}</a>,
Navigate: ({ to }: { to: string }) => <div data-testid="navigate">{to}</div>,
useLocation: () => ({ pathname: "/projects/project-1/plugin-operations", search: "", hash: "", state: null }),
useLocation: () => ({ pathname: mockLocation.pathname, search: mockLocation.search, hash: "", state: null }),
useNavigate: () => mockNavigate,
useParams: () => ({ projectId: "project-1" }),
}));
vi.mock("../context/CompanyContext", () => ({
useCompany: () => ({
companies: [{ id: "company-1", issuePrefix: "PAP" }],
selectedCompanyId: "company-1",
companies: mockCompanyContext.companies,
selectedCompanyId: mockCompanyContext.selectedCompanyId,
setSelectedCompanyId: vi.fn(),
}),
}));
@ -63,9 +73,12 @@ vi.mock("../context/PanelContext", () => ({ usePanel: () => ({ closePanel: vi.fn
vi.mock("../context/ToastContext", () => ({ useToastActions: () => ({ pushToast: vi.fn() }) }));
vi.mock("../context/BreadcrumbContext", () => ({ useBreadcrumbs: () => ({ setBreadcrumbs: mockSetBreadcrumbs }) }));
vi.mock("@/plugins/slots", () => ({
PluginSlotMount: () => null,
PluginSlotMount: (props: unknown) => {
mockPluginSlotMount(props);
return <div data-testid="plugin-slot-mount" />;
},
PluginSlotOutlet: () => null,
usePluginSlots: () => ({ slots: [], isLoading: false }),
usePluginSlots: mockUsePluginSlots,
}));
vi.mock("@/plugins/launchers", () => ({ PluginLauncherOutlet: () => null }));
vi.mock("../components/ProjectProperties", () => ({
@ -166,6 +179,11 @@ describe("ProjectDetail", () => {
beforeEach(() => {
container = document.createElement("div");
document.body.appendChild(container);
mockLocation.pathname = "/projects/project-1/plugin-operations";
mockLocation.search = "";
mockCompanyContext.companies = [{ id: "company-1", issuePrefix: "PAP" }];
mockCompanyContext.selectedCompanyId = "company-1";
mockUsePluginSlots.mockReturnValue({ slots: [], isLoading: false });
mockProjectsApi.get.mockResolvedValue(project());
mockProjectsApi.list.mockResolvedValue([project()]);
mockIssuesApi.list.mockResolvedValue([]);
@ -227,4 +245,85 @@ describe("ProjectDetail", () => {
originKindPrefix: "plugin:paperclip.missions",
});
});
describe("plugin detail-tab deep links", () => {
const PLUGIN_TAB = "plugin:paperclipai.plugin-llm-wiki:project-knowledge";
const knowledgeSlot = {
id: "project-knowledge",
type: "detailTab",
displayName: "Knowledge",
entityTypes: ["project"],
pluginId: "plugin-llm-wiki",
pluginKey: "paperclipai.plugin-llm-wiki",
pluginDisplayName: "LLM Wiki",
pluginVersion: "0.2.0",
};
async function renderDetail() {
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
await act(async () => {
root = createRoot(container);
root.render(
<QueryClientProvider client={queryClient}>
<ProjectDetail />
</QueryClientProvider>,
);
});
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 0));
await new Promise((resolve) => setTimeout(resolve, 0));
});
}
it("keeps a cold deep link on the plugin tab while contributions are still loading", async () => {
mockLocation.pathname = "/projects/project-1";
mockLocation.search = `?tab=${encodeURIComponent(PLUGIN_TAB)}`;
mockUsePluginSlots.mockReturnValue({ slots: [], isLoading: true });
await renderDetail();
expect(container.querySelector('[data-testid="navigate"]')).toBeNull();
expect(container.querySelector('[data-testid="plugin-slot-mount"]')).toBeNull();
});
it("does not bounce to the issues tab before the company for the slots query resolves", async () => {
// Cold reload race: the slots query is disabled until a company is
// known, and a disabled query reports isLoading=false.
mockLocation.pathname = "/projects/project-1";
mockLocation.search = `?tab=${encodeURIComponent(PLUGIN_TAB)}`;
mockCompanyContext.companies = [];
mockCompanyContext.selectedCompanyId = null;
mockUsePluginSlots.mockReturnValue({ slots: [], isLoading: false });
await renderDetail();
expect(container.querySelector('[data-testid="navigate"]')).toBeNull();
});
it("renders the registered plugin tab for a direct deep link", async () => {
mockLocation.pathname = "/projects/project-1";
mockLocation.search = `?tab=${encodeURIComponent(PLUGIN_TAB)}`;
mockUsePluginSlots.mockReturnValue({ slots: [knowledgeSlot], isLoading: false });
await renderDetail();
expect(container.querySelector('[data-testid="navigate"]')).toBeNull();
expect(container.querySelector('[data-testid="plugin-slot-mount"]')).not.toBeNull();
expect(mockPluginSlotMount).toHaveBeenCalledWith(expect.objectContaining({
slot: expect.objectContaining({ id: "project-knowledge", pluginKey: "paperclipai.plugin-llm-wiki" }),
}));
expect(container.textContent).toContain("Knowledge");
});
it("falls back to the issues tab once contributions load without the requested tab", async () => {
mockLocation.pathname = "/projects/project-1";
mockLocation.search = `?tab=${encodeURIComponent(PLUGIN_TAB)}`;
mockUsePluginSlots.mockReturnValue({ slots: [], isLoading: false });
await renderDetail();
expect(container.querySelector('[data-testid="navigate"]')?.textContent)
.toBe("/projects/project-1/issues");
});
});
});

View File

@ -430,6 +430,10 @@ export function ProjectDetail() {
[pluginDetailSlots],
);
const activePluginTab = pluginTabItems.find((item) => item.value === activeTab) ?? null;
// The slots query is disabled until the project's company resolves, and a
// disabled query reports isLoading=false — so "not loading" alone cannot
// distinguish "contribution unavailable" from "not asked yet" on cold loads.
const pluginTabDecisionLoaded = Boolean(resolvedCompanyId) && !pluginDetailSlotsLoading;
const isolatedWorkspacesEnabled = experimentalSettingsQuery.data?.enableIsolatedWorkspaces === true;
const workspaceTabProjectId = project?.id ?? null;
const { data: workspaceTabIssues = [], isLoading: isWorkspaceTabIssuesLoading, error: workspaceTabIssuesError } = useQuery({
@ -673,7 +677,10 @@ export function ProjectDetail() {
},
});
if (pluginTabFromSearch && !pluginDetailSlotsLoading && !activePluginTab) {
if (pluginTabFromSearch && !activePluginTab && !error) {
if (!pluginTabDecisionLoaded) {
return <PageSkeleton variant="detail" />;
}
return <Navigate to={`/projects/${canonicalProjectRef}/issues`} replace />;
}

View File

@ -22,6 +22,7 @@ import {
usePluginToast,
} from "./bridge.js";
import { Component, createElement, useEffect, useMemo, useState, type ComponentType, type ReactNode } from "react";
import * as ReactJsxRuntimeModule from "react/jsx-runtime";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { User } from "lucide-react";
import {
@ -72,6 +73,14 @@ import { copyTextToClipboard } from "@/lib/clipboard";
*/
export interface PluginBridgeRegistry {
react: unknown;
/**
* The host's real `react/jsx-runtime` module. Plugin bundles compiled with
* the automatic JSX transform must use these `jsx`/`jsxs` implementations:
* reconstructing them via `createElement(type, { children })` loses React's
* static-children marking, so dev React demands a key on every multi-child
* element inside plugin components.
*/
reactJsxRuntime: unknown;
reactDom: unknown;
sdkUi: Record<string, unknown>;
}
@ -673,6 +682,7 @@ export function initPluginBridge(
): void {
globalThis.__paperclipPluginBridge__ = {
react,
reactJsxRuntime: ReactJsxRuntimeModule,
reactDom,
sdkUi: {
usePluginData,

View File

@ -1,12 +1,13 @@
// @vitest-environment jsdom
import * as React from "react";
import * as ReactDOM from "react-dom";
import * as ReactJsxRuntime from "react/jsx-runtime";
import { flushSync } from "react-dom";
import { createRoot } from "react-dom/client";
import type { MouseEvent as ReactMouseEvent } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { MemoryRouter } from "react-router-dom";
import { afterEach, describe, expect, it } from "vitest";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
FileTree as SdkFileTree,
ManagedRoutinesList as SdkManagedRoutinesList,
@ -254,6 +255,7 @@ describe("plugin SDK FileTree bridge", () => {
it("throws a clear error when the host FileTree implementation is missing", () => {
globalThis.__paperclipPluginBridge__ = {
react: React,
reactJsxRuntime: ReactJsxRuntime,
reactDom: ReactDOM,
sdkUi: {},
};
@ -287,6 +289,7 @@ describe("plugin SDK markdown component bridge", () => {
it("renders plugin-provided markdown components when registered by the host", () => {
globalThis.__paperclipPluginBridge__ = {
react: React,
reactJsxRuntime: ReactJsxRuntime,
reactDom: ReactDOM,
sdkUi: {
MarkdownBlock: ({ content, enableWikiLinks, wikiLinkRoot }: { content: string; enableWikiLinks?: boolean; wikiLinkRoot?: string }) =>
@ -333,3 +336,42 @@ describe("plugin React shim", () => {
expect(source).toContain("export const startTransition = R.startTransition;");
});
});
describe("plugin jsx runtime bridge", () => {
it("exposes the host jsx runtime on the bridge registry", () => {
initPluginBridge(React, ReactDOM);
const runtime = globalThis.__paperclipPluginBridge__?.reactJsxRuntime as typeof ReactJsxRuntime;
expect(runtime.jsx).toBeTypeOf("function");
expect(runtime.jsxs).toBeTypeOf("function");
expect(runtime.Fragment).toBe(ReactJsxRuntime.Fragment);
});
it("renders unkeyed static children through the bridged runtime without key warnings", () => {
initPluginBridge(React, ReactDOM);
const runtime = globalThis.__paperclipPluginBridge__?.reactJsxRuntime as typeof ReactJsxRuntime;
// Mirror what a compiled plugin bundle emits for static multi-child JSX:
// jsxs() with an unkeyed children array. The previous shim rebuilt this
// via createElement(type, { children }), which made dev React demand a
// key on every static child (LOOA-1547 Panel/ProjectKnowledgeTab noise).
const warnings: string[] = [];
const spy = vi.spyOn(console, "error").mockImplementation((...args: unknown[]) => {
warnings.push(args.map(String).join(" "));
});
try {
renderToStaticMarkup(
runtime.jsxs("section", {
children: [
runtime.jsx("header", { children: "static child one" }),
runtime.jsx("div", { children: "static child two" }),
],
}) as React.ReactElement,
);
} finally {
spy.mockRestore();
}
expect(warnings.filter((message) => message.includes("key"))).toEqual([]);
});
});

View File

@ -311,12 +311,19 @@ function getShimBlobUrl(specifier: "react" | "react-dom" | "react-dom/client" |
source = createReactShimSource(ReactModule);
break;
case "react/jsx-runtime":
// Prefer the host's real jsx runtime: rebuilding jsx/jsxs on top of
// createElement(type, { children }) drops React's static-children
// marking, so dev React emits "unique key" warnings for every
// multi-child element rendered by a plugin component.
source = `
const R = globalThis.__paperclipPluginBridge__?.react;
const BRIDGE = globalThis.__paperclipPluginBridge__;
const RUNTIME = BRIDGE?.reactJsxRuntime;
const R = BRIDGE?.react;
const withKey = ${applyJsxRuntimeKey.toString()};
export const jsx = (type, props, key) => R.createElement(type, withKey(props, key));
export const jsxs = (type, props, key) => R.createElement(type, withKey(props, key));
export const Fragment = R.Fragment;
const fallbackJsx = (type, props, key) => R.createElement(type, withKey(props, key));
export const jsx = RUNTIME?.jsx ?? fallbackJsx;
export const jsxs = RUNTIME?.jsxs ?? fallbackJsx;
export const Fragment = RUNTIME?.Fragment ?? R.Fragment;
`;
break;
case "react-dom":