diff --git a/packages/@react-aria/overlays/src/ariaHideOutside.ts b/packages/@react-aria/overlays/src/ariaHideOutside.ts index 753c2a926a3..47217ab4f41 100644 --- a/packages/@react-aria/overlays/src/ariaHideOutside.ts +++ b/packages/@react-aria/overlays/src/ariaHideOutside.ts @@ -147,7 +147,12 @@ export function ariaHideOutside(targets: Element[], options?: AriaHideOutsideOpt // If the parent element of the added nodes is not within one of the targets, // and not already inside a hidden node, hide all of the new children. - if (![...visibleNodes, ...hiddenNodes].some(node => node.contains(change.target))) { + if ( + change.target.isConnected && + ![...visibleNodes, ...hiddenNodes].some((node) => + node.contains(change.target) + ) + ) { for (let node of change.addedNodes) { if ( (node instanceof HTMLElement || node instanceof SVGElement) && diff --git a/packages/@react-aria/overlays/test/ariaHideOutside.test.js b/packages/@react-aria/overlays/test/ariaHideOutside.test.js index 0eddc67e0ac..36f921002b5 100644 --- a/packages/@react-aria/overlays/test/ariaHideOutside.test.js +++ b/packages/@react-aria/overlays/test/ariaHideOutside.test.js @@ -12,7 +12,7 @@ import {act, render, waitFor} from '@react-spectrum/test-utils-internal'; import {ariaHideOutside} from '../src'; -import React, {useState} from 'react'; +import React, {useRef, useState} from 'react'; describe('ariaHideOutside', function () { it('should hide everything except the provided element [button]', function () { @@ -275,6 +275,76 @@ describe('ariaHideOutside', function () { expect(() => getByTestId('test')).not.toThrow(); }); + it('should handle when a new element is added and then reparented', async function () { + + let Test = () => { + const ref = useRef(null); + const mutate = () => { + let parent = document.createElement('ul'); + let child = document.createElement('li'); + ref.current.append(parent); + parent.appendChild(child); + parent.remove(); // this results in a mutation record for a disconnected ul with a connected li (through the new ul parent) in `addedNodes` + let newParent = document.createElement('ul'); + newParent.appendChild(child); + ref.current.append(newParent); + }; + + return ( + <> +
+ +
+ + ); + }; + + let {queryByRole, getAllByRole, getByTestId} = render(); + + ariaHideOutside([getByTestId('test')]); + + queryByRole('button').click(); + await Promise.resolve(); // Wait for mutation observer tick + + expect(getAllByRole('listitem')).toHaveLength(1); + }); + + it('should handle when a new element is added and then reparented to a hidden container', async function () { + + let Test = () => { + const ref = useRef(null); + const mutate = () => { + let parent = document.createElement('ul'); + let child = document.createElement('li'); + ref.current.append(parent); + parent.appendChild(child); + parent.remove(); // this results in a mutation record for a disconnected ul with a connected li (through the new ul parent) in `addedNodes` + let newParent = document.createElement('ul'); + newParent.appendChild(child); + ref.current.append(newParent); + }; + + return ( + <> +
+ +
+
+ + ); + }; + + let {queryByRole, queryAllByRole, getByTestId} = render(); + + ariaHideOutside([getByTestId('test')]); + + queryByRole('button').click(); + await Promise.resolve(); // Wait for mutation observer tick + + expect(queryAllByRole('listitem')).toHaveLength(0); + }); + + it('work when called multiple times', function () { let {getByRole, getAllByRole} = render( <>