Skip to content

fix(dialog): ensure scroll lock is cleared on unmount during navigation#3840

Open
sujiu222 wants to merge 4 commits intoradix-ui:mainfrom
sujiu222:fix/dialog-scroll-lock-cleanup-on-unmount
Open

fix(dialog): ensure scroll lock is cleared on unmount during navigation#3840
sujiu222 wants to merge 4 commits intoradix-ui:mainfrom
sujiu222:fix/dialog-scroll-lock-cleanup-on-unmount

Conversation

@sujiu222
Copy link
Copy Markdown

@sujiu222 sujiu222 commented Apr 3, 2026

Summary

Fixes #3797

Problem

When a Link inside a Dialog triggers route navigation, the Dialog unmounts but the scroll lock (overflow: hidden on body) is not properly cleaned up. This leaves the page in a non-scrollable state — data-scroll-locked="1" persists on document.body — even after navigating to a different page and back.

Root Cause

react-remove-scroll manages the data-scroll-locked attribute via useEffect (asynchronous). In certain SPA router scenarios (TanStack Router, Next.js App Router with transitions), the old route's async effect cleanups may be deferred or skipped before the new route is painted. This causes data-scroll-locked to remain on document.body, making the page non-scrollable.

Solution

Add a useLayoutEffect cleanup in DialogOverlayImpl that synchronously decrements (or removes) the data-scroll-locked attribute when the overlay unmounts while the dialog is still open (i.e. a forced unmount caused by router navigation).

Double-decrement protection

A isOpenRef guard prevents interfering with the normal close flow:

  • Normal close: context.open transitions to false before the overlay unmounts (via Presence), so isOpenRef.current === false → our cleanup skips, leaving react-remove-scroll's own cleanup to handle the decrement normally ✅
  • Forced unmount (navigation): context.open is still true at unmount time → our useLayoutEffect cleanup fires synchronously and decrements the counter ✅

Nested dialogs

When multiple dialogs are stacked (counter > 1), our cleanup correctly decrements to the next value rather than blindly removing the attribute. react-remove-scroll's subsequent async decrement reads 0 and calls removeAttribute, which is a safe no-op on an already-absent attribute.

Testing

Added a regression test (scroll lock cleanup on forced unmount) that:

  1. Renders a controlled Dialog with open={true}
  2. Waits for react-remove-scroll's useEffect to set the attribute
  3. Calls root.unmount() directly (simulating a router tearing down the page)
  4. Asserts that data-scroll-locked is no longer present on document.body

The test passes with this fix and fails without it.

Note: The existing test suite has pre-existing failures due to a React.act is not a function incompatibility between @testing-library/react@16.3.0 and React 19. Those failures are unrelated to this change. The new regression test avoids @testing-library/react's render path (which triggers the issue) and uses ReactDOM.createRoot directly.

sujiu222 added 2 commits April 3, 2026 17:44
The DialogTitle and DialogDescription accessibility warnings used
document.getElementById to find elements for validation. When Dialog
is rendered inside a Shadow DOM, elements are not accessible via
document.getElementById, causing false-positive accessibility warnings
in the console.

Fix: Use contentRef.current.getRootNode() to obtain the correct root
node (Document or ShadowRoot), then call getElementById on that root.
This works in both regular DOM and Shadow DOM contexts, since ShadowRoot
implements DocumentOrShadowRoot which defines getElementById.

Fixes radix-ui#3814
When a Link inside Dialog triggers navigation, the Dialog unmounts
but the scroll lock (overflow: hidden on body) was not being cleaned up,
leaving the page in a non-scrollable state.

Root cause: react-remove-scroll manages data-scroll-locked via useEffect
(asynchronous). In certain SPA router scenarios (TanStack Router, Next.js
App Router) the old route's async effect cleanups may be deferred or
entirely skipped, causing data-scroll-locked to persist on document.body.

Fix: Add a useLayoutEffect cleanup in DialogOverlayImpl that synchronously
decrements (or removes) the data-scroll-locked attribute when the overlay
unmounts while the dialog is still open (i.e. a forced unmount caused by
router navigation, not a normal user-initiated close).

The guard (isOpenRef.current) prevents double-decrement in the normal close
flow: when the user closes the dialog through the normal UI, context.open
transitions to false before the overlay unmounts via Presence, so we defer
cleanup to react-remove-scroll's own useEffect cleanup in that case. The
synchronous cleanup only fires when the dialog was still open at the time
of unmount, correctly handling the navigation/forced-unmount scenario.

Fixes radix-ui#3797
Copilot AI review requested due to automatic review settings April 3, 2026 10:01
@changeset-bot
Copy link
Copy Markdown

changeset-bot bot commented Apr 3, 2026

🦋 Changeset detected

Latest commit: 9c5e0f3

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

