Skip to content
Open
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/fix-dialog-scroll-lock-cleanup-on-unmount.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@radix-ui/react-dialog': patch
---

Fix scroll lock not being released when Dialog is forcefully unmounted during SPA navigation. Added a `useLayoutEffect` cleanup in `DialogOverlayImpl` that synchronously removes `data-scroll-locked` from `document.body` when the overlay unmounts while the dialog is still open (e.g. route change triggered by a Link inside the Dialog).
59 changes: 59 additions & 0 deletions packages/react/dialog/src/dialog.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -139,3 +139,62 @@ describe('given a default Dialog', () => {
});
});
});

describe('scroll lock cleanup on forced unmount', () => {
/**
* Regression test for: https://github.com/radix-ui/primitives/issues/3797
*
* When a Dialog is open and its parent component is forcefully unmounted
* (e.g. during SPA route navigation), the `data-scroll-locked` attribute
* should be removed from `document.body` synchronously, preventing the
* page from remaining in a non-scrollable state.
*
* We use ReactDOM.createRoot directly to avoid @testing-library/react's
* dependency on the deprecated ReactDOM.act from react-dom/test-utils.
*/
let container: HTMLDivElement;
let root: import('react-dom/client').Root;

beforeEach(() => {
container = document.createElement('div');
document.body.appendChild(container);
});

afterEach(() => {
// Ensure no leftover attributes between tests
document.body.removeAttribute('data-scroll-locked');
if (root) {
root.unmount();
}
container.remove();
});

it('should remove data-scroll-locked when the open Dialog is force-unmounted', async () => {
const { createRoot } = await import('react-dom/client');
root = createRoot(container);

// Render a dialog in open state (simulating a route with open dialog)
await new Promise<void>((resolve) => {
root.render(
React.createElement(Dialog.Root, { open: true },
React.createElement(Dialog.Overlay),
React.createElement(Dialog.Content, null,
React.createElement(Dialog.Title, null, 'Title')
)
)
);
// Allow layout effects and async effects to run
setTimeout(resolve, 50);
});

// The dialog is open, scroll should be locked
expect(document.body).toHaveAttribute('data-scroll-locked');

// Simulate router navigation: forcefully unmount the entire tree
// while the dialog is still open (no onOpenChange(false) was called)
root.unmount();

// Scroll lock should be cleared synchronously (via useLayoutEffect)
expect(document.body).not.toHaveAttribute('data-scroll-locked');
});
});
80 changes: 74 additions & 6 deletions packages/react/dialog/src/dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -196,10 +196,69 @@ interface DialogOverlayImplProps extends PrimitiveDivProps {}

const Slot = createSlot('DialogOverlay.RemoveScroll');

/**
* The attribute set on `document.body` by `react-remove-scroll-bar` to lock scroll.
* We reference it here to ensure synchronous cleanup on forced unmount.
*/
const SCROLL_LOCK_ATTRIBUTE = 'data-scroll-locked';

const DialogOverlayImpl = React.forwardRef<DialogOverlayImplElement, DialogOverlayImplProps>(
(props: ScopedProps<DialogOverlayImplProps>, forwardedRef) => {
const { __scopeDialog, ...overlayProps } = props;
const context = useDialogContext(OVERLAY_NAME, __scopeDialog);

/**
* Ensure the scroll lock is released synchronously when the overlay is forcefully
* unmounted (e.g. when a router Link inside the Dialog triggers navigation while
* the Dialog is still open).
*
* `react-remove-scroll` manages `data-scroll-locked` via `useEffect`, which is
* asynchronous. In certain SPA router scenarios the old route's async effect
* cleanups may be deferred or skipped, leaving `overflow: hidden` on the body
* and preventing scrolling on the destination or return page.
*
* By using `useLayoutEffect` (synchronous, fires during the commit phase) we
* guarantee the attribute is decremented and removed as soon as the overlay
* leaves the React tree when the Dialog was still open at the time of unmount
* (i.e. a forced unmount, not a user-initiated close).
*
* We only run the cleanup when `context.open` is still `true` at unmount time.
* When the Dialog is closed normally, `context.open` transitions to `false`
* before the overlay unmounts (via Presence), so we defer to `react-remove-scroll`'s
* own cleanup to avoid double-decrementing the lock counter.
*
* Counter coordination: `react-remove-scroll-bar` stores the number of active
* locks as an integer in the attribute value. We read and write that same
* counter so nested / stacked dialogs continue to work correctly. Because our
* `useLayoutEffect` cleanup runs *before* `react-remove-scroll`'s `useEffect`
* cleanup, the subsequent async decrement will read 0 and call
* `removeAttribute`, which is a safe no-op on an already-absent attribute.
*/
// Track the latest `open` state in a ref so the cleanup function below can
// read it without needing to be in the effect's dependency array.
const isOpenRef = React.useRef(context.open);
isOpenRef.current = context.open;

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.

const raw = document.body.getAttribute(SCROLL_LOCK_ATTRIBUTE);
if (raw === null) return; // already cleaned up
const current = parseInt(raw, 10);
const next = isFinite(current) ? current - 1 : 0;
if (next <= 0) {
document.body.removeAttribute(SCROLL_LOCK_ATTRIBUTE);
} else {
document.body.setAttribute(SCROLL_LOCK_ATTRIBUTE, String(next));
}
};
}, []);

return (
// Make sure `Content` is scrollable even when it doesn't live inside `RemoveScroll`
// ie. when `Overlay` and `Content` are siblings
Expand Down Expand Up @@ -414,7 +473,7 @@ const DialogContentImpl = React.forwardRef<DialogContentImplElement, DialogConte
</FocusScope>
{process.env.NODE_ENV !== 'production' && (
<>
<TitleWarning titleId={context.titleId} />
<TitleWarning contentRef={contentRef} titleId={context.titleId} />
<DescriptionWarning contentRef={contentRef} descriptionId={context.descriptionId} />
</>
)}
Expand Down Expand Up @@ -503,9 +562,12 @@ const [WarningProvider, useWarningContext] = createContext(TITLE_WARNING_NAME, {
docsSlug: 'dialog',
});

type TitleWarningProps = { titleId?: string };
type TitleWarningProps = {
contentRef: React.RefObject<DialogContentElement | null>;
titleId?: string;
};

const TitleWarning: React.FC<TitleWarningProps> = ({ titleId }) => {
const TitleWarning: React.FC<TitleWarningProps> = ({ contentRef, titleId }) => {
const titleWarningContext = useWarningContext(TITLE_WARNING_NAME);

const MESSAGE = `\`${titleWarningContext.contentName}\` requires a \`${titleWarningContext.titleName}\` for the component to be accessible for screen reader users.
Expand All @@ -516,10 +578,13 @@ For more information, see https://radix-ui.com/primitives/docs/components/${titl

React.useEffect(() => {
if (titleId) {
const hasTitle = document.getElementById(titleId);
// 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);
Comment on lines +582 to 593
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.
}
}, [MESSAGE, titleId]);
}, [MESSAGE, contentRef, titleId]);

return null;
};
Expand All @@ -539,7 +604,10 @@ const DescriptionWarning: React.FC<DescriptionWarningProps> = ({ contentRef, des
const describedById = contentRef.current?.getAttribute('aria-describedby');
// if we have an id and the user hasn't set aria-describedby={undefined}
if (descriptionId && describedById) {
const hasDescription = document.getElementById(descriptionId);
// 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);
Comment on lines +615 to 626
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.
}
}, [MESSAGE, contentRef, descriptionId]);
Expand Down