From 9bba54cd67138b7719e1f6dae5877356d8cd20e4 Mon Sep 17 00:00:00 2001 From: SlavCo Date: Mon, 24 Aug 2026 21:05:32 +0100 Subject: [PATCH 1/2] feat: add configurable portal target Allow built-in overlays to stay inside host CSS and stacking contexts by sharing one top-level portal target across Mermaid fullscreen, table fullscreen, and the link safety modal. Refs #499 --- .changeset/shared-overlay-portal.md | 5 ++ apps/website/content/docs/configuration.mdx | 23 ++++++++ .../__tests__/link-modal-keyboard.test.tsx | 23 ++++++++ .../__tests__/mermaid-fullscreen.test.tsx | 23 ++++++++ packages/streamdown/__tests__/portal.test.tsx | 53 +++++++++++++++++++ .../__tests__/table-fullscreen.test.tsx | 21 ++++++++ packages/streamdown/index.tsx | 12 +++++ packages/streamdown/lib/link-modal.tsx | 7 ++- .../lib/mermaid/fullscreen-button.tsx | 10 ++-- packages/streamdown/lib/portal.ts | 8 +++ .../lib/table/fullscreen-button.tsx | 5 +- 11 files changed, 183 insertions(+), 7 deletions(-) create mode 100644 .changeset/shared-overlay-portal.md create mode 100644 packages/streamdown/__tests__/portal.test.tsx create mode 100644 packages/streamdown/lib/portal.ts diff --git a/.changeset/shared-overlay-portal.md b/.changeset/shared-overlay-portal.md new file mode 100644 index 00000000..de14e3b3 --- /dev/null +++ b/.changeset/shared-overlay-portal.md @@ -0,0 +1,5 @@ +--- +"streamdown": patch +--- + +Add a top-level `portal` prop for configuring the container used by Mermaid fullscreen, table fullscreen, and the built-in link safety modal. diff --git a/apps/website/content/docs/configuration.mdx b/apps/website/content/docs/configuration.mdx index 2539a1be..8fe7c917 100644 --- a/apps/website/content/docs/configuration.mdx +++ b/apps/website/content/docs/configuration.mdx @@ -184,6 +184,12 @@ Math rendering and CJK support require installing separate plugins. See [Mathema type: "LinkSafetyConfig", default: "{ enabled: true }", }, + portal: { + description: + "DOM node (or getter) used as the portal target for built-in overlays. Useful for micro-frontends and scoped or prefixed CSS.", + type: "HTMLElement | null | (() => HTMLElement | null)", + default: "document.body", + }, plugins: { description: "Plugin configuration for math, mermaid, code highlighting, and CJK support. See [Plugins](/docs/plugins).", type: "PluginConfig", @@ -213,6 +219,23 @@ Math rendering and CJK support require installing separate plugins. See [Mathema }} /> +### Portal Target + +By default, Mermaid fullscreen, table fullscreen, and the built-in link safety modal render into `document.body`. Use `portal` to keep these overlays inside a micro-frontend or another subtree that provides scoped CSS, prefixed Tailwind utilities, or a specific stacking context. + +```tsx title="app/page.tsx" +const portalRef = useRef(null); + +return ( +
+
+ portalRef.current}>{markdown} +
+); +``` + +The getter form is useful when the portal element is assigned after the first render. Returning `null` falls back to `document.body`. A custom `linkSafety.renderModal` controls its own placement and is not moved by this prop. + ### Mermaid Options The `mermaid` prop accepts an object with the following properties: diff --git a/packages/streamdown/__tests__/link-modal-keyboard.test.tsx b/packages/streamdown/__tests__/link-modal-keyboard.test.tsx index 4de1c905..a77afe0f 100644 --- a/packages/streamdown/__tests__/link-modal-keyboard.test.tsx +++ b/packages/streamdown/__tests__/link-modal-keyboard.test.tsx @@ -1,5 +1,6 @@ import { fireEvent, render, waitFor } from "@testing-library/react"; import { afterEach, describe, expect, it, vi } from "vitest"; +import { Streamdown } from "../index"; import { LinkSafetyModal } from "../lib/link-modal"; describe("LinkSafetyModal keyboard and interaction", () => { @@ -228,4 +229,26 @@ describe("LinkSafetyModal keyboard and interaction", () => { fireEvent.keyDown(document, { key: "Escape" }); expect(onClose).toHaveBeenCalled(); }); + + it("should portal the default modal to the configured portal target", () => { + const portalRoot = document.createElement("div"); + document.body.appendChild(portalRoot); + + const { container, unmount } = render( + portalRoot}> + {"[External link](https://example.com)"} + + ); + + const link = container.querySelector('[data-streamdown="link"]'); + // biome-ignore lint/style/noNonNullAssertion: test assertion + fireEvent.click(link!); + + expect( + portalRoot.querySelector('[data-streamdown="link-safety-modal"]') + ).toBeTruthy(); + + unmount(); + portalRoot.remove(); + }); }); diff --git a/packages/streamdown/__tests__/mermaid-fullscreen.test.tsx b/packages/streamdown/__tests__/mermaid-fullscreen.test.tsx index 5b48eb9e..fd5900cf 100644 --- a/packages/streamdown/__tests__/mermaid-fullscreen.test.tsx +++ b/packages/streamdown/__tests__/mermaid-fullscreen.test.tsx @@ -348,4 +348,27 @@ describe("MermaidFullscreenButton", () => { document.querySelector('button[title="Download diagram"]') ).toBeFalsy(); }); + + it("should portal fullscreen overlay to the configured portal target", async () => { + const portalRoot = document.createElement("div"); + document.body.appendChild(portalRoot); + + const { container, unmount } = renderWithContext( + { chart: "graph TD; A-->B" }, + { portal: () => portalRoot } + ); + + const openBtn = container.querySelector('button[title="View fullscreen"]'); + // biome-ignore lint/style/noNonNullAssertion: test assertion + fireEvent.click(openBtn!); + + await waitFor(() => { + expect( + portalRoot.querySelector('[data-streamdown="mermaid-fullscreen"]') + ).toBeTruthy(); + }); + + unmount(); + portalRoot.remove(); + }); }); diff --git a/packages/streamdown/__tests__/portal.test.tsx b/packages/streamdown/__tests__/portal.test.tsx new file mode 100644 index 00000000..cabe5ae1 --- /dev/null +++ b/packages/streamdown/__tests__/portal.test.tsx @@ -0,0 +1,53 @@ +import { fireEvent, render } from "@testing-library/react"; +import { renderToString } from "react-dom/server"; +import { describe, expect, it, vi } from "vitest"; +import { Streamdown } from "../index"; +import { resolvePortalTarget } from "../lib/portal"; + +const markdownWithTable = ` +| Name | Age | +|------|-----| +| Alice | 30 | +`; + +describe("portal target", () => { + it("should fall back to document.body when the portal getter returns null", () => { + expect(resolvePortalTarget(() => null)).toBe(document.body); + }); + + it("should not resolve the portal target during server rendering", () => { + const getPortal = vi.fn(() => document.body); + + renderToString(Content); + + expect(getPortal).not.toHaveBeenCalled(); + }); + + it("should update the portal target when the prop changes", () => { + const firstRoot = document.createElement("div"); + const secondRoot = document.createElement("div"); + document.body.append(firstRoot, secondRoot); + + const { container, rerender, unmount } = render( + {markdownWithTable} + ); + + rerender({markdownWithTable}); + + const button = container.querySelector( + 'button[title="View fullscreen"]' + ) as HTMLButtonElement; + fireEvent.click(button); + + expect( + firstRoot.querySelector('[data-streamdown="table-fullscreen"]') + ).toBeNull(); + expect( + secondRoot.querySelector('[data-streamdown="table-fullscreen"]') + ).toBeTruthy(); + + unmount(); + firstRoot.remove(); + secondRoot.remove(); + }); +}); diff --git a/packages/streamdown/__tests__/table-fullscreen.test.tsx b/packages/streamdown/__tests__/table-fullscreen.test.tsx index a7c3b2c4..2fbe42b1 100644 --- a/packages/streamdown/__tests__/table-fullscreen.test.tsx +++ b/packages/streamdown/__tests__/table-fullscreen.test.tsx @@ -390,6 +390,27 @@ describe("TableFullscreenButton", () => { overlay?.querySelector('button[title="Download table as Markdown"]') ).toBeTruthy(); }); + + it("should portal fullscreen overlay to the configured portal target", () => { + const portalRoot = document.createElement("div"); + document.body.appendChild(portalRoot); + + const { container, unmount } = render( + {markdownWithTable} + ); + + const btn = container.querySelector( + 'button[title="View fullscreen"]' + ) as HTMLButtonElement; + fireEvent.click(btn); + + expect( + portalRoot.querySelector('[data-streamdown="table-fullscreen"]') + ).toBeTruthy(); + + unmount(); + portalRoot.remove(); + }); }); describe("TableFullscreenButton copy and download", () => { diff --git a/packages/streamdown/index.tsx b/packages/streamdown/index.tsx index 2c6bbe1b..5bf4ad25 100644 --- a/packages/streamdown/index.tsx +++ b/packages/streamdown/index.tsx @@ -192,6 +192,7 @@ export interface MermaidOptions { } export type AllowedTags = Record; +export type PortalTarget = HTMLElement | null | (() => HTMLElement | null); export type StreamdownProps = Options & { mode?: "static" | "streaming"; @@ -230,6 +231,11 @@ export type StreamdownProps = Options & { plugins?: PluginConfig; remend?: RemendOptions; linkSafety?: LinkSafetyConfig; + /** + * DOM node for Streamdown overlays, or a function returning one. + * Defaults to `document.body`. + */ + portal?: PortalTarget; /** Custom tags to allow through sanitization with their permitted attributes */ allowedTags?: AllowedTags; /** @@ -320,6 +326,7 @@ export interface StreamdownContextType { linkSafety?: LinkSafetyConfig; mermaid?: MermaidOptions; mode: "static" | "streaming"; + portal?: PortalTarget; shikiTheme: [ThemeInput, ThemeInput]; /** Max height for tables. @default 300 */ tableMaxHeight: number | string; @@ -343,6 +350,7 @@ const defaultStreamdownContext: StreamdownContextType = { mode: "streaming", mermaid: undefined, linkSafety: defaultLinkSafetyConfig, + portal: undefined, tableMaxHeight: 300, }; @@ -496,6 +504,7 @@ export const Streamdown = memo( plugins, remend: remendOptions, linkSafety = defaultLinkSafetyConfig, + portal, lineNumbers = true, allowedTags, literalTagContent, @@ -688,6 +697,7 @@ export const Streamdown = memo( mode, mermaid, linkSafety, + portal, tableMaxHeight, }), [ @@ -699,6 +709,7 @@ export const Streamdown = memo( mode, mermaid, linkSafety, + portal, plugins?.code, tableMaxHeight, ] @@ -978,6 +989,7 @@ export const Streamdown = memo( prevProps.plugins === nextProps.plugins && prevProps.className === nextProps.className && prevProps.linkSafety === nextProps.linkSafety && + prevProps.portal === nextProps.portal && prevProps.lineNumbers === nextProps.lineNumbers && prevProps.codeBlockMaxHeight === nextProps.codeBlockMaxHeight && prevProps.tableMaxHeight === nextProps.tableMaxHeight && diff --git a/packages/streamdown/lib/link-modal.tsx b/packages/streamdown/lib/link-modal.tsx index f98ed64a..f5ea26e4 100644 --- a/packages/streamdown/lib/link-modal.tsx +++ b/packages/streamdown/lib/link-modal.tsx @@ -1,6 +1,8 @@ -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useContext, useEffect, useState } from "react"; import { createPortal } from "react-dom"; +import { StreamdownContext } from "../index"; import { useIcons } from "./icon-context"; +import { resolvePortalTarget } from "./portal"; import { useCn } from "./prefix-context"; import { lockBodyScroll, unlockBodyScroll } from "./scroll-lock"; import { useTranslations } from "./translations-context"; @@ -22,6 +24,7 @@ export const LinkSafetyModal = ({ const cn = useCn(); const [copied, setCopied] = useState(false); const t = useTranslations(); + const { portal } = useContext(StreamdownContext); const handleCopy = useCallback(async () => { try { @@ -150,5 +153,5 @@ export const LinkSafetyModal = ({
); - return createPortal(modal, document.body); + return createPortal(modal, resolvePortalTarget(portal)); }; diff --git a/packages/streamdown/lib/mermaid/fullscreen-button.tsx b/packages/streamdown/lib/mermaid/fullscreen-button.tsx index 0f4efae8..a0be28ae 100644 --- a/packages/streamdown/lib/mermaid/fullscreen-button.tsx +++ b/packages/streamdown/lib/mermaid/fullscreen-button.tsx @@ -4,6 +4,7 @@ import { StreamdownContext } from "../../index"; import { CodeBlockCopyButton } from "../code-block/copy-button"; import { useIcons } from "../icon-context"; import type { MermaidConfig } from "../plugin-types"; +import { resolvePortalTarget } from "../portal"; import { useCn } from "../prefix-context"; import { lockBodyScroll, unlockBodyScroll } from "../scroll-lock"; import { useTranslations } from "../translations-context"; @@ -28,8 +29,11 @@ export const MermaidFullscreenButton = ({ const { Maximize2Icon, XIcon } = useIcons(); const cn = useCn(); const [isFullscreen, setIsFullscreen] = useState(false); - const { isAnimating, controls: controlsConfig } = - useContext(StreamdownContext); + const { + isAnimating, + controls: controlsConfig, + portal, + } = useContext(StreamdownContext); const t = useTranslations(); const showPanZoomControls = (() => { if (typeof controlsConfig === "boolean") { @@ -179,7 +183,7 @@ export const MermaidFullscreenButton = ({ /> , - document.body + resolvePortalTarget(portal) ) : null} diff --git a/packages/streamdown/lib/portal.ts b/packages/streamdown/lib/portal.ts new file mode 100644 index 00000000..908c4c16 --- /dev/null +++ b/packages/streamdown/lib/portal.ts @@ -0,0 +1,8 @@ +import type { PortalTarget } from "../index"; + +export const resolvePortalTarget = ( + portal: PortalTarget | undefined +): HTMLElement => { + const container = typeof portal === "function" ? portal() : portal; + return container ?? document.body; +}; diff --git a/packages/streamdown/lib/table/fullscreen-button.tsx b/packages/streamdown/lib/table/fullscreen-button.tsx index 4589e602..27c3d446 100644 --- a/packages/streamdown/lib/table/fullscreen-button.tsx +++ b/packages/streamdown/lib/table/fullscreen-button.tsx @@ -2,6 +2,7 @@ import { useContext, useEffect, useState } from "react"; import { createPortal } from "react-dom"; import { StreamdownContext } from "../../index"; import { useIcons } from "../icon-context"; +import { resolvePortalTarget } from "../portal"; import { useCn } from "../prefix-context"; import { lockBodyScroll, unlockBodyScroll } from "../scroll-lock"; import { useTranslations } from "../translations-context"; @@ -24,7 +25,7 @@ export const TableFullscreenButton = ({ const { Maximize2Icon, XIcon } = useIcons(); const cn = useCn(); const [isFullscreen, setIsFullscreen] = useState(false); - const { isAnimating } = useContext(StreamdownContext); + const { isAnimating, portal } = useContext(StreamdownContext); const t = useTranslations(); const handleOpen = () => { @@ -122,7 +123,7 @@ export const TableFullscreenButton = ({ , - document.body + resolvePortalTarget(portal) ) : null} From b231f043e071111733c8580934a8bf21950e1cb4 Mon Sep 17 00:00:00 2001 From: SlavCo Zute Date: Wed, 26 Aug 2026 13:10:14 +0100 Subject: [PATCH 2/2] refactor(portal): treat portal as an initializing prop Remove `portal` from the memo comparator. `PortalTarget` allows a `() => HTMLElement | null` getter, so an inline getter produced a new function identity on every parent render and defeated memoization for every consumer using that form. `portal` now behaves like the other function props (`urlTransform`, `allowElement`), which are likewise absent from the comparator. The getter form remains the recommended way to target an element that is assigned late, since it is resolved each time an overlay opens rather than at render time. --- apps/website/content/docs/configuration.mdx | 2 +- packages/streamdown/__tests__/portal.test.tsx | 6 +++--- packages/streamdown/index.tsx | 1 - 3 files changed, 4 insertions(+), 5 deletions(-) diff --git a/apps/website/content/docs/configuration.mdx b/apps/website/content/docs/configuration.mdx index 8fe7c917..4225a07d 100644 --- a/apps/website/content/docs/configuration.mdx +++ b/apps/website/content/docs/configuration.mdx @@ -234,7 +234,7 @@ return ( ); ``` -The getter form is useful when the portal element is assigned after the first render. Returning `null` falls back to `document.body`. A custom `linkSafety.renderModal` controls its own placement and is not moved by this prop. +The getter form is useful when the portal element is assigned after the first render. Returning `null` falls back to `document.body`. A custom `linkSafety.renderModal` controls its own placement and is not moved by this prop. Like `urlTransform` and `allowElement`, `portal` is an initializing prop: swapping it for a different target alone does not re-render, so prefer the getter form — it is resolved each time an overlay opens. ### Mermaid Options diff --git a/packages/streamdown/__tests__/portal.test.tsx b/packages/streamdown/__tests__/portal.test.tsx index cabe5ae1..b4066749 100644 --- a/packages/streamdown/__tests__/portal.test.tsx +++ b/packages/streamdown/__tests__/portal.test.tsx @@ -23,7 +23,7 @@ describe("portal target", () => { expect(getPortal).not.toHaveBeenCalled(); }); - it("should update the portal target when the prop changes", () => { + it("should treat portal as an initializing prop", () => { const firstRoot = document.createElement("div"); const secondRoot = document.createElement("div"); document.body.append(firstRoot, secondRoot); @@ -41,10 +41,10 @@ describe("portal target", () => { expect( firstRoot.querySelector('[data-streamdown="table-fullscreen"]') - ).toBeNull(); + ).toBeTruthy(); expect( secondRoot.querySelector('[data-streamdown="table-fullscreen"]') - ).toBeTruthy(); + ).toBeNull(); unmount(); firstRoot.remove(); diff --git a/packages/streamdown/index.tsx b/packages/streamdown/index.tsx index 5bf4ad25..bc01484a 100644 --- a/packages/streamdown/index.tsx +++ b/packages/streamdown/index.tsx @@ -989,7 +989,6 @@ export const Streamdown = memo( prevProps.plugins === nextProps.plugins && prevProps.className === nextProps.className && prevProps.linkSafety === nextProps.linkSafety && - prevProps.portal === nextProps.portal && prevProps.lineNumbers === nextProps.lineNumbers && prevProps.codeBlockMaxHeight === nextProps.codeBlockMaxHeight && prevProps.tableMaxHeight === nextProps.tableMaxHeight &&