Skip to content

feat(react-headless-components-preview): add Portal component#36238

Draft
dmytrokirpa wants to merge 2 commits into
microsoft:masterfrom
dmytrokirpa:feat/headless-portal-component
Draft

feat(react-headless-components-preview): add Portal component#36238
dmytrokirpa wants to merge 2 commits into
microsoft:masterfrom
dmytrokirpa:feat/headless-portal-component

Conversation

@dmytrokirpa
Copy link
Copy Markdown
Contributor

Summary

  • Adds a headless Portal component to @fluentui/react-headless-components-preview with a stable <span hidden> anchor at the original tree location, SSR-safe createPortal gating, and setVirtualParent linking so DOM-walking utilities work across the portal boundary.
  • Refactors non-modal DialogSurface to use the new Portal instead of inline ReactDOM.createPortal, fixing a hydration mismatch when the dialog renders open on first paint.
  • Mirrors v9 react-portal's public API shape (simple React.FC, mountNode prop with targetDocument.body fallback) without depending on @fluentui/react-portal — the headless package must stay Griffel-free (no runtime CSS injection, CSP-friendly).

Design notes

  • Hydration safety: the renderer always emits <span hidden ref={anchorRef}> at the original tree location. On the server mountNode is undefined, so only the anchor renders; on hydration the anchor still renders identically (portal content lives elsewhere in the DOM, which React's hydration diff ignores). This is the same pattern v9 react-portal uses.
  • Virtual parent linking: usePortal calls setVirtualParent(mountNode, anchor) in useEffect so utilities like elementContains, click-outside detection, and focus traversal treat the portalled subtree as logically nested under its React parent. Skipped when the mount node already contains the anchor (cycle guard).
  • No react-portal dep: the headless package explicitly avoids Griffel and any Griffel-dependent packages, which rules out reusing v9 Portal. The reimplementation drops theme className / dir propagation / focus-visible registration (not needed for headless) and keeps only the SSR anchor + virtual-parent link.

API

// @fluentui/react-headless-components-preview/portal
export const Portal: React.FC<PortalProps>;
export type PortalProps = { children?: React.ReactNode; mountNode?: HTMLElement | null };
export const usePortal: (props: PortalProps) => PortalState;
export const renderPortal: (state: PortalState) => JSXElement;

Test plan

  • Unit tests pass (Portal.test.tsx — 11/11 including conformance: renders into default body, renders into custom mountNode, hidden anchor at original location, sets virtual parent, skips linking when mount node contains the anchor)
  • Dialog tests still pass after renderDialogSurface refactor (11/11)
  • type-check passes
  • lint passes
  • generate-api regenerated — new etc/portal.api.md and updated etc/dialog.api.md (picked up new mountNode?: HTMLElement field on DialogSurfaceState)
  • Manual SSR smoke test in a Next.js consumer (recommended before merge)

🤖 Generated with Claude Code

… it in non-modal DialogSurface

Adds a headless Portal that renders children into a mount node (defaults
to targetDocument.body) while keeping a stable <span hidden> anchor at
the original tree location for SSR-safe hydration. Links the mount node
to the anchor via setVirtualParent so DOM-walking utilities work across
the portal boundary.

Non-modal DialogSurface now uses Portal instead of inline createPortal,
which fixes a hydration mismatch when the dialog is open during initial
render. No dependency on @fluentui/react-portal — the headless package
must stay Griffel-free per package constraints.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@github-actions
Copy link
Copy Markdown

github-actions Bot commented May 21, 2026

📊 Bundle size report

Package & Exports Baseline (minified/GZIP) PR Change
react-headless-components-preview
react-headless-components-preview: entire library
160.587 kB
45.747 kB
156.981 kB
44.642 kB
-3.606 kB
-1.105 kB

🤖 This report was generated against 4c758e46232f72a041bf8aaed8e1e64ef0db1c3b

@github-actions
Copy link
Copy Markdown

Pull request demo site: URL

@dmytrokirpa dmytrokirpa self-assigned this May 21, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant