Skip to content

Fix Portal server rendering by deferring the portal mount to the client - #8276

Open
mattcosta7 with Copilot wants to merge 7 commits into
mainfrom
copilot/fix-portal-ssr-compatibility
Open

Fix Portal server rendering by deferring the portal mount to the client#8276
mattcosta7 with Copilot wants to merge 7 commits into
mainfrom
copilot/fix-portal-ssr-compatibility

Conversation

Copilot AI commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

Closes #3721

Portal called document.createElement during render, so importing and rendering any component built on it (Dialog, ActionMenu, Overlay, SelectPanel, tooltips, …) crashed immediately in a server environment where document does not exist.

Simply guarding with typeof document === 'undefined' is not enough: in a server render that does have a DOM (a jsdom-based SSR smoke test, for example) the guard passes and renderToString then throws Portals are not currently supported by the server renderer. So the portal has to be skipped based on whether we are server rendering, not on whether a DOM happens to be present.

Portal now uses useSyncExternalStore to detect a client render, and only creates its host element and calls createPortal once that is true. The host element is created in a lazy useState initializer rather than in an effect, which is the key detail for consumers.

Behavior

Scenario Result
Server render, no DOM document is never touched; renders null
Server render, DOM present (jsdom) Renders null via the server snapshot; no throw
Hydration First pass renders null, matching the server; React's store-consistency check then synchronously re-renders and mounts the portal. No hydration mismatch, and generated IDs are preserved
Client-only render Unchanged. Single pass — children mount in the same commit as Portal, so refs are populated for parent layout effects

That last row is the important one. An earlier iteration of this PR mounted in two passes via useLayoutEffect + setState, which delayed portaled children by one commit. That broke every consumer that reads a ref in its first layout effect, and required ref→state workarounds across ActionMenu, Dialog, Overlay, SelectPanel, and useOpenAndCloseFocus. All of those workarounds have been reverted — no consumer changes are needed.

Changelog

Changed

  • Portal renders nothing while server rendering and during hydration, instead of accessing the DOM during render.
  • onMount is read through a latest-ref, so passing an inline arrow no longer risks re-firing the mount effect.
  • Enabled React Compiler for Portal.tsx and useMenuInitialFocus.ts by removing their react-hooks ESLint suppressions (the compiler bails on any file that disables those rules). Both are verified as actually compiled.

Rollout strategy

  • Patch release
  • Minor release
  • Major release; if selected, include a written rollout or migration plan
  • None; if selected, include a brief description as to why

Bug fix only. Client-only rendering — the path essentially all current consumers are on — is behaviorally identical.

Testing & Reviewing

Portal.test.tsx covers 16 cases; new ones worth reviewing:

  • Mounts children in the same commit — asserts a parent's layout effect sees a populated ref and that only one render occurs. This is the regression guard for the consumer breakage described above.
  • Hydration — uses hydrateRoot and asserts no recoverable errors and that generated IDs are preserved.
  • StrictMode — effects are double-invoked (mount → cleanup → mount); verifies the reused host element still attaches exactly once.
  • Unmount cleanup and containerName change — cover the effect cleanup and dependency-array changes.

src/__tests__/ssr.test.tsx is a new node-environment test (the browser project always has a document, so it cannot simulate this). It asserts typeof document === 'undefined' and that renderToString(<Portal>) returns ''. It is added to the node project's include allowlist and excluded from the browser project.

Full suite, type-check, lint, and format are green.

Known follow-up

Fixing Portal is necessary but not sufficient to server-render consumers end to end. I verified this by attempting renderToString(<Dialog>) in the node environment: Portal is no longer the blocker, but it still throws from useFocusTrap.ts, which reads document.activeElement during render — the same class of bug, in a second location.

I deliberately left that out of this PR. Moving that capture into a layout effect changes focus-restoration timing, which is a real behavioral risk in focus management and deserves its own PR and review. Once it lands, a consumer-level SSR test becomes possible and would be a much stronger regression guard.

@changeset-bot

changeset-bot Bot commented Aug 6, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 1e2f20d

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
@primer/react Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

Copilot AI and others added 2 commits August 6, 2026 16:08
Co-authored-by: mattcosta7 <8616962+mattcosta7@users.noreply.github.com>
Co-authored-by: mattcosta7 <8616962+mattcosta7@users.noreply.github.com>
Detect server rendering with useSyncExternalStore instead of deferring the
host element into an effect. The portal stays client-only, but client-only
renders mount portaled children in the same commit as the Portal itself, so
refs into portaled content are populated when the parent's layout effects
run. This removes the need for the ref-to-state workarounds in ActionMenu,
Dialog, Overlay, SelectPanel, useMenuInitialFocus and useOpenAndCloseFocus,
which are reverted.

Also read onMount from a ref so it can leave the effect deps without a lint
suppression, and drop the stale containerRef suppression in
useMenuInitialFocus. Both files now compile under the React Compiler.

Adds a node-environment SSR test that runs with no DOM available.
@github-actions github-actions Bot added the integration-tests: recommended This change needs to be tested for breaking changes. See https://arc.net/l/quote/tdmpakpm label Aug 7, 2026
@github-actions

github-actions Bot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

⚠️ Action required

👋 Hi, this pull request contains changes to the source code that github/github-ui depends on. If you are GitHub staff, test these changes with github/github-ui using the integration workflow. Check the integration testing docs for step-by-step instructions. Or, apply the integration-tests: skipped manually label to skip these checks.

@github-actions
github-actions Bot requested a deployment to storybook-preview-8276 August 7, 2026 13:55 Abandoned
@primer

primer Bot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

🤖 Lint issues have been automatically fixed and committed to this PR.

@github-actions
github-actions Bot temporarily deployed to storybook-preview-8276 August 7, 2026 14:05 Inactive
@mattcosta7 mattcosta7 added the Canary Release Apply this label when you want CI to create a canary release of the current PR label Aug 7, 2026
@mattcosta7 mattcosta7 changed the title [WIP] Fix Portal SSR compatibility by avoiding document usage during render Fix Portal server rendering by deferring the portal mount to the client Aug 7, 2026
@primer

primer Bot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

🤖 Lint issues have been automatically fixed and committed to this PR.

@mattcosta7
mattcosta7 requested a review from a team as a code owner August 7, 2026 15:34
@mattcosta7
mattcosta7 requested review from liuliu-dev and a lite review from Copilot August 7, 2026 15:34
@primer-integration

primer-integration Bot commented Aug 7, 2026

Copy link
Copy Markdown

Integration test results from github/github-ui PR:

Failed  CI   Failed
Running  VRT   Running
Waiting  Projects   Waiting

CI check runs linting, type checking, and unit tests. Check the workflow logs for specific failures.

Need help? If you believe this failure is unrelated to your changes, please reach out to the Primer team for assistance.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

This PR updates Portal to avoid crashing or throwing during server rendering by deferring portal creation/mounting until React is doing a client render (including special handling for hydration), and adds targeted tests/config updates to cover SSR behavior.

Changes:

  • Update Portal to use useSyncExternalStore to distinguish server render/hydration vs client render, returning null until client render is confirmed.
  • Expand Portal test coverage for SSR, hydration, StrictMode, cleanup, and onMount stability; add a dedicated node-environment SSR test.
  • Enable React Compiler for Portal.tsx and useMenuInitialFocus.ts by removing them from the unsupported list and addressing hook-deps suppression.
Show a summary per file
File Description
script/check-classname-tests.mjs Ignores the new SSR-focused test from className markup requirements.
packages/react/vitest.config.mts Adds ssr.test.tsx to the node test project include list.
packages/react/vitest.config.browser.mts Excludes ssr.test.tsx from the browser (Playwright) test project.
packages/react/src/Portal/Portal.tsx Defers portal rendering/mounting using useSyncExternalStore; refactors onMount handling.
packages/react/src/Portal/Portal.test.tsx Adds SSR/hydration/StrictMode/cleanup/container move/onMount identity regression tests.
packages/react/src/hooks/useMenuInitialFocus.ts Removes exhaustive-deps suppression by including containerRef in deps with rationale.
packages/react/src/tests/ssr.test.tsx New node-environment SSR regression test ensuring Portal renders null with no DOM.
packages/react/script/react-compiler.mjs Removes Portal.tsx and useMenuInitialFocus.ts from the unsupported patterns list.
.changeset/clean-portals-render.md Adds a patch changeset describing the SSR/hydration Portal behavior change.

Review details

  • Files reviewed: 9/9 changed files
  • Comments generated: 2
  • Review effort level: Lite

Comment thread packages/react/src/Portal/Portal.tsx
Comment thread packages/react/src/Portal/Portal.tsx
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Canary Release Apply this label when you want CI to create a canary release of the current PR integration-tests: recommended This change needs to be tested for breaking changes. See https://arc.net/l/quote/tdmpakpm

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Portal is not ssr compat - accesses document during render

3 participants