-
Notifications
You must be signed in to change notification settings - Fork 1.3k
test: adds ShadowDOM / UNSAFE_PortalProvider tests #8806
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
7a6dfcc
bbb7588
322c7cf
3098625
c636c8b
02deef6
2e0e86e
06a5380
a9b85aa
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -20,6 +20,7 @@ import {Provider} from '@react-spectrum/provider'; | |
import React, {useEffect, useState} from 'react'; | ||
import ReactDOM from 'react-dom'; | ||
import {Example as StorybookExample} from '../stories/FocusScope.stories'; | ||
import {UNSAFE_PortalProvider} from '@react-aria/overlays'; | ||
import {useEvent} from '@react-aria/utils'; | ||
import userEvent from '@testing-library/user-event'; | ||
|
||
|
@@ -2150,6 +2151,208 @@ describe('FocusScope with Shadow DOM', function () { | |
unmount(); | ||
document.body.removeChild(shadowHost); | ||
}); | ||
|
||
it('should reproduce the specific issue #8675: Menu items in popover close immediately with UNSAFE_PortalProvider', async function () { | ||
const {shadowRoot, cleanup} = createShadowRoot(); | ||
let actionExecuted = false; | ||
let menuClosed = false; | ||
|
||
// Create portal container within the shadow DOM for the popover | ||
const popoverPortal = document.createElement('div'); | ||
popoverPortal.setAttribute('data-testid', 'popover-portal'); | ||
shadowRoot.appendChild(popoverPortal); | ||
|
||
// This reproduces the exact scenario described in the issue | ||
function WebComponentWithReactApp() { | ||
const [isPopoverOpen, setIsPopoverOpen] = React.useState(true); | ||
|
||
const handleMenuAction = key => { | ||
actionExecuted = true; | ||
// In the original issue, this never executes because the popover closes first | ||
console.log('Menu action executed:', key); | ||
}; | ||
|
||
return ( | ||
<UNSAFE_PortalProvider getContainer={() => shadowRoot}> | ||
<div data-testid="web-component-root"> | ||
<button | ||
data-testid="close-popover" | ||
onClick={() => { | ||
setIsPopoverOpen(false); | ||
menuClosed = true; | ||
}} | ||
style={{position: 'absolute', top: 0, right: 0}}> | ||
Close | ||
</button> | ||
{/* Portal the popover overlay to simulate real-world usage */} | ||
{isPopoverOpen && | ||
ReactDOM.createPortal( | ||
<FocusScope contain restoreFocus> | ||
<div data-testid="popover-overlay"> | ||
<FocusScope contain> | ||
<div role="menu" data-testid="menu-container"> | ||
<button role="menuitem" data-testid="menu-item-save" onClick={() => handleMenuAction('save')}> | ||
Save Document | ||
</button> | ||
<button | ||
role="menuitem" | ||
data-testid="menu-item-export" | ||
onClick={() => handleMenuAction('export')}> | ||
Export Document | ||
</button> | ||
</div> | ||
</FocusScope> | ||
</div> | ||
</FocusScope>, | ||
popoverPortal | ||
)} | ||
</div> | ||
</UNSAFE_PortalProvider> | ||
); | ||
} | ||
|
||
const {unmount} = render(<WebComponentWithReactApp />); | ||
|
||
// Wait for rendering | ||
act(() => { | ||
jest.runAllTimers(); | ||
}); | ||
|
||
// Query elements from shadow DOM | ||
const saveMenuItem = shadowRoot.querySelector('[data-testid="menu-item-save"]'); | ||
const exportMenuItem = shadowRoot.querySelector('[data-testid="menu-item-export"]'); | ||
const menuContainer = shadowRoot.querySelector('[data-testid="menu-container"]'); | ||
const popoverOverlay = shadowRoot.querySelector('[data-testid="popover-overlay"]'); | ||
// const closeButton = shadowRoot.querySelector('[data-testid="close-popover"]'); | ||
|
||
// Verify the menu is initially visible in shadow DOM | ||
expect(popoverOverlay).not.toBeNull(); | ||
expect(menuContainer).not.toBeNull(); | ||
expect(saveMenuItem).not.toBeNull(); | ||
expect(exportMenuItem).not.toBeNull(); | ||
|
||
// Focus the first menu item | ||
act(() => { | ||
saveMenuItem.focus(); | ||
}); | ||
expect(shadowRoot.activeElement).toBe(saveMenuItem); | ||
|
||
// Click the menu item - this should execute the onAction handler, NOT close the menu | ||
await user.click(saveMenuItem); | ||
|
||
// The action should have been executed (this would fail in the buggy version) | ||
expect(actionExecuted).toBe(true); | ||
|
||
// The menu should still be open (this would fail in the buggy version where it closes immediately) | ||
expect(menuClosed).toBe(false); | ||
expect(shadowRoot.querySelector('[data-testid="menu-container"]')).not.toBeNull(); | ||
|
||
// Test focus containment within the menu | ||
act(() => { | ||
saveMenuItem.focus(); | ||
}); | ||
await user.tab(); | ||
expect(shadowRoot.activeElement).toBe(exportMenuItem); | ||
|
||
await user.tab(); | ||
// Focus should wrap back to first item due to containment | ||
expect(shadowRoot.activeElement).toBe(saveMenuItem); | ||
|
||
// Cleanup | ||
unmount(); | ||
cleanup(); | ||
}); | ||
|
||
it('should handle web component scenario with multiple nested portals and UNSAFE_PortalProvider', async function () { | ||
const {shadowRoot, cleanup} = createShadowRoot(); | ||
|
||
// Create nested portal containers within the shadow DOM | ||
const modalPortal = document.createElement('div'); | ||
modalPortal.setAttribute('data-testid', 'modal-portal'); | ||
shadowRoot.appendChild(modalPortal); | ||
|
||
const tooltipPortal = document.createElement('div'); | ||
tooltipPortal.setAttribute('data-testid', 'tooltip-portal'); | ||
shadowRoot.appendChild(tooltipPortal); | ||
|
||
function ComplexWebComponent() { | ||
const [showModal, setShowModal] = React.useState(true); | ||
const [showTooltip] = React.useState(true); | ||
|
||
return ( | ||
<UNSAFE_PortalProvider getContainer={() => shadowRoot}> | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. same question about the context here too. I'm guessing the AI got confused, the consumer is inside our hooks/components which call createPortal, it won't just work on every instance of ReactDOM.createPortal unfortunately. https://react-spectrum.adobe.com/react-aria/PortalProvider.html#contexts |
||
<div data-testid="main-app"> | ||
<button data-testid="main-button">Main Button</button> | ||
|
||
{/* Modal with its own focus scope */} | ||
{showModal && | ||
ReactDOM.createPortal( | ||
<FocusScope contain restoreFocus autoFocus> | ||
<div data-testid="modal" role="dialog"> | ||
<button data-testid="modal-button-1">Modal Button 1</button> | ||
<button data-testid="modal-button-2">Modal Button 2</button> | ||
<button data-testid="close-modal" onClick={() => setShowModal(false)}> | ||
Close Modal | ||
</button> | ||
</div> | ||
</FocusScope>, | ||
modalPortal | ||
)} | ||
|
||
{/* Tooltip with nested focus scope */} | ||
{showTooltip && | ||
ReactDOM.createPortal( | ||
<FocusScope> | ||
<div data-testid="tooltip" role="tooltip"> | ||
<button data-testid="tooltip-action">Tooltip Action</button> | ||
</div> | ||
</FocusScope>, | ||
tooltipPortal | ||
)} | ||
</div> | ||
</UNSAFE_PortalProvider> | ||
); | ||
} | ||
|
||
const {unmount} = render(<ComplexWebComponent />); | ||
|
||
const modalButton1 = shadowRoot.querySelector('[data-testid="modal-button-1"]'); | ||
const modalButton2 = shadowRoot.querySelector('[data-testid="modal-button-2"]'); | ||
const tooltipAction = shadowRoot.querySelector('[data-testid="tooltip-action"]'); | ||
|
||
// Due to autoFocus, the first modal button should be focused | ||
act(() => { | ||
jest.runAllTimers(); | ||
}); | ||
expect(shadowRoot.activeElement).toBe(modalButton1); | ||
|
||
// Tab navigation should work within the modal | ||
await user.tab(); | ||
expect(shadowRoot.activeElement).toBe(modalButton2); | ||
|
||
// Focus should be contained within the modal due to the contain prop | ||
await user.tab(); | ||
// Should cycle to the close button | ||
expect(shadowRoot.activeElement.getAttribute('data-testid')).toBe('close-modal'); | ||
|
||
await user.tab(); | ||
// Should wrap back to first modal button | ||
expect(shadowRoot.activeElement).toBe(modalButton1); | ||
|
||
// The tooltip button should be focusable when we explicitly focus it | ||
act(() => { | ||
tooltipAction.focus(); | ||
}); | ||
act(() => { | ||
jest.runAllTimers(); | ||
}); | ||
// But due to modal containment, focus should be restored back to modal | ||
expect(shadowRoot.activeElement).toBe(modalButton1); | ||
|
||
// Cleanup | ||
unmount(); | ||
cleanup(); | ||
}); | ||
}); | ||
|
||
describe('Unmounting cleanup', () => { | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
same