This PR includes changesets to release 3 packages
Name Type
@radix-ui/react-dialog Patch
@radix-ui/react-alert-dialog Patch
radix-ui 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

Copy link
Copy Markdown

Copilot AI left a comment

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 addresses a Dialog scroll-lock cleanup bug that can occur during SPA navigation when a Dialog unmounts while still open, leaving the page in a locked-scroll state (data-scroll-locked on document.body). It also improves the dev-only accessibility warnings to better support Shadow DOM contexts.

Changes:

  • Add a synchronous unmount cleanup in DialogOverlayImpl to decrement/remove the data-scroll-locked attribute on forced unmounts.
  • Update TitleWarning (and align lookup logic with DescriptionWarning) to resolve IDs via the component’s root node to support Shadow DOM.
  • Add a regression test that simulates a forced unmount via ReactDOM.createRoot().unmount() and asserts scroll lock cleanup.

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 3 comments.

File Description
packages/react/dialog/src/dialog.tsx Adds forced-unmount scroll-lock cleanup and updates a11y warning element lookup for Shadow DOM.
packages/react/dialog/src/dialog.test.tsx Adds regression coverage for scroll-lock cleanup when the Dialog tree is force-unmounted.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +242 to +248
React.useLayoutEffect(() => {
return () => {
// Only perform synchronous cleanup for forced unmounts (navigation while dialog is open).
// When the dialog closes normally, `context.open` is already `false` before this
// component unmounts, so we leave cleanup to `react-remove-scroll` to avoid
// double-decrementing the scroll lock counter.
if (!isOpenRef.current) return;
Copy link

Copilot AI Apr 3, 2026

Choose a reason for hiding this comment

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

DialogOverlayImpl uses React.useLayoutEffect, but this repo generally uses the SSR-safe useLayoutEffect from @radix-ui/react-use-layout-effect to avoid server-render warnings (e.g. packages/react/portal/src/portal.tsx:4). Consider switching to the shared hook for consistency and SSR compatibility.

Copilot uses AI. Check for mistakes.
Comment on lines +581 to 585
// Use getRootNode() to support Shadow DOM contexts where document.getElementById
// would fail to find elements rendered inside a shadow root.
const rootNode = contentRef.current?.getRootNode() ?? document;
const hasTitle = (rootNode as Document | ShadowRoot).getElementById(titleId);
if (!hasTitle) console.error(MESSAGE);
Copy link

Copilot AI Apr 3, 2026

Choose a reason for hiding this comment

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

getRootNode() can return a DocumentFragment when Dialog.Portal is rendered into a DocumentFragment container (supported by @radix-ui/react-portal). DocumentFragment doesn’t guarantee getElementById, so (rootNode as Document | ShadowRoot).getElementById(...) can throw at runtime. Please guard for rootNode having getElementById (or fall back to document / use querySelector) before calling it.

Copilot uses AI. Check for mistakes.
Comment on lines +607 to 611
// Use getRootNode() to support Shadow DOM contexts where document.getElementById
// would fail to find elements rendered inside a shadow root.
const rootNode = contentRef.current?.getRootNode() ?? document;
const hasDescription = (rootNode as Document | ShadowRoot).getElementById(descriptionId);
if (!hasDescription) console.warn(MESSAGE);
Copy link

Copilot AI Apr 3, 2026

Choose a reason for hiding this comment

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

Same issue as TitleWarning: contentRef.current?.getRootNode() may be a DocumentFragment (portal container type allows this), which may not implement getElementById. Calling getElementById on it can throw; add a runtime check/fallback before using it.

Copilot uses AI. Check for mistakes.
- Use @radix-ui/react-use-layout-effect instead of React.useLayoutEffect
  for SSR safety and consistency with other Radix packages
- Add instanceof Document || instanceof ShadowRoot guard before calling
  getElementById; fall back to ownerDocument ?? document for
  DocumentFragment containers (e.g. portal into DocumentFragment)

Both issues apply to TitleWarning and DescriptionWarning.
Addresses Copilot review on radix-ui#3840.
@sujiu222
Copy link
Copy Markdown
Author

sujiu222 commented Apr 3, 2026

Thanks for the review! Addressed all three points:

  1. SSR-safe useLayoutEffect: Switched from React.useLayoutEffect to @radix-ui/react-use-layout-effect for consistency with the rest of the codebase (e.g. portal.tsx). Also added @radix-ui/react-use-layout-effect to the dialog package dependencies.

  2. & 3. getRootNode() guard for DocumentFragment: Added instanceof Document || instanceof ShadowRoot check before calling getElementById. Falls back to contentRef.current?.ownerDocument ?? document when the root node is a DocumentFragment or any other node type that doesn't implement getElementById. Applied to both TitleWarning and DescriptionWarning.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Dialog] Scroll lock (overflow: hidden) persists after navigating away via Link inside Dialog

2 participants