From 7123a8127a3e6ba371b06690cf069b8e65bc35b5 Mon Sep 17 00:00:00 2001 From: Michael Yong Date: Thu, 20 Aug 2026 15:50:33 -0700 Subject: [PATCH 1/4] Fix plugin link anchor semantics --- .../plugin/ExperimentalFileLink.test.tsx | 28 ++++++++++- .../plugin/ExperimentalFileLink.tsx | 4 +- .../plugin/ExperimentalUrlLink.test.tsx | 45 ++++++++++++++++++ .../components/plugin/ExperimentalUrlLink.tsx | 19 ++++++-- docs/api_to_audit.md | 3 +- packages/plugin-sdk/README.md | 5 +- packages/plugin-sdk/src/app-contract.ts | 10 ++-- .../testing/__tests__/app-harness.test.tsx | 46 +++++++++++++++++++ packages/plugin-sdk/src/testing/app.tsx | 5 +- 9 files changed, 152 insertions(+), 13 deletions(-) diff --git a/apps/app/src/components/plugin/ExperimentalFileLink.test.tsx b/apps/app/src/components/plugin/ExperimentalFileLink.test.tsx index 0bb7c317907..406343efff4 100644 --- a/apps/app/src/components/plugin/ExperimentalFileLink.test.tsx +++ b/apps/app/src/components/plugin/ExperimentalFileLink.test.tsx @@ -56,7 +56,28 @@ describe("ExperimentalFileLink", () => { expect(openFilePreview).not.toHaveBeenCalled(); }); - it("does not dispatch a malformed target supplied across a JavaScript boundary", () => { + it("uses a scheme-safe href for a valid scheme-like file name", () => { + const openFilePreview = vi.fn(() => true); + render( + + + + vscode:foo + + + , + ); + const link = screen.getByRole("link", { name: "vscode:foo" }); + expect(link.getAttribute("href")).toBe("./vscode%3Afoo"); + + fireEvent.click(link); + expect(openFilePreview).toHaveBeenCalledWith({ + target: { ...target, path: "vscode:foo" }, + location: null, + }); + }); + + it("renders a malformed target supplied across a JavaScript boundary as inert", () => { const openFilePreview = vi.fn(() => true); render( @@ -67,7 +88,10 @@ describe("ExperimentalFileLink", () => { , ); - fireEvent.click(screen.getByRole("link", { name: "invalid" })); + const invalid = screen.getByText("invalid"); + expect(screen.queryByRole("link", { name: "invalid" })).toBeNull(); + expect(invalid.getAttribute("href")).toBeNull(); + fireEvent.click(invalid); expect(openFilePreview).not.toHaveBeenCalled(); }); }); diff --git a/apps/app/src/components/plugin/ExperimentalFileLink.tsx b/apps/app/src/components/plugin/ExperimentalFileLink.tsx index 24be2d40d03..e01f56aab15 100644 --- a/apps/app/src/components/plugin/ExperimentalFileLink.tsx +++ b/apps/app/src/components/plugin/ExperimentalFileLink.tsx @@ -61,8 +61,10 @@ export function ExperimentalFileLink({ }, [intent, navigation, onClick], ); + const href = + intent === null ? undefined : `./${encodeURIComponent(intent.target.path)}`; const anchor = ( - + ); if (intent === null) return anchor; diff --git a/apps/app/src/components/plugin/ExperimentalUrlLink.test.tsx b/apps/app/src/components/plugin/ExperimentalUrlLink.test.tsx index d97966ca335..94ec61c927a 100644 --- a/apps/app/src/components/plugin/ExperimentalUrlLink.test.tsx +++ b/apps/app/src/components/plugin/ExperimentalUrlLink.test.tsx @@ -44,6 +44,26 @@ describe("ExperimentalUrlLink", () => { expect(openUrl).not.toHaveBeenCalled(); }); + it.each(["_blank", "preview-pane"])( + "leaves the explicit %s target native when the URL host would accept it", + (target) => { + const openUrl = vi.fn(() => true); + render( + + + + Example + + + , + ); + const link = screen.getByRole("link", { name: "Example" }); + expect(link.getAttribute("target")).toBe(target); + expect(fireEvent.click(link)).toBe(true); + expect(openUrl).not.toHaveBeenCalled(); + }, + ); + it("routes internal links through browser history before URL preferences", () => { const openUrl = vi.fn(() => true); render( @@ -62,4 +82,29 @@ describe("ExperimentalUrlLink", () => { expect(screen.getByText("Settings route")).toBeTruthy(); expect(openUrl).not.toHaveBeenCalled(); }); + + it("keeps an explicit target on an internal route native", () => { + const openUrl = vi.fn(() => true); + render( + + + + + Settings in new context + + + Settings route} /> + + + + , + ); + const link = screen.getByRole("link", { + name: "Settings in new context", + }); + expect(link.getAttribute("target")).toBe("_blank"); + expect(fireEvent.click(link)).toBe(true); + expect(screen.queryByText("Settings route")).toBeNull(); + expect(openUrl).not.toHaveBeenCalled(); + }); }); diff --git a/apps/app/src/components/plugin/ExperimentalUrlLink.tsx b/apps/app/src/components/plugin/ExperimentalUrlLink.tsx index 3ac1984d24d..435cb2885e1 100644 --- a/apps/app/src/components/plugin/ExperimentalUrlLink.tsx +++ b/apps/app/src/components/plugin/ExperimentalUrlLink.tsx @@ -14,7 +14,8 @@ function shouldHandleUrlClick( event.ctrlKey || event.metaKey || event.shiftKey || - event.currentTarget.hasAttribute("download") + event.currentTarget.hasAttribute("download") || + event.currentTarget.hasAttribute("target") ) { return false; } @@ -51,12 +52,24 @@ export function ExperimentalUrlLink({ }, [href, navigation, onClick], ); + const resolvedRel = + target === "_blank" ? (rel ?? "noopener noreferrer") : rel; + if (target !== undefined) { + return ( + + ); + } return ( ); diff --git a/docs/api_to_audit.md b/docs/api_to_audit.md index cc1480c578b..6927306359e 100644 --- a/docs/api_to_audit.md +++ b/docs/api_to_audit.md @@ -83,7 +83,8 @@ external method resolves the current client's preferred file target, absolute path, local/remote-SSH context, and line/column support. The boolean methods report host acceptance; later OS failures remain host-owned. The host id added to file-opener sources preserves explicit host identity when a plugin page -opens a host file without ambient thread context. +opens a host file without ambient thread context. Valid link targets expose a +scheme-safe href, while malformed runtime targets remain inert. **Audit before stabilizing.** diff --git a/packages/plugin-sdk/README.md b/packages/plugin-sdk/README.md index c133f7adf04..61e2fbf2503 100644 --- a/packages/plugin-sdk/README.md +++ b/packages/plugin-sdk/README.md @@ -31,7 +31,10 @@ an `openUrl` behavior option. Use `experimental_FileLink` for an explicit live workspace, host, or thread-storage file. Ordinary activation opens the shared BB preview and its context menu exposes built-in/plugin viewers, preferred external opening, and -copy actions. Buttons and menus can call +copy actions. Valid targets expose an encoded, scheme-safe anchor href so +modifier clicks, downloads, and copied links cannot reinterpret a file name as +an external URL scheme; malformed runtime targets have no active href. Buttons +and menus can call `experimental_openFilePreview({ target, location })` or `experimental_openFileExternally({ target, location })`; both return whether the current host accepted the intent. Targets never infer an ambient workspace. diff --git a/packages/plugin-sdk/src/app-contract.ts b/packages/plugin-sdk/src/app-contract.ts index c148065e5bc..a4bb01c5609 100644 --- a/packages/plugin-sdk/src/app-contract.ts +++ b/packages/plugin-sdk/src/app-contract.ts @@ -1597,8 +1597,9 @@ export interface MarkdownProps { /** * Props for BB's semantic URL link. The host owns ordinary activation while - * retaining native anchor behavior for app routes, modifiers, copying, and - * unsupported schemes. Experimental: see docs/api_to_audit.md. + * retaining native anchor behavior for app routes, modifiers, explicit + * targets, copying, and unsupported schemes. Experimental: see + * docs/api_to_audit.md. */ export interface ExperimentalUrlLinkProps extends Omit< ComponentPropsWithoutRef<"a">, @@ -1624,7 +1625,10 @@ export interface ExperimentalFileOpenOptions { location: ExperimentalFileLocation | null; } -/** Props for BB's host-rendered semantic file link. */ +/** + * Props for BB's host-rendered semantic file link. Valid targets receive a + * scheme-safe anchor href; malformed runtime targets remain inert. + */ export interface ExperimentalFileLinkProps extends Omit< ComponentPropsWithoutRef<"a">, "href" | "target" diff --git a/packages/plugin-sdk/src/testing/__tests__/app-harness.test.tsx b/packages/plugin-sdk/src/testing/__tests__/app-harness.test.tsx index a1c1d8b3d54..6b0b1a280df 100644 --- a/packages/plugin-sdk/src/testing/__tests__/app-harness.test.tsx +++ b/packages/plugin-sdk/src/testing/__tests__/app-harness.test.tsx @@ -153,6 +153,9 @@ function UrlNavigationProbe() { return (
Open link + + Open in explicit target +