From ed8d9c53998779d92aa09786f3f0dce59843f20d Mon Sep 17 00:00:00 2001 From: Albert Yu Date: Wed, 13 May 2026 03:05:35 +0800 Subject: [PATCH 1/5] Fix FocusTrap leaking in shadow DOM --- .../src/Unstable_TrapFocus/FocusTrap.test.tsx | 153 +++++++++++++++++- .../src/Unstable_TrapFocus/FocusTrap.tsx | 63 ++++++-- 2 files changed, 203 insertions(+), 13 deletions(-) diff --git a/packages/mui-material/src/Unstable_TrapFocus/FocusTrap.test.tsx b/packages/mui-material/src/Unstable_TrapFocus/FocusTrap.test.tsx index e779ecb554b754..af56980fafb393 100644 --- a/packages/mui-material/src/Unstable_TrapFocus/FocusTrap.test.tsx +++ b/packages/mui-material/src/Unstable_TrapFocus/FocusTrap.test.tsx @@ -1,7 +1,15 @@ import * as React from 'react'; import * as ReactDOM from 'react-dom'; import { expect } from 'chai'; -import { act, createRenderer, fireEvent, reactMajor, screen } from '@mui/internal-test-utils'; +import { + act, + createRenderer, + fireEvent, + isJsdom, + reactMajor, + screen, + within, +} from '@mui/internal-test-utils'; import FocusTrap from '@mui/material/Unstable_TrapFocus'; import Portal from '@mui/material/Portal'; import getActiveElement from '../utils/getActiveElement'; @@ -11,6 +19,27 @@ interface GenericProps { [index: string]: any; } +/** + * This file runs in both node and browser projects, so `vitest/browser` must be + * imported lazily from browser-only tests. + * + * Use the Vitest Browser userEvent provider for shadow-root keyboard interactions. + * The `user` returned by `render()` comes from @testing-library/user-event. + * Its Tab implementation computes the tab order from `document.querySelectorAll()`, + * which doesn't include focusable elements inside shadow roots. + * + * Keep the import specifier widened to `string` so package TypeScript checks + * don't load Vitest Browser's ambient matcher types. + */ +async function setupBrowser() { + const { page, userEvent } = await import('vitest/browser' as string); + + return { + page, + user: userEvent.setup(), + }; +} + describe('', () => { const { clock, render } = createRenderer(); @@ -366,6 +395,128 @@ describe('', () => { expect(screen.getByRole('textbox')).toHaveFocus(); }); + describe.skipIf(isJsdom())('shadow DOM', () => { + it('loops focus when rendered in a shadow root', async () => { + const shadowHost = document.createElement('div'); + document.body.appendChild(shadowHost); + const shadowRoot = shadowHost.attachShadow({ mode: 'open' }); + const shadowContainer = document.createElement('div'); + shadowRoot.appendChild(shadowContainer); + + let unmount: (() => void) | undefined; + + try { + ({ unmount } = render( + +
+ + +
+
, + { container: shadowContainer }, + )); + + const { user } = await setupBrowser(); + const firstButton = within(shadowContainer).getByRole('button', { name: 'first' }); + const lastButton = within(shadowContainer).getByRole('button', { name: 'last' }); + + await user.click(lastButton); + expect(shadowRoot.activeElement).to.equal(lastButton); + + await user.tab(); + expect(shadowRoot.activeElement).to.equal(firstButton); + + await user.tab({ shift: true }); + expect(shadowRoot.activeElement).to.equal(lastButton); + } finally { + unmount?.(); + document.body.removeChild(shadowHost); + } + }); + + it('loops backward when Shift+Tab enters a lazy shadow-root trap from the document', async () => { + const shadowHost = document.createElement('div'); + const outsideAfter = document.createElement('button'); + outsideAfter.textContent = 'after'; + document.body.appendChild(shadowHost); + document.body.appendChild(outsideAfter); + const shadowRoot = shadowHost.attachShadow({ mode: 'open' }); + const shadowContainer = document.createElement('div'); + shadowRoot.appendChild(shadowContainer); + + let unmount: (() => void) | undefined; + + try { + ({ unmount } = render( + +
+ + +
+
, + { container: shadowContainer }, + )); + + const { user } = await setupBrowser(); + const lastButton = within(shadowContainer).getByRole('button', { name: 'last' }); + + await user.click(outsideAfter); + expect(document.activeElement).to.equal(outsideAfter); + + await user.tab({ shift: true }); + expect(shadowRoot.activeElement).to.equal(lastButton); + } finally { + unmount?.(); + document.body.removeChild(shadowHost); + document.body.removeChild(outsideAfter); + } + }); + + it('loops backward when Shift+Tab enters a shadow-root trap from an iframe document', async () => { + const frame = document.createElement('iframe'); + document.body.appendChild(frame); + const frameDocument = frame.contentDocument!; + const shadowHost = frameDocument.createElement('div'); + const outsideAfter = frameDocument.createElement('button'); + outsideAfter.textContent = 'after'; + frameDocument.body.appendChild(shadowHost); + frameDocument.body.appendChild(outsideAfter); + const shadowRoot = shadowHost.attachShadow({ mode: 'open' }); + const shadowContainer = frameDocument.createElement('div'); + shadowRoot.appendChild(shadowContainer); + + let unmount: (() => void) | undefined; + + try { + ({ unmount } = render( + +
+ + +
+
, + { container: shadowContainer }, + )); + + const { page, user } = await setupBrowser(); + const frameLocator = page.frameLocator(page.elementLocator(frame)); + const lastButton = within(shadowContainer).getByRole('button', { name: 'last' }); + + // Click through a frame locator; passing the iframe-owned DOM element + // directly to `user.click()` does not focus it. + // eslint-disable-next-line testing-library/prefer-screen-queries -- `frameLocator` is a Vitest Browser locator, not a render result. + await user.click(frameLocator.getByRole('button', { name: 'after' })); + expect(frameDocument.activeElement).to.equal(outsideAfter); + + await user.tab({ shift: true }); + expect(shadowRoot.activeElement).to.equal(lastButton); + } finally { + unmount?.(); + document.body.removeChild(frame); + } + }); + }); + it('restores focus when closed', () => { function Test(props: GenericProps) { return ( diff --git a/packages/mui-material/src/Unstable_TrapFocus/FocusTrap.tsx b/packages/mui-material/src/Unstable_TrapFocus/FocusTrap.tsx index 58a900b4bac312..48f405d6271d8f 100644 --- a/packages/mui-material/src/Unstable_TrapFocus/FocusTrap.tsx +++ b/packages/mui-material/src/Unstable_TrapFocus/FocusTrap.tsx @@ -123,6 +123,26 @@ function defaultIsEnabled(): boolean { return true; } +const DOCUMENT_FRAGMENT_NODE = 11; + +function isShadowRoot(node: Node): node is ShadowRoot { + return node.nodeType === DOCUMENT_FRAGMENT_NODE && 'host' in node && 'activeElement' in node; +} + +function isKeyboardEvent(event: Event): event is KeyboardEvent { + return 'key' in event && typeof event.key === 'string'; +} + +function getFocusRoot(node: HTMLElement): Document | ShadowRoot { + const rootNode = node.getRootNode(); + + if (isShadowRoot(rootNode)) { + return rootNode; + } + + return ownerDocument(node); +} + /** * @ignore - internal component. */ @@ -169,7 +189,8 @@ function FocusTrap(props: FocusTrapProps): React.JSX.Element { } const doc = ownerDocument(rootRef.current); - const activeElement = getActiveElement(doc); + const focusRoot = getFocusRoot(rootRef.current); + const activeElement = getActiveElement(focusRoot) ?? getActiveElement(doc); // Prefer the explicitly marked focusable element. Fall back to the root // element for generic FocusTrap usage. @@ -214,8 +235,14 @@ function FocusTrap(props: FocusTrapProps): React.JSX.Element { } const doc = ownerDocument(rootRef.current); + const focusRoot = getFocusRoot(rootRef.current); + const getRootActiveElement = () => getActiveElement(focusRoot) ?? getActiveElement(doc); + + const handleLoopFocus: EventListener = (nativeEvent) => { + if (!isKeyboardEvent(nativeEvent)) { + return; + } - const loopFocus = (nativeEvent: KeyboardEvent) => { lastKeydown.current = nativeEvent; if (disableEnforceFocus || !isEnabled() || nativeEvent.key !== 'Tab') { @@ -223,7 +250,7 @@ function FocusTrap(props: FocusTrapProps): React.JSX.Element { } const rootElement = rootRef.current; - const activeElement = getActiveElement(doc); + const activeElement = getRootActiveElement(); if (rootElement === null) { return; @@ -280,16 +307,18 @@ function FocusTrap(props: FocusTrapProps): React.JSX.Element { } }; - const contain = () => { + // Enforce the focus trap when focus moves outside the root or lands on + // one of the sentinel elements. + const handleFocusIn = () => { const rootElement = rootRef.current; // Cleanup functions are executed lazily in React 17. - // Contain can be called between the component being unmounted and its cleanup function being run. + // The focus-in handler can be called between the component being unmounted and its cleanup function being run. if (rootElement === null) { return; } - const activeEl = getActiveElement(doc); + const activeEl = getRootActiveElement(); if (!doc.hasFocus() || !isEnabled() || ignoreNextEnforceFocus.current) { ignoreNextEnforceFocus.current = false; @@ -349,8 +378,13 @@ function FocusTrap(props: FocusTrapProps): React.JSX.Element { } }; - doc.addEventListener('focusin', contain); - doc.addEventListener('keydown', loopFocus, true); + focusRoot.addEventListener('focusin', handleFocusIn); + focusRoot.addEventListener('keydown', handleLoopFocus, true); + + if (focusRoot !== doc) { + doc.addEventListener('focusin', handleFocusIn); + doc.addEventListener('keydown', handleLoopFocus, true); + } // With Edge, Safari and Firefox, no focus related events are fired when the focused area stops being a focused area. // for example https://bugzilla.mozilla.org/show_bug.cgi?id=559561. @@ -359,17 +393,22 @@ function FocusTrap(props: FocusTrapProps): React.JSX.Element { // The whatwg spec defines how the browser should behave but does not explicitly mention any events: // https://html.spec.whatwg.org/multipage/interaction.html#focus-fixup-rule. const interval = setInterval(() => { - const activeEl = getActiveElement(doc); + const activeEl = getRootActiveElement(); if (activeEl && activeEl.tagName === 'BODY') { - contain(); + handleFocusIn(); } }, 50); return () => { clearInterval(interval); - doc.removeEventListener('focusin', contain); - doc.removeEventListener('keydown', loopFocus, true); + focusRoot.removeEventListener('focusin', handleFocusIn); + focusRoot.removeEventListener('keydown', handleLoopFocus, true); + + if (focusRoot !== doc) { + doc.removeEventListener('focusin', handleFocusIn); + doc.removeEventListener('keydown', handleLoopFocus, true); + } }; }, [disableAutoFocus, disableEnforceFocus, disableRestoreFocus, isEnabled, open, getTabbable]); From b93920712511af917b2a87d2fe04401aaacffe04 Mon Sep 17 00:00:00 2001 From: Albert Yu Date: Wed, 20 May 2026 07:40:34 +0800 Subject: [PATCH 2/5] fix ci --- .../src/Unstable_TrapFocus/FocusTrap.test.tsx | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/packages/mui-material/src/Unstable_TrapFocus/FocusTrap.test.tsx b/packages/mui-material/src/Unstable_TrapFocus/FocusTrap.test.tsx index af56980fafb393..918af99bf4564c 100644 --- a/packages/mui-material/src/Unstable_TrapFocus/FocusTrap.test.tsx +++ b/packages/mui-material/src/Unstable_TrapFocus/FocusTrap.test.tsx @@ -436,8 +436,8 @@ describe('', () => { it('loops backward when Shift+Tab enters a lazy shadow-root trap from the document', async () => { const shadowHost = document.createElement('div'); - const outsideAfter = document.createElement('button'); - outsideAfter.textContent = 'after'; + const outsideAfter = document.createElement('input'); + outsideAfter.setAttribute('aria-label', 'after'); document.body.appendChild(shadowHost); document.body.appendChild(outsideAfter); const shadowRoot = shadowHost.attachShadow({ mode: 'open' }); @@ -457,10 +457,11 @@ describe('', () => { { container: shadowContainer }, )); - const { user } = await setupBrowser(); + const { page, user } = await setupBrowser(); const lastButton = within(shadowContainer).getByRole('button', { name: 'last' }); - await user.click(outsideAfter); + // eslint-disable-next-line testing-library/prefer-screen-queries -- `page` is a Vitest Browser locator provider, not a render result. + await user.fill(page.getByRole('textbox', { name: 'after' }), 'focused'); expect(document.activeElement).to.equal(outsideAfter); await user.tab({ shift: true }); @@ -477,8 +478,8 @@ describe('', () => { document.body.appendChild(frame); const frameDocument = frame.contentDocument!; const shadowHost = frameDocument.createElement('div'); - const outsideAfter = frameDocument.createElement('button'); - outsideAfter.textContent = 'after'; + const outsideAfter = frameDocument.createElement('input'); + outsideAfter.setAttribute('aria-label', 'after'); frameDocument.body.appendChild(shadowHost); frameDocument.body.appendChild(outsideAfter); const shadowRoot = shadowHost.attachShadow({ mode: 'open' }); @@ -505,7 +506,7 @@ describe('', () => { // Click through a frame locator; passing the iframe-owned DOM element // directly to `user.click()` does not focus it. // eslint-disable-next-line testing-library/prefer-screen-queries -- `frameLocator` is a Vitest Browser locator, not a render result. - await user.click(frameLocator.getByRole('button', { name: 'after' })); + await user.fill(frameLocator.getByRole('textbox', { name: 'after' }), 'focused'); expect(frameDocument.activeElement).to.equal(outsideAfter); await user.tab({ shift: true }); From 521a2cc154255596bf0cc28e9cb7f50f567fd661 Mon Sep 17 00:00:00 2001 From: Albert Yu Date: Wed, 20 May 2026 20:20:41 +0800 Subject: [PATCH 3/5] fix ci again --- .../src/Unstable_TrapFocus/FocusTrap.test.tsx | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/packages/mui-material/src/Unstable_TrapFocus/FocusTrap.test.tsx b/packages/mui-material/src/Unstable_TrapFocus/FocusTrap.test.tsx index 918af99bf4564c..6bbd30f6ea9bf4 100644 --- a/packages/mui-material/src/Unstable_TrapFocus/FocusTrap.test.tsx +++ b/packages/mui-material/src/Unstable_TrapFocus/FocusTrap.test.tsx @@ -475,7 +475,13 @@ describe('', () => { it('loops backward when Shift+Tab enters a shadow-root trap from an iframe document', async () => { const frame = document.createElement('iframe'); + frame.setAttribute('data-testid', 'focus-trap-shadow-root-iframe'); + frame.srcdoc = ''; + const frameLoaded = new Promise((resolve) => { + frame.addEventListener('load', () => resolve(), { once: true }); + }); document.body.appendChild(frame); + await frameLoaded; const frameDocument = frame.contentDocument!; const shadowHost = frameDocument.createElement('div'); const outsideAfter = frameDocument.createElement('input'); @@ -500,13 +506,14 @@ describe('', () => { )); const { page, user } = await setupBrowser(); - const frameLocator = page.frameLocator(page.elementLocator(frame)); + // eslint-disable-next-line testing-library/prefer-screen-queries -- `page` is a Vitest Browser locator provider, not a render result. + const frameLocator = page.frameLocator(page.getByTestId('focus-trap-shadow-root-iframe')); const lastButton = within(shadowContainer).getByRole('button', { name: 'last' }); - // Click through a frame locator; passing the iframe-owned DOM element - // directly to `user.click()` does not focus it. + // Fill through a frame locator; passing the iframe-owned DOM element + // directly to `user.fill()` does not focus it. // eslint-disable-next-line testing-library/prefer-screen-queries -- `frameLocator` is a Vitest Browser locator, not a render result. - await user.fill(frameLocator.getByRole('textbox', { name: 'after' }), 'focused'); + await user.fill(frameLocator.getByLabelText('after'), 'focused'); expect(frameDocument.activeElement).to.equal(outsideAfter); await user.tab({ shift: true }); From 692c2c163485df5ca5c2fd9ce19fc8674872303a Mon Sep 17 00:00:00 2001 From: Albert Yu Date: Wed, 20 May 2026 23:31:38 +0800 Subject: [PATCH 4/5] test cleanup --- .../src/Unstable_TrapFocus/FocusTrap.test.tsx | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/packages/mui-material/src/Unstable_TrapFocus/FocusTrap.test.tsx b/packages/mui-material/src/Unstable_TrapFocus/FocusTrap.test.tsx index 6bbd30f6ea9bf4..5e4ebce8b16763 100644 --- a/packages/mui-material/src/Unstable_TrapFocus/FocusTrap.test.tsx +++ b/packages/mui-material/src/Unstable_TrapFocus/FocusTrap.test.tsx @@ -32,11 +32,11 @@ interface GenericProps { * don't load Vitest Browser's ambient matcher types. */ async function setupBrowser() { - const { page, userEvent } = await import('vitest/browser' as string); + const { page, userEvent: user } = await import('vitest/browser' as string); return { page, - user: userEvent.setup(), + user, }; } @@ -460,6 +460,8 @@ describe('', () => { const { page, user } = await setupBrowser(); const lastButton = within(shadowContainer).getByRole('button', { name: 'last' }); + // In Firefox, filling through a provider locator is the reliable way + // to move focus to this dynamically appended input before pressing Shift+Tab. // eslint-disable-next-line testing-library/prefer-screen-queries -- `page` is a Vitest Browser locator provider, not a render result. await user.fill(page.getByRole('textbox', { name: 'after' }), 'focused'); expect(document.activeElement).to.equal(outsideAfter); @@ -476,6 +478,8 @@ describe('', () => { it('loops backward when Shift+Tab enters a shadow-root trap from an iframe document', async () => { const frame = document.createElement('iframe'); frame.setAttribute('data-testid', 'focus-trap-shadow-root-iframe'); + // Firefox in the full no-isolate browser run needs the iframe to load + // a real same-origin document before Vitest Browser can query inside it reliably. frame.srcdoc = ''; const frameLoaded = new Promise((resolve) => { frame.addEventListener('load', () => resolve(), { once: true }); @@ -506,12 +510,15 @@ describe('', () => { )); const { page, user } = await setupBrowser(); + // Use a unique page locator instead of `page.elementLocator(frame)`. + // Vitest Browser serializes element locators for iframes to a broad `iframe` + // selector, which can bind to a stale iframe in the full no-isolate Firefox run. // eslint-disable-next-line testing-library/prefer-screen-queries -- `page` is a Vitest Browser locator provider, not a render result. const frameLocator = page.frameLocator(page.getByTestId('focus-trap-shadow-root-iframe')); const lastButton = within(shadowContainer).getByRole('button', { name: 'last' }); - // Fill through a frame locator; passing the iframe-owned DOM element - // directly to `user.fill()` does not focus it. + // Passing the iframe-owned DOM element directly to `user.fill()` does + // not focus it reliably in Firefox. // eslint-disable-next-line testing-library/prefer-screen-queries -- `frameLocator` is a Vitest Browser locator, not a render result. await user.fill(frameLocator.getByLabelText('after'), 'focused'); expect(frameDocument.activeElement).to.equal(outsideAfter); From 70b05c2c1c8a05b4fac7f48543a69f1ed6b3c450 Mon Sep 17 00:00:00 2001 From: Albert Yu Date: Mon, 1 Jun 2026 16:06:11 +0800 Subject: [PATCH 5/5] add test --- .../src/Unstable_TrapFocus/FocusTrap.test.tsx | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/packages/mui-material/src/Unstable_TrapFocus/FocusTrap.test.tsx b/packages/mui-material/src/Unstable_TrapFocus/FocusTrap.test.tsx index 5e4ebce8b16763..8d6404bf8e544e 100644 --- a/packages/mui-material/src/Unstable_TrapFocus/FocusTrap.test.tsx +++ b/packages/mui-material/src/Unstable_TrapFocus/FocusTrap.test.tsx @@ -434,6 +434,40 @@ describe('', () => { } }); + it('loops backward when Shift+Tab starts from the shadow-root trap container', async () => { + const shadowHost = document.createElement('div'); + document.body.appendChild(shadowHost); + const shadowRoot = shadowHost.attachShadow({ mode: 'open' }); + const shadowContainer = document.createElement('div'); + shadowRoot.appendChild(shadowContainer); + + let unmount: (() => void) | undefined; + + try { + ({ unmount } = render( + +
+ + +
+
, + { container: shadowContainer }, + )); + + const { user } = await setupBrowser(); + const root = within(shadowContainer).getByTestId('root'); + const lastButton = within(shadowContainer).getByRole('button', { name: 'last' }); + + expect(shadowRoot.activeElement).to.equal(root); + + await user.tab({ shift: true }); + expect(shadowRoot.activeElement).to.equal(lastButton); + } finally { + unmount?.(); + document.body.removeChild(shadowHost); + } + }); + it('loops backward when Shift+Tab enters a lazy shadow-root trap from the document', async () => { const shadowHost = document.createElement('div'); const outsideAfter = document.createElement('input');