From ebd50a22fad13b66dabb759093544b0a59080497 Mon Sep 17 00:00:00 2001 From: Robert Snow Date: Wed, 28 Jan 2026 14:54:46 +1100 Subject: [PATCH 1/4] chore: faster node contains paths --- .../nwsapi-npm-2.2.23-aa3710d724.patch | 18 ++++ eslint.config.mjs | 1 + package.json | 3 +- .../calendar/src/useRangeCalendar.ts | 2 +- packages/@react-aria/dialog/src/useDialog.ts | 4 +- packages/@react-aria/grid/src/useGridCell.ts | 4 +- .../gridlist/src/useGridListItem.ts | 2 +- .../@react-aria/landmark/src/useLandmark.ts | 4 +- .../@react-aria/menu/src/useSubmenuTrigger.ts | 2 +- .../overlays/src/useOverlayPosition.ts | 4 +- .../selection/src/useSelectableCollection.ts | 4 +- .../test-utils/src/checkboxgroup.ts | 3 +- .../@react-aria/test-utils/src/combobox.ts | 5 +- packages/@react-aria/test-utils/src/dialog.ts | 7 +- .../@react-aria/test-utils/src/gridlist.ts | 5 +- .../@react-aria/test-utils/src/listbox.ts | 3 +- packages/@react-aria/test-utils/src/menu.ts | 6 +- .../@react-aria/test-utils/src/radiogroup.ts | 3 +- packages/@react-aria/test-utils/src/select.ts | 7 +- packages/@react-aria/test-utils/src/table.ts | 13 ++- packages/@react-aria/test-utils/src/tabs.ts | 5 +- packages/@react-aria/test-utils/src/tree.ts | 7 +- .../@react-aria/utils/src/scrollIntoView.ts | 13 ++- .../menu/src/ContextualHelpTrigger.tsx | 2 +- .../menu/src/SubmenuTrigger.tsx | 4 +- .../menu/src/useOverlayPosition.ts | 4 +- packages/@react-spectrum/s2/src/TableView.tsx | 2 +- .../table/src/TableViewBase.tsx | 4 +- packages/dev/eslint-plugin-rsp-rules/index.js | 4 +- .../rules/faster-node-contains.js | 82 +++++++++++++++++++ .../test/faster-node-contains.test-lint.js | 63 ++++++++++++++ .../react-aria-components/src/Popover.tsx | 4 +- yarn.lock | 15 +++- 33 files changed, 236 insertions(+), 73 deletions(-) create mode 100644 .yarn/patches/nwsapi-npm-2.2.23-aa3710d724.patch create mode 100644 packages/dev/eslint-plugin-rsp-rules/rules/faster-node-contains.js create mode 100644 packages/dev/eslint-plugin-rsp-rules/test/faster-node-contains.test-lint.js diff --git a/.yarn/patches/nwsapi-npm-2.2.23-aa3710d724.patch b/.yarn/patches/nwsapi-npm-2.2.23-aa3710d724.patch new file mode 100644 index 00000000000..5aee0935007 --- /dev/null +++ b/.yarn/patches/nwsapi-npm-2.2.23-aa3710d724.patch @@ -0,0 +1,18 @@ +diff --git a/src/nwsapi.js b/src/nwsapi.js +index 872026b4ab3462f3c2411bc564dd428bd3165323..f71ae753743c9d6155e2f522c1ac3c7fdbb05c32 100644 +--- a/src/nwsapi.js ++++ b/src/nwsapi.js +@@ -1241,9 +1241,10 @@ + 'if((e===n||e.autofocus)){' + source + '}'; + break; + case 'focus-within': +- source = 'if(n=s.isFocusable(e)){' + +- 'if(n!==e){while(n){n=n.parentElement;if(n===e)break;}}}' + +- 'if((n===e||n.autofocus)){' + source + '}'; ++ // Check if e contains the activeElement by walking up from activeElement ++ source = 'if(s.doc.hasFocus()){' + ++ 'n=s.doc.activeElement;' + ++ 'while(n){if(n===e){' + source + 'break;}n=n.parentElement;}}'; + break; + default: + emit('\'' + expression + '\'' + qsInvalid); diff --git a/eslint.config.mjs b/eslint.config.mjs index ad336dabaf3..1269ca11617 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -250,6 +250,7 @@ export default [{ "rsp-rules/no-react-key": [ERROR], "rsp-rules/sort-imports": [ERROR], "rsp-rules/no-non-shadow-contains": [ERROR], + "rsp-rules/faster-node-contains": [ERROR], "rulesdir/imports": [ERROR], "rulesdir/useLayoutEffectRule": [ERROR], "rulesdir/pure-render": [ERROR], diff --git a/package.json b/package.json index bcf6427197d..8d98d66b2b5 100644 --- a/package.json +++ b/package.json @@ -239,7 +239,8 @@ "lightningcss": "1.30.1", "react-server-dom-parcel": "canary", "react-test-renderer": "19.1.0", - "@parcel/packager-react-static": "^2.16.3" + "@parcel/packager-react-static": "^2.16.3", + "nwsapi@npm:^2.2.2": "patch:nwsapi@npm%3A2.2.23#~/.yarn/patches/nwsapi-npm-2.2.23-aa3710d724.patch" }, "@parcel/transformer-css": { "cssModules": { diff --git a/packages/@react-aria/calendar/src/useRangeCalendar.ts b/packages/@react-aria/calendar/src/useRangeCalendar.ts index f228c77b477..eba5a6984ba 100644 --- a/packages/@react-aria/calendar/src/useRangeCalendar.ts +++ b/packages/@react-aria/calendar/src/useRangeCalendar.ts @@ -52,7 +52,7 @@ export function useRangeCalendar(props: AriaRangeCalendarPr let target = e.target as Element; if ( ref.current && - nodeContains(ref.current, document.activeElement) && + ref.current.matches(':focus-within') && (!nodeContains(ref.current, target) || !target.closest('button, [role="button"]')) ) { state.selectFocusedDate(); diff --git a/packages/@react-aria/dialog/src/useDialog.ts b/packages/@react-aria/dialog/src/useDialog.ts index eef23f9968c..238f3c0706a 100644 --- a/packages/@react-aria/dialog/src/useDialog.ts +++ b/packages/@react-aria/dialog/src/useDialog.ts @@ -12,7 +12,7 @@ import {AriaDialogProps} from '@react-types/dialog'; import {DOMAttributes, FocusableElement, RefObject} from '@react-types/shared'; -import {filterDOMProps, nodeContains, useSlotId} from '@react-aria/utils'; +import {filterDOMProps, useSlotId} from '@react-aria/utils'; import {focusSafely} from '@react-aria/interactions'; import {useEffect, useRef} from 'react'; import {useOverlayFocusContain} from '@react-aria/overlays'; @@ -40,7 +40,7 @@ export function useDialog(props: AriaDialogProps, ref: RefObject { - if (ref.current && !nodeContains(ref.current, document.activeElement)) { + if (ref.current && !ref.current.matches(':focus-within')) { focusSafely(ref.current); // Safari on iOS does not move the VoiceOver cursor to the dialog diff --git a/packages/@react-aria/grid/src/useGridCell.ts b/packages/@react-aria/grid/src/useGridCell.ts index a7ee80f5ec6..676a59ea28b 100644 --- a/packages/@react-aria/grid/src/useGridCell.ts +++ b/packages/@react-aria/grid/src/useGridCell.ts @@ -75,7 +75,7 @@ export function useGridCell>(props: GridCellProps let treeWalker = getFocusableTreeWalker(ref.current); if (focusMode === 'child') { // If focus is already on a focusable child within the cell, early return so we don't shift focus - if (nodeContains(ref.current, document.activeElement) && ref.current !== document.activeElement) { + if (ref.current.matches(':focus-within') && ref.current !== document.activeElement) { return; } @@ -90,7 +90,7 @@ export function useGridCell>(props: GridCellProps if ( (keyWhenFocused.current != null && node.key !== keyWhenFocused.current) || - !nodeContains(ref.current, document.activeElement) + !ref.current.matches(':focus-within') ) { focusSafely(ref.current); } diff --git a/packages/@react-aria/gridlist/src/useGridListItem.ts b/packages/@react-aria/gridlist/src/useGridListItem.ts index 6a1bd8e27c1..211db1a1cad 100644 --- a/packages/@react-aria/gridlist/src/useGridListItem.ts +++ b/packages/@react-aria/gridlist/src/useGridListItem.ts @@ -79,7 +79,7 @@ export function useGridListItem(props: AriaGridListItemOptions, state: ListSt if ( ref.current !== null && ((keyWhenFocused.current != null && node.key !== keyWhenFocused.current) || - !nodeContains(ref.current, document.activeElement)) + !ref.current.matches(':focus-within')) ) { focusSafely(ref.current); } diff --git a/packages/@react-aria/landmark/src/useLandmark.ts b/packages/@react-aria/landmark/src/useLandmark.ts index 692d434981b..34001d640c1 100644 --- a/packages/@react-aria/landmark/src/useLandmark.ts +++ b/packages/@react-aria/landmark/src/useLandmark.ts @@ -325,7 +325,7 @@ class LandmarkManager implements LandmarkManagerApi { private focusMain() { let main = this.getLandmarkByRole('main'); - if (main && main.ref.current && nodeContains(document, main.ref.current)) { + if (main && main.ref.current && main.ref.current.isConnected) { this.focusLandmark(main.ref.current, 'forward'); return true; } @@ -352,7 +352,7 @@ class LandmarkManager implements LandmarkManagerApi { } // Otherwise, focus the landmark itself - if (nextLandmark.ref.current && nodeContains(document, nextLandmark.ref.current)) { + if (nextLandmark.ref.current && nextLandmark.ref.current.isConnected) { this.focusLandmark(nextLandmark.ref.current, backward ? 'backward' : 'forward'); return true; } diff --git a/packages/@react-aria/menu/src/useSubmenuTrigger.ts b/packages/@react-aria/menu/src/useSubmenuTrigger.ts index 14df02c2243..6235e23a3d4 100644 --- a/packages/@react-aria/menu/src/useSubmenuTrigger.ts +++ b/packages/@react-aria/menu/src/useSubmenuTrigger.ts @@ -100,7 +100,7 @@ export function useSubmenuTrigger(props: AriaSubmenuTriggerProps, state: Subm let submenuKeyDown = (e: KeyboardEvent) => { // If focus is not within the menu, assume virtual focus is being used. // This means some other input element is also within the popover, so we shouldn't close the menu. - if (!nodeContains(e.currentTarget, document.activeElement)) { + if (!e.currentTarget.matches(':focus-within')) { return; } diff --git a/packages/@react-aria/overlays/src/useOverlayPosition.ts b/packages/@react-aria/overlays/src/useOverlayPosition.ts index ee218a5b7ca..53c29e5da12 100644 --- a/packages/@react-aria/overlays/src/useOverlayPosition.ts +++ b/packages/@react-aria/overlays/src/useOverlayPosition.ts @@ -12,10 +12,10 @@ import {calculatePosition, getRect, PositionResult} from './calculatePosition'; import {DOMAttributes, RefObject} from '@react-types/shared'; -import {nodeContains, useLayoutEffect, useResizeObserver} from '@react-aria/utils'; import {Placement, PlacementAxis, PositionProps} from '@react-types/overlays'; import {useCallback, useEffect, useRef, useState} from 'react'; import {useCloseOnScroll} from './useCloseOnScroll'; +import {useLayoutEffect, useResizeObserver} from '@react-aria/utils'; import {useLocale} from '@react-aria/i18n'; export interface AriaPositionProps extends PositionProps { @@ -154,7 +154,7 @@ export function useOverlayPosition(props: AriaPositionProps): PositionAria { // so it can be restored after repositioning. This way if the overlay height // changes, the focused element appears to stay in the same position. let anchor: ScrollAnchor | null = null; - if (scrollRef.current && nodeContains(scrollRef.current, document.activeElement)) { + if (scrollRef.current && scrollRef.current.matches(':focus-within')) { let anchorRect = document.activeElement?.getBoundingClientRect(); let scrollRect = scrollRef.current.getBoundingClientRect(); // Anchor from the top if the offset is in the top half of the scrollable element, diff --git a/packages/@react-aria/selection/src/useSelectableCollection.ts b/packages/@react-aria/selection/src/useSelectableCollection.ts index 39e5dd7fe45..e36f3443847 100644 --- a/packages/@react-aria/selection/src/useSelectableCollection.ts +++ b/packages/@react-aria/selection/src/useSelectableCollection.ts @@ -314,7 +314,7 @@ export function useSelectableCollection(options: AriaSelectableCollectionOptions // If the active element is NOT tabbable but is contained by an element that IS tabbable (aka the cell), the browser will actually move focus to // the containing element. We need to special case this so that tab will move focus out of the grid instead of looping between // focusing the containing cell and back to the non-tabbable child element - if (next && (!nodeContains(next, document.activeElement) || (document.activeElement && !isTabbable(document.activeElement)))) { + if (next && (!next.matches(':focus-within') || (document.activeElement && !isTabbable(document.activeElement)))) { focusWithoutScrolling(next); } } @@ -379,7 +379,7 @@ export function useSelectableCollection(options: AriaSelectableCollectionOptions let element = getItemElement(ref, manager.focusedKey); if (element instanceof HTMLElement) { // This prevents a flash of focus on the first/last element in the collection, or the collection itself. - if (!nodeContains(element, document.activeElement) && !shouldUseVirtualFocus) { + if (!element.matches(':focus-within') && !shouldUseVirtualFocus) { focusWithoutScrolling(element); } diff --git a/packages/@react-aria/test-utils/src/checkboxgroup.ts b/packages/@react-aria/test-utils/src/checkboxgroup.ts index 7451b7ec9cb..28aec8efd48 100644 --- a/packages/@react-aria/test-utils/src/checkboxgroup.ts +++ b/packages/@react-aria/test-utils/src/checkboxgroup.ts @@ -12,7 +12,6 @@ import {act, within} from '@testing-library/react'; import {CheckboxGroupTesterOpts, UserOpts} from './types'; -import {nodeContains} from '@react-aria/utils'; import {pressElement} from './events'; interface TriggerCheckboxOptions { @@ -95,7 +94,7 @@ export class CheckboxGroupTester { throw new Error('Checkbox provided is not in the checkbox group.'); } - if (!nodeContains(this.checkboxgroup, document.activeElement)) { + if (!this.checkboxgroup.matches(':focus-within')) { act(() => checkboxes[0].focus()); } diff --git a/packages/@react-aria/test-utils/src/combobox.ts b/packages/@react-aria/test-utils/src/combobox.ts index 3dfb46c3946..aa6e09fc8f0 100644 --- a/packages/@react-aria/test-utils/src/combobox.ts +++ b/packages/@react-aria/test-utils/src/combobox.ts @@ -12,7 +12,6 @@ import {act, waitFor, within} from '@testing-library/react'; import {ComboBoxTesterOpts, UserOpts} from './types'; -import {nodeContains} from '@react-aria/utils'; interface ComboBoxOpenOpts { /** @@ -177,7 +176,7 @@ export class ComboBoxTester { if (option.getAttribute('href') == null) { await waitFor(() => { - if (nodeContains(document, listbox)) { + if (listbox.isConnected) { throw new Error('Expected listbox element to not be in the document after selecting an option'); } else { return true; @@ -199,7 +198,7 @@ export class ComboBoxTester { await this.user.keyboard('[Escape]'); await waitFor(() => { - if (nodeContains(document, listbox)) { + if (listbox.isConnected) { throw new Error('Expected listbox element to not be in the document after selecting an option'); } else { return true; diff --git a/packages/@react-aria/test-utils/src/dialog.ts b/packages/@react-aria/test-utils/src/dialog.ts index 10e3b22d0f5..a8c3d2278e5 100644 --- a/packages/@react-aria/test-utils/src/dialog.ts +++ b/packages/@react-aria/test-utils/src/dialog.ts @@ -12,7 +12,6 @@ import {act, waitFor, within} from '@testing-library/react'; import {DialogTesterOpts, UserOpts} from './types'; -import {nodeContains} from '@react-aria/utils'; interface DialogOpenOpts { /** @@ -97,7 +96,7 @@ export class DialogTester { } }); - if (dialog && document.activeElement !== this._trigger && nodeContains(dialog, document.activeElement)) { + if (dialog && document.activeElement !== this._trigger && dialog.matches(':focus-within')) { this._dialog = dialog; } else { throw new Error('New modal dialog doesnt contain the active element OR the active element is still the trigger. Uncertain if the proper modal dialog was found'); @@ -114,7 +113,7 @@ export class DialogTester { if (dialog) { await this.user.keyboard('[Escape]'); await waitFor(() => { - if (nodeContains(document, dialog)) { + if (dialog.isConnected) { throw new Error('Expected the dialog to not be in the document after closing it.'); } else { this._dialog = undefined; @@ -139,6 +138,6 @@ export class DialogTester { * Returns the dialog if present. */ get dialog(): HTMLElement | null { - return this._dialog && nodeContains(document, this._dialog) ? this._dialog : null; + return this._dialog && this._dialog.isConnected ? this._dialog : null; } } diff --git a/packages/@react-aria/test-utils/src/gridlist.ts b/packages/@react-aria/test-utils/src/gridlist.ts index ebf8af799b6..a348da57cd9 100644 --- a/packages/@react-aria/test-utils/src/gridlist.ts +++ b/packages/@react-aria/test-utils/src/gridlist.ts @@ -13,7 +13,6 @@ import {act, within} from '@testing-library/react'; import {getAltKey, getMetaKey, pressElement, triggerLongPress} from './events'; import {GridListTesterOpts, GridRowActionOpts, ToggleGridRowOpts, UserOpts} from './types'; -import {nodeContains} from '@react-aria/utils'; interface GridListToggleRowOpts extends ToggleGridRowOpts {} interface GridListRowActionOpts extends GridRowActionOpts {} @@ -67,13 +66,13 @@ export class GridListTester { throw new Error('Option provided is not in the gridlist'); } - if (document.activeElement !== this._gridlist && !nodeContains(this._gridlist, document.activeElement)) { + if (document.activeElement !== this._gridlist && !this._gridlist.matches(':focus-within')) { act(() => this._gridlist.focus()); } if (document.activeElement === this._gridlist) { await this.user.keyboard(`${selectionOnNav === 'none' ? `[${altKey}>]` : ''}[ArrowDown]${selectionOnNav === 'none' ? `[/${altKey}]` : ''}`); - } else if (nodeContains(this._gridlist, document.activeElement) && document.activeElement!.getAttribute('role') !== 'row') { + } else if (this._gridlist.matches(':focus-within') && document.activeElement!.getAttribute('role') !== 'row') { do { await this.user.keyboard('[ArrowLeft]'); } while (document.activeElement!.getAttribute('role') !== 'row'); diff --git a/packages/@react-aria/test-utils/src/listbox.ts b/packages/@react-aria/test-utils/src/listbox.ts index d8fcab4a772..240036151af 100644 --- a/packages/@react-aria/test-utils/src/listbox.ts +++ b/packages/@react-aria/test-utils/src/listbox.ts @@ -13,7 +13,6 @@ import {act, within} from '@testing-library/react'; import {getAltKey, getMetaKey, pressElement, triggerLongPress} from './events'; import {ListBoxTesterOpts, UserOpts} from './types'; -import {nodeContains} from '@react-aria/utils'; interface ListBoxToggleOptionOpts { /** @@ -104,7 +103,7 @@ export class ListBoxTester { throw new Error('Option provided is not in the listbox'); } - if (document.activeElement !== this._listbox && !nodeContains(this._listbox, document.activeElement)) { + if (document.activeElement !== this._listbox && !this._listbox.matches(':focus-within')) { act(() => this._listbox.focus()); await this.user.keyboard(`${selectionOnNav === 'none' ? `[${altKey}>]` : ''}[ArrowDown]${selectionOnNav === 'none' ? `[/${altKey}]` : ''}`); } diff --git a/packages/@react-aria/test-utils/src/menu.ts b/packages/@react-aria/test-utils/src/menu.ts index f3dcdd3bdd0..7724e0429e1 100644 --- a/packages/@react-aria/test-utils/src/menu.ts +++ b/packages/@react-aria/test-utils/src/menu.ts @@ -216,7 +216,7 @@ export class MenuTester { return; } - if (document.activeElement !== menu && !nodeContains(menu, document.activeElement)) { + if (document.activeElement !== menu && !menu.matches(':focus-within')) { act(() => menu.focus()); } @@ -263,7 +263,7 @@ export class MenuTester { // close. In React 16, focus actually makes it all the way to the root menu's submenu trigger so we need check the root menu if (this._isSubmenu) { await waitFor(() => { - if (document.activeElement === this.trigger || nodeContains(this._rootMenu, document.activeElement)) { + if (document.activeElement === this.trigger || (this._rootMenu && this._rootMenu.matches(':focus-within'))) { throw new Error('Expected focus after selecting an submenu option to move away from the original submenu trigger.'); } else { return true; @@ -379,7 +379,7 @@ export class MenuTester { } }); - if (nodeContains(document, menu)) { + if (menu.isConnected) { throw new Error('Expected the menu to not be in the document after closing it.'); } } diff --git a/packages/@react-aria/test-utils/src/radiogroup.ts b/packages/@react-aria/test-utils/src/radiogroup.ts index bdcbfce9730..21979fb8add 100644 --- a/packages/@react-aria/test-utils/src/radiogroup.ts +++ b/packages/@react-aria/test-utils/src/radiogroup.ts @@ -12,7 +12,6 @@ import {act, within} from '@testing-library/react'; import {Direction, Orientation, RadioGroupTesterOpts, UserOpts} from './types'; -import {nodeContains} from '@react-aria/utils'; import {pressElement} from './events'; interface TriggerRadioOptions { @@ -95,7 +94,7 @@ export class RadioGroupTester { throw new Error('Radio provided is not in the radio group.'); } - if (!nodeContains(this.radiogroup, document.activeElement)) { + if (!this.radiogroup.matches(':focus-within')) { let selectedRadio = this.selectedRadio; if (selectedRadio != null) { act(() => selectedRadio.focus()); diff --git a/packages/@react-aria/test-utils/src/select.ts b/packages/@react-aria/test-utils/src/select.ts index 56a9372dfe5..2e14bf3ca57 100644 --- a/packages/@react-aria/test-utils/src/select.ts +++ b/packages/@react-aria/test-utils/src/select.ts @@ -11,7 +11,6 @@ */ import {act, waitFor, within} from '@testing-library/react'; -import {nodeContains} from '@react-aria/utils'; import {SelectTesterOpts, UserOpts} from './types'; interface SelectOpenOpts { @@ -111,7 +110,7 @@ export class SelectTester { } }); - if (listbox && nodeContains(document, listbox)) { + if (listbox && listbox.isConnected) { throw new Error('Expected the select element listbox to not be in the document after closing the dropdown.'); } } @@ -192,7 +191,7 @@ export class SelectTester { return; } - if (document.activeElement !== listbox && !nodeContains(listbox, document.activeElement)) { + if (document.activeElement !== listbox && !listbox.matches(':focus-within')) { act(() => listbox.focus()); } await this.keyboardNavigateToOption({option}); @@ -215,7 +214,7 @@ export class SelectTester { } }); - if (nodeContains(document, listbox)) { + if (listbox.isConnected) { throw new Error('Expected select element listbox to not be in the document after selecting an option'); } } diff --git a/packages/@react-aria/test-utils/src/table.ts b/packages/@react-aria/test-utils/src/table.ts index 24071fd3b90..289370198db 100644 --- a/packages/@react-aria/test-utils/src/table.ts +++ b/packages/@react-aria/test-utils/src/table.ts @@ -13,7 +13,6 @@ import {act, waitFor, within} from '@testing-library/react'; import {getAltKey, getMetaKey, pressElement, triggerLongPress} from './events'; import {GridRowActionOpts, TableTesterOpts, ToggleGridRowOpts, UserOpts} from './types'; -import {nodeContains} from '@react-aria/utils'; interface TableToggleRowOpts extends ToggleGridRowOpts {} interface TableToggleSortOpts { @@ -66,7 +65,7 @@ export class TableTester { } // Move focus into the table - if (document.activeElement !== this._table && !nodeContains(this._table, document.activeElement)) { + if (document.activeElement !== this._table && !this._table.matches(':focus-within')) { act(() => this._table.focus()); } @@ -75,14 +74,14 @@ export class TableTester { } // If focus is currently somewhere in the first row group (aka on a column), we want to keyboard navigate downwards till we reach the rows - if (nodeContains(this.rowGroups[0], document.activeElement)) { + if (this.rowGroups[0].matches(':focus-within')) { do { await this.user.keyboard('[ArrowDown]'); - } while (!nodeContains(this.rowGroups[1], document.activeElement)); + } while (!this.rowGroups[1].matches(':focus-within')); } // Move focus onto the row itself - if (nodeContains(this.rowGroups[1], document.activeElement) && document.activeElement!.getAttribute('role') !== 'row') { + if (this.rowGroups[1].matches(':focus-within') && document.activeElement!.getAttribute('role') !== 'row') { do { await this.user.keyboard('[ArrowLeft]'); } while (document.activeElement!.getAttribute('role') !== 'row'); @@ -223,7 +222,7 @@ export class TableTester { } await waitFor(() => { - if (nodeContains(document, menu)) { + if (menu.isConnected) { throw new Error('Expected table column menu listbox to not be in the document after selecting an option'); } else { return true; @@ -309,7 +308,7 @@ export class TableTester { await pressElement(this.user, within(menu).getAllByRole('menuitem')[action], interactionType); await waitFor(() => { - if (nodeContains(document, menu)) { + if (menu.isConnected) { throw new Error('Expected table column menu listbox to not be in the document after selecting an option'); } else { return true; diff --git a/packages/@react-aria/test-utils/src/tabs.ts b/packages/@react-aria/test-utils/src/tabs.ts index f87a2d9afd2..46bfe860827 100644 --- a/packages/@react-aria/test-utils/src/tabs.ts +++ b/packages/@react-aria/test-utils/src/tabs.ts @@ -12,7 +12,6 @@ import {act, within} from '@testing-library/react'; import {Direction, Orientation, TabsTesterOpts, UserOpts} from './types'; -import {nodeContains} from '@react-aria/utils'; import {pressElement} from './events'; interface TriggerTabOptions { @@ -90,7 +89,7 @@ export class TabsTester { throw new Error('Tab provided is not in the tablist'); } - if (!nodeContains(this._tablist, document.activeElement)) { + if (!this._tablist.matches(':focus-within')) { let selectedTab = this.selectedTab; if (selectedTab != null) { act(() => selectedTab.focus()); @@ -143,7 +142,7 @@ export class TabsTester { } if (interactionType === 'keyboard') { - if (document.activeElement !== this._tablist && !nodeContains(this._tablist, document.activeElement)) { + if (document.activeElement !== this._tablist && !this._tablist.matches(':focus-within')) { act(() => this._tablist.focus()); } diff --git a/packages/@react-aria/test-utils/src/tree.ts b/packages/@react-aria/test-utils/src/tree.ts index 28a566d59ec..721dfb252f4 100644 --- a/packages/@react-aria/test-utils/src/tree.ts +++ b/packages/@react-aria/test-utils/src/tree.ts @@ -13,7 +13,6 @@ import {act, within} from '@testing-library/react'; import {BaseGridRowInteractionOpts, GridRowActionOpts, ToggleGridRowOpts, TreeTesterOpts, UserOpts} from './types'; import {getAltKey, getMetaKey, pressElement, triggerLongPress} from './events'; -import {nodeContains} from '@react-aria/utils'; interface TreeToggleExpansionOpts extends BaseGridRowInteractionOpts {} interface TreeToggleRowOpts extends ToggleGridRowOpts {} @@ -74,13 +73,13 @@ export class TreeTester { throw new Error('Option provided is not in the tree'); } - if (document.activeElement !== this._tree && !nodeContains(this._tree, document.activeElement)) { + if (document.activeElement !== this._tree && !this._tree.matches(':focus-within')) { act(() => this._tree.focus()); } if (document.activeElement === this.tree) { await this.user.keyboard(`${selectionOnNav === 'none' ? `[${altKey}>]` : ''}[ArrowDown]${selectionOnNav === 'none' ? `[/${altKey}]` : ''}`); - } else if (nodeContains(this._tree, document.activeElement) && document.activeElement!.getAttribute('role') !== 'row') { + } else if (this._tree.matches(':focus-within') && document.activeElement!.getAttribute('role') !== 'row') { do { await this.user.keyboard('[ArrowLeft]'); } while (document.activeElement!.getAttribute('role') !== 'row'); @@ -179,7 +178,7 @@ export class TreeTester { row, interactionType = this._interactionType } = opts; - if (!nodeContains(this.tree, document.activeElement)) { + if (!this.tree.matches(':focus-within')) { await act(async () => { this.tree.focus(); }); diff --git a/packages/@react-aria/utils/src/scrollIntoView.ts b/packages/@react-aria/utils/src/scrollIntoView.ts index 76a1e6316cc..18e0d499793 100644 --- a/packages/@react-aria/utils/src/scrollIntoView.ts +++ b/packages/@react-aria/utils/src/scrollIntoView.ts @@ -11,7 +11,6 @@ */ import {getScrollParents} from './getScrollParents'; -import {nodeContains} from './shadowdom/DOMFunctions'; interface ScrollIntoViewportOpts { /** The optional containing element of the target to be centered in the viewport. */ @@ -76,13 +75,13 @@ export function scrollIntoView(scrollView: HTMLElement, element: HTMLElement): v function relativeOffset(ancestor: HTMLElement, child: HTMLElement, axis: 'left'|'top') { let childRect = child.getBoundingClientRect(); let ancestorRect = ancestor.getBoundingClientRect(); - - let viewportOffset = axis === 'left' - ? childRect.left - ancestorRect.left + + let viewportOffset = axis === 'left' + ? childRect.left - ancestorRect.left : childRect.top - ancestorRect.top; - let scrollAdjustment = axis === 'left' - ? ancestor.scrollLeft + let scrollAdjustment = axis === 'left' + ? ancestor.scrollLeft : ancestor.scrollTop; return viewportOffset + scrollAdjustment; @@ -94,7 +93,7 @@ function relativeOffset(ancestor: HTMLElement, child: HTMLElement, axis: 'left'| * the body (e.g. targetElement is in a popover), this will only scroll the scroll parents of the targetElement up to but not including the body itself. */ export function scrollIntoViewport(targetElement: Element | null, opts?: ScrollIntoViewportOpts): void { - if (targetElement && nodeContains(document, targetElement)) { + if (targetElement && targetElement.isConnected) { let root = document.scrollingElement || document.documentElement; let isScrollPrevented = window.getComputedStyle(root).overflow === 'hidden'; // If scrolling is not currently prevented then we aren’t in a overlay nor is a overlay open, just use element.scrollIntoView to bring the element into view diff --git a/packages/@react-spectrum/menu/src/ContextualHelpTrigger.tsx b/packages/@react-spectrum/menu/src/ContextualHelpTrigger.tsx index 34128d67d45..46dca0194b9 100644 --- a/packages/@react-spectrum/menu/src/ContextualHelpTrigger.tsx +++ b/packages/@react-spectrum/menu/src/ContextualHelpTrigger.tsx @@ -99,7 +99,7 @@ function ContextualHelpTrigger(props: InternalMenuDialogTriggerProps): ReactElem setTraySubmenuAnimation('spectrum-TraySubmenu-exit'); setTimeout(() => { submenuTriggerState.close(); - if (parentMenuRef.current && !nodeContains(parentMenuRef.current, document.activeElement)) { + if (parentMenuRef.current && !parentMenuRef.current.matches(':focus-within')) { parentMenuRef.current.focus(); } }, 220); // Matches transition duration diff --git a/packages/@react-spectrum/menu/src/SubmenuTrigger.tsx b/packages/@react-spectrum/menu/src/SubmenuTrigger.tsx index 061e40d55b7..b27a563b4b3 100644 --- a/packages/@react-spectrum/menu/src/SubmenuTrigger.tsx +++ b/packages/@react-spectrum/menu/src/SubmenuTrigger.tsx @@ -13,7 +13,7 @@ import {classNames, useIsMobileDevice} from '@react-spectrum/utils'; import {Key} from '@react-types/shared'; import {MenuContext, SubmenuTriggerContext, useMenuStateContext} from './context'; -import {mergeProps, nodeContains} from '@react-aria/utils'; +import {mergeProps} from '@react-aria/utils'; import {Popover} from './Popover'; import React, {type JSX, ReactElement, useRef} from 'react'; import ReactDOM from 'react-dom'; @@ -49,7 +49,7 @@ function SubmenuTrigger(props: SubmenuTriggerProps) { let isMobile = useIsMobileDevice(); let onBackButtonPress = () => { submenuTriggerState.close(); - if (parentMenuRef.current && !nodeContains(parentMenuRef.current, document.activeElement)) { + if (parentMenuRef.current && !parentMenuRef.current.matches(':focus-within')) { parentMenuRef.current.focus(); } }; diff --git a/packages/@react-spectrum/menu/src/useOverlayPosition.ts b/packages/@react-spectrum/menu/src/useOverlayPosition.ts index ee218a5b7ca..53c29e5da12 100644 --- a/packages/@react-spectrum/menu/src/useOverlayPosition.ts +++ b/packages/@react-spectrum/menu/src/useOverlayPosition.ts @@ -12,10 +12,10 @@ import {calculatePosition, getRect, PositionResult} from './calculatePosition'; import {DOMAttributes, RefObject} from '@react-types/shared'; -import {nodeContains, useLayoutEffect, useResizeObserver} from '@react-aria/utils'; import {Placement, PlacementAxis, PositionProps} from '@react-types/overlays'; import {useCallback, useEffect, useRef, useState} from 'react'; import {useCloseOnScroll} from './useCloseOnScroll'; +import {useLayoutEffect, useResizeObserver} from '@react-aria/utils'; import {useLocale} from '@react-aria/i18n'; export interface AriaPositionProps extends PositionProps { @@ -154,7 +154,7 @@ export function useOverlayPosition(props: AriaPositionProps): PositionAria { // so it can be restored after repositioning. This way if the overlay height // changes, the focused element appears to stay in the same position. let anchor: ScrollAnchor | null = null; - if (scrollRef.current && nodeContains(scrollRef.current, document.activeElement)) { + if (scrollRef.current && scrollRef.current.matches(':focus-within')) { let anchorRect = document.activeElement?.getBoundingClientRect(); let scrollRect = scrollRef.current.getBoundingClientRect(); // Anchor from the top if the offset is in the top half of the scrollable element, diff --git a/packages/@react-spectrum/s2/src/TableView.tsx b/packages/@react-spectrum/s2/src/TableView.tsx index b5e66fd84a2..bcf34a444f5 100644 --- a/packages/@react-spectrum/s2/src/TableView.tsx +++ b/packages/@react-spectrum/s2/src/TableView.tsx @@ -1301,7 +1301,7 @@ function EditableCellInner(props: EditableCellProps & {isFocusVisible: boolean, onOpenChange={setIsOpen} ref={popoverRef} shouldCloseOnInteractOutside={() => { - if (!nodeContains(popoverRef.current, document.activeElement)) { + if (!(!!popoverRef.current && popoverRef.current.matches(':focus-within'))) { return false; } formRef.current?.requestSubmit(); diff --git a/packages/@react-spectrum/table/src/TableViewBase.tsx b/packages/@react-spectrum/table/src/TableViewBase.tsx index 443303f5fb2..2ec2967f708 100644 --- a/packages/@react-spectrum/table/src/TableViewBase.tsx +++ b/packages/@react-spectrum/table/src/TableViewBase.tsx @@ -33,7 +33,7 @@ import {GridNode} from '@react-types/grid'; import {InsertionIndicator} from './InsertionIndicator'; // @ts-ignore import intlMessages from '../intl/*.json'; -import {isAndroid, mergeProps, nodeContains, scrollIntoView, scrollIntoViewport, useLoadMore} from '@react-aria/utils'; +import {isAndroid, mergeProps, scrollIntoView, scrollIntoViewport, useLoadMore} from '@react-aria/utils'; import {Item, Menu, MenuTrigger} from '@react-spectrum/menu'; import {LayoutInfo, Rect, ReusableView, useVirtualizerState} from '@react-stately/virtualizer'; import {layoutInfoToStyle, ScrollView, setScrollLeft, VirtualizerItem} from '@react-aria/virtualizer'; @@ -606,7 +606,7 @@ function TableVirtualizer(props: TableVirtualizerProps) { // only that it changes in a resize, and when that happens, we want to sync the body to the // header scroll position useEffect(() => { - if (getInteractionModality() === 'keyboard' && headerRef.current && nodeContains(headerRef.current, document.activeElement) && bodyRef.current) { + if (getInteractionModality() === 'keyboard' && headerRef.current && headerRef.current.matches(':focus-within') && bodyRef.current) { scrollIntoView(headerRef.current, document.activeElement as HTMLElement); scrollIntoViewport(document.activeElement, {containingElement: domRef.current}); bodyRef.current.scrollLeft = headerRef.current.scrollLeft; diff --git a/packages/dev/eslint-plugin-rsp-rules/index.js b/packages/dev/eslint-plugin-rsp-rules/index.js index 4bb1265f0fc..aba60c2f759 100644 --- a/packages/dev/eslint-plugin-rsp-rules/index.js +++ b/packages/dev/eslint-plugin-rsp-rules/index.js @@ -11,6 +11,7 @@ */ import actEventsTest from './rules/act-events-test.js'; +import fasterNodeContains from './rules/faster-node-contains.js'; import noGetByRoleToThrow from './rules/no-getByRole-toThrow.js'; import noNonShadowContains from './rules/no-non-shadow-contains.js'; import noReactKey from './rules/no-react-key.js'; @@ -21,7 +22,8 @@ const rules = { 'no-getByRole-toThrow': noGetByRoleToThrow, 'no-react-key': noReactKey, 'sort-imports': sortImports, - 'no-non-shadow-contains': noNonShadowContains + 'no-non-shadow-contains': noNonShadowContains, + 'faster-node-contains': fasterNodeContains }; const meta = { diff --git a/packages/dev/eslint-plugin-rsp-rules/rules/faster-node-contains.js b/packages/dev/eslint-plugin-rsp-rules/rules/faster-node-contains.js new file mode 100644 index 00000000000..7b55d4be05e --- /dev/null +++ b/packages/dev/eslint-plugin-rsp-rules/rules/faster-node-contains.js @@ -0,0 +1,82 @@ +/* + * Copyright 2023 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +const plugin = { + meta: { + type: 'suggestion', + docs: { + description: 'Optimize nodeContains calls by using faster alternatives like :focus-within and isConnected', + recommended: true + }, + fixable: 'code', + messages: { + useFocusWithin: 'Use element.matches(\':focus-within\') instead of nodeContains for activeElement checks.', + useIsConnected: 'Use node.isConnected instead of nodeContains for document contains checks.' + } + }, + create: (context) => { + return { + // Detect nodeContains() function calls + CallExpression(node) { + if (node.callee.type === 'Identifier' && node.callee.name === 'nodeContains') { + const sourceCode = context.sourceCode; + + // nodeContains should have exactly 2 arguments + if (node.arguments.length === 2) { + const firstArg = node.arguments[0]; + const secondArg = node.arguments[1]; + + if (isDocumentActiveElement(secondArg)) { + // Case 1: Check if second argument is document.activeElement + const elementText = sourceCode.getText(firstArg); + + context.report({ + node, + messageId: 'useFocusWithin', + fix: (fixer) => { + return fixer.replaceText(node, `${elementText}.matches(':focus-within')`); + } + }); + } else if (isDocument(firstArg)) { + // Case 2: Check if first argument is document + const nodeText = sourceCode.getText(secondArg); + + context.report({ + node, + messageId: 'useIsConnected', + fix: (fixer) => { + return fixer.replaceText(node, `${nodeText}.isConnected`); + } + }); + } + } + } + } + }; + } +}; + +function isDocumentActiveElement(node) { + return ( + node.type === 'MemberExpression' && + node.object.type === 'Identifier' && + node.object.name === 'document' && + node.property.type === 'Identifier' && + node.property.name === 'activeElement' + ); +} + +function isDocument(node) { + return node.type === 'Identifier' && node.name === 'document'; +} + +export default plugin; diff --git a/packages/dev/eslint-plugin-rsp-rules/test/faster-node-contains.test-lint.js b/packages/dev/eslint-plugin-rsp-rules/test/faster-node-contains.test-lint.js new file mode 100644 index 00000000000..709ff47148f --- /dev/null +++ b/packages/dev/eslint-plugin-rsp-rules/test/faster-node-contains.test-lint.js @@ -0,0 +1,63 @@ +/* + * Copyright 2023 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import fasterNodeContainsRule from '../rules/faster-node-contains.js'; +import {RuleTester} from 'eslint'; + +const ruleTester = new RuleTester({ + languageOptions: { + ecmaVersion: 2015, + sourceType: 'module' + } +}); + +// Throws error if the tests in ruleTester.run() do not pass +ruleTester.run( + 'faster-node-contains', + fasterNodeContainsRule, + { + // 'valid' checks cases that should pass + valid: [ + { + code: ` +if (nodeContains(element, other)) { + console.log('contained'); +}` + } + ], + // 'invalid' checks cases that should not pass + invalid: [ + { + code: ` +if (nodeContains(element, document.activeElement)) { + console.log('contained'); +}`, + output: ` +if (element.matches(':focus-within')) { + console.log('contained'); +}`, + errors: 1 + }, + { + code: ` +if (nodeContains(document, other)) { + console.log('connected'); +}`, + output: ` +if (other.isConnected) { + console.log('connected'); +}`, + errors: 1 + } + ] + } +); diff --git a/packages/react-aria-components/src/Popover.tsx b/packages/react-aria-components/src/Popover.tsx index 144f8515b44..156249c0502 100644 --- a/packages/react-aria-components/src/Popover.tsx +++ b/packages/react-aria-components/src/Popover.tsx @@ -21,7 +21,7 @@ import { useContextProps, useRenderProps } from './utils'; -import {filterDOMProps, mergeProps, nodeContains, useEnterAnimation, useExitAnimation, useLayoutEffect} from '@react-aria/utils'; +import {filterDOMProps, mergeProps, useEnterAnimation, useExitAnimation, useLayoutEffect} from '@react-aria/utils'; import {focusSafely} from '@react-aria/interactions'; import {OverlayArrowContext} from './OverlayArrow'; import {OverlayTriggerProps, OverlayTriggerState, useOverlayTriggerState} from 'react-stately'; @@ -199,7 +199,7 @@ function PopoverInner({state, isExiting, UNSTABLE_portalContainer, clearContexts // Focus the popover itself on mount, unless a child element is already focused. // Skip this for submenus since hovering a submenutrigger should keep focus on the trigger useEffect(() => { - if (isDialog && props.trigger !== 'SubmenuTrigger' && ref.current && !nodeContains(ref.current, document.activeElement)) { + if (isDialog && props.trigger !== 'SubmenuTrigger' && ref.current && !ref.current.matches(':focus-within')) { focusSafely(ref.current); } }, [isDialog, ref, props.trigger]); diff --git a/yarn.lock b/yarn.lock index 955e24b9555..1edc42e384b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -22926,10 +22926,17 @@ __metadata: languageName: node linkType: hard -"nwsapi@npm:^2.2.2": - version: 2.2.4 - resolution: "nwsapi@npm:2.2.4" - checksum: 10c0/d6d297feed79e0fc984e7a9588fe20255a493c0645f35a3419b04662535311cc38126fa12d8e1a5ae8f18cc0de0ec0d36680e03fab44050f7117e0503ecc9c38 +"nwsapi@npm:2.2.23": + version: 2.2.23 + resolution: "nwsapi@npm:2.2.23" + checksum: 10c0/e44bfc9246baf659581206ed716d291a1905185247795fb8a302cb09315c943a31023b4ac4d026a5eaf32b2def51d77b3d0f9ebf4f3d35f70e105fcb6447c76e + languageName: node + linkType: hard + +"nwsapi@patch:nwsapi@npm%3A2.2.23#~/.yarn/patches/nwsapi-npm-2.2.23-aa3710d724.patch": + version: 2.2.23 + resolution: "nwsapi@patch:nwsapi@npm%3A2.2.23#~/.yarn/patches/nwsapi-npm-2.2.23-aa3710d724.patch::version=2.2.23&hash=6459c2" + checksum: 10c0/aa7549179845c18b5c76ea873ca71d62e47d51bdc9a55f52f987a5bc0aed2fa45b87c4108d5b38fe9ab997c5c23e4fd01fcba8a2db908cb2c4942199620ae5b6 languageName: node linkType: hard From 8bf4c46ac80abb8d9fce3c94b85e40749a1b53aa Mon Sep 17 00:00:00 2001 From: Robert Snow Date: Wed, 28 Jan 2026 15:20:12 +1100 Subject: [PATCH 2/4] update patch to be shadow dom safe --- .yarn/patches/nwsapi-npm-2.2.23-aa3710d724.patch | 14 ++++++++++---- yarn.lock | 4 ++-- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/.yarn/patches/nwsapi-npm-2.2.23-aa3710d724.patch b/.yarn/patches/nwsapi-npm-2.2.23-aa3710d724.patch index 5aee0935007..d0f7fed2a48 100644 --- a/.yarn/patches/nwsapi-npm-2.2.23-aa3710d724.patch +++ b/.yarn/patches/nwsapi-npm-2.2.23-aa3710d724.patch @@ -1,18 +1,24 @@ diff --git a/src/nwsapi.js b/src/nwsapi.js -index 872026b4ab3462f3c2411bc564dd428bd3165323..f71ae753743c9d6155e2f522c1ac3c7fdbb05c32 100644 +index 872026b4ab3462f3c2411bc564dd428bd3165323..748f32f16595e62ddf40446e361df6555cfb5495 100644 --- a/src/nwsapi.js +++ b/src/nwsapi.js -@@ -1241,9 +1241,10 @@ +@@ -1241,9 +1241,16 @@ 'if((e===n||e.autofocus)){' + source + '}'; break; case 'focus-within': - source = 'if(n=s.isFocusable(e)){' + - 'if(n!==e){while(n){n=n.parentElement;if(n===e)break;}}}' + - 'if((n===e||n.autofocus)){' + source + '}'; -+ // Check if e contains the activeElement by walking up from activeElement ++ // Shadow DOM safe: drill down through shadowRoot.activeElement, then walk up including shadow boundaries + source = 'if(s.doc.hasFocus()){' + + 'n=s.doc.activeElement;' + -+ 'while(n){if(n===e){' + source + 'break;}n=n.parentElement;}}'; ++ // Drill down through shadow roots to find the actual focused element ++ 'while(n&&n.shadowRoot&&n.shadowRoot.activeElement){n=n.shadowRoot.activeElement;}' + ++ // Walk up the tree, crossing shadow boundaries via getRootNode().host ++ 'while(n){' + ++ 'if(n===e){' + source + 'break;}' + ++ 'n=n.parentElement||(n.getRootNode&&n.getRootNode().host);' + ++ '}}'; break; default: emit('\'' + expression + '\'' + qsInvalid); diff --git a/yarn.lock b/yarn.lock index 1edc42e384b..e80bd3b40c8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -22935,8 +22935,8 @@ __metadata: "nwsapi@patch:nwsapi@npm%3A2.2.23#~/.yarn/patches/nwsapi-npm-2.2.23-aa3710d724.patch": version: 2.2.23 - resolution: "nwsapi@patch:nwsapi@npm%3A2.2.23#~/.yarn/patches/nwsapi-npm-2.2.23-aa3710d724.patch::version=2.2.23&hash=6459c2" - checksum: 10c0/aa7549179845c18b5c76ea873ca71d62e47d51bdc9a55f52f987a5bc0aed2fa45b87c4108d5b38fe9ab997c5c23e4fd01fcba8a2db908cb2c4942199620ae5b6 + resolution: "nwsapi@patch:nwsapi@npm%3A2.2.23#~/.yarn/patches/nwsapi-npm-2.2.23-aa3710d724.patch::version=2.2.23&hash=3e65e8" + checksum: 10c0/1fb540bdc6ef46b13b1777b8b0777db0961fb857e4e8038653a25673131953bc3128cdcb8611edcc6d7a958f57b7a8714832273a8845ddd3203bbd04bf17fc0c languageName: node linkType: hard From 88f3aeff567f478f9804a71880a9b9911b1d6ff8 Mon Sep 17 00:00:00 2001 From: Robert Snow Date: Fri, 6 Feb 2026 08:19:18 +1100 Subject: [PATCH 3/4] update to more consistently fast isFocusWithin --- .../nwsapi-npm-2.2.23-aa3710d724.patch | 24 - eslint.config.mjs | 10 + focus-within-performance-test.html | 427 ++++++++++++++++++ isconnected-performance-test.html | 406 +++++++++++++++++ package.json | 3 +- .../calendar/src/useRangeCalendar.ts | 4 +- packages/@react-aria/dialog/src/useDialog.ts | 4 +- packages/@react-aria/grid/src/useGridCell.ts | 6 +- .../gridlist/src/useGridListItem.ts | 4 +- .../@react-aria/menu/src/useSubmenuTrigger.ts | 4 +- .../overlays/src/useOverlayPosition.ts | 4 +- .../selection/src/useSelectableCollection.ts | 6 +- .../test-utils/src/checkboxgroup.ts | 3 +- .../@react-aria/test-utils/src/combobox.ts | 5 +- packages/@react-aria/test-utils/src/dialog.ts | 7 +- .../@react-aria/test-utils/src/gridlist.ts | 5 +- .../@react-aria/test-utils/src/listbox.ts | 3 +- packages/@react-aria/test-utils/src/menu.ts | 6 +- .../@react-aria/test-utils/src/radiogroup.ts | 3 +- packages/@react-aria/test-utils/src/select.ts | 7 +- packages/@react-aria/test-utils/src/table.ts | 13 +- packages/@react-aria/test-utils/src/tabs.ts | 5 +- packages/@react-aria/test-utils/src/tree.ts | 7 +- packages/@react-aria/utils/src/index.ts | 2 +- .../utils/src/shadowdom/DOMFunctions.ts | 23 +- .../menu/src/ContextualHelpTrigger.tsx | 4 +- .../menu/src/SubmenuTrigger.tsx | 4 +- .../menu/src/useOverlayPosition.ts | 4 +- packages/@react-spectrum/s2/src/TableView.tsx | 4 +- .../table/src/TableViewBase.tsx | 4 +- .../rules/faster-node-contains.js | 63 ++- .../test/faster-node-contains.test-lint.js | 31 +- .../react-aria-components/src/Popover.tsx | 4 +- yarn.lock | 15 +- 34 files changed, 1027 insertions(+), 97 deletions(-) delete mode 100644 .yarn/patches/nwsapi-npm-2.2.23-aa3710d724.patch create mode 100644 focus-within-performance-test.html create mode 100644 isconnected-performance-test.html diff --git a/.yarn/patches/nwsapi-npm-2.2.23-aa3710d724.patch b/.yarn/patches/nwsapi-npm-2.2.23-aa3710d724.patch deleted file mode 100644 index d0f7fed2a48..00000000000 --- a/.yarn/patches/nwsapi-npm-2.2.23-aa3710d724.patch +++ /dev/null @@ -1,24 +0,0 @@ -diff --git a/src/nwsapi.js b/src/nwsapi.js -index 872026b4ab3462f3c2411bc564dd428bd3165323..748f32f16595e62ddf40446e361df6555cfb5495 100644 ---- a/src/nwsapi.js -+++ b/src/nwsapi.js -@@ -1241,9 +1241,16 @@ - 'if((e===n||e.autofocus)){' + source + '}'; - break; - case 'focus-within': -- source = 'if(n=s.isFocusable(e)){' + -- 'if(n!==e){while(n){n=n.parentElement;if(n===e)break;}}}' + -- 'if((n===e||n.autofocus)){' + source + '}'; -+ // Shadow DOM safe: drill down through shadowRoot.activeElement, then walk up including shadow boundaries -+ source = 'if(s.doc.hasFocus()){' + -+ 'n=s.doc.activeElement;' + -+ // Drill down through shadow roots to find the actual focused element -+ 'while(n&&n.shadowRoot&&n.shadowRoot.activeElement){n=n.shadowRoot.activeElement;}' + -+ // Walk up the tree, crossing shadow boundaries via getRootNode().host -+ 'while(n){' + -+ 'if(n===e){' + source + 'break;}' + -+ 'n=n.parentElement||(n.getRootNode&&n.getRootNode().host);' + -+ '}}'; - break; - default: - emit('\'' + expression + '\'' + qsInvalid); diff --git a/eslint.config.mjs b/eslint.config.mjs index 1269ca11617..9e1e30adbee 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -431,6 +431,7 @@ export default [{ "rsp-rules/act-events-test": ERROR, "rsp-rules/no-getByRole-toThrow": ERROR, "rsp-rules/no-non-shadow-contains": OFF, + "rsp-rules/faster-node-contains": OFF, "rulesdir/imports": OFF, "monorepo/no-internal-import": OFF, "jsdoc/require-jsdoc": OFF @@ -502,6 +503,15 @@ export default [{ message: "Use getOwnerDocument from @react-aria/utils instead.", }], }, +}, { + files: [ + "packages/@react-aria/test-utils/src/**/*.ts", + "packages/@react-aria/test-utils/src/**/*.tsx", + ], + + rules: { + "rsp-rules/faster-node-contains": OFF, + }, }, { files: ["packages/@react-spectrum/s2/**", "packages/dev/s2-docs/**"], diff --git a/focus-within-performance-test.html b/focus-within-performance-test.html new file mode 100644 index 00000000000..7729e84b9df --- /dev/null +++ b/focus-within-performance-test.html @@ -0,0 +1,427 @@ + + + + + + Focus-Within Performance Test + + + +

Focus-Within Performance Test

+

+ This test compares the performance of three methods for checking if a node contains focus: +

+
    +
  • nodeContains(node, document.activeElement) - ShadowDOM-safe contains
  • +
  • node.matches(':focus-within')
  • +
  • isFocusWithin(node) - uses node.getRootNode().activeElement
  • +
+ +
+

Configuration

+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ + +
+ +
+ + + + + + diff --git a/isconnected-performance-test.html b/isconnected-performance-test.html new file mode 100644 index 00000000000..e678a0f59ca --- /dev/null +++ b/isconnected-performance-test.html @@ -0,0 +1,406 @@ + + + + + + isConnected Performance Test + + + +

isConnected Performance Test

+

+ This test compares the performance of document.contains(node) + vs node.isConnected for checking if a node is in the document. +

+ +
+

Configuration

+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ + +
+ + + +
+ + + + + + diff --git a/package.json b/package.json index 8d98d66b2b5..bcf6427197d 100644 --- a/package.json +++ b/package.json @@ -239,8 +239,7 @@ "lightningcss": "1.30.1", "react-server-dom-parcel": "canary", "react-test-renderer": "19.1.0", - "@parcel/packager-react-static": "^2.16.3", - "nwsapi@npm:^2.2.2": "patch:nwsapi@npm%3A2.2.23#~/.yarn/patches/nwsapi-npm-2.2.23-aa3710d724.patch" + "@parcel/packager-react-static": "^2.16.3" }, "@parcel/transformer-css": { "cssModules": { diff --git a/packages/@react-aria/calendar/src/useRangeCalendar.ts b/packages/@react-aria/calendar/src/useRangeCalendar.ts index eba5a6984ba..78bff4f50af 100644 --- a/packages/@react-aria/calendar/src/useRangeCalendar.ts +++ b/packages/@react-aria/calendar/src/useRangeCalendar.ts @@ -13,7 +13,7 @@ import {AriaRangeCalendarProps, DateValue} from '@react-types/calendar'; import {CalendarAria, useCalendarBase} from './useCalendarBase'; import {FocusableElement, RefObject} from '@react-types/shared'; -import {nodeContains, useEvent} from '@react-aria/utils'; +import {isFocusWithin, nodeContains, useEvent} from '@react-aria/utils'; import {RangeCalendarState} from '@react-stately/calendar'; import {useRef} from 'react'; @@ -52,7 +52,7 @@ export function useRangeCalendar(props: AriaRangeCalendarPr let target = e.target as Element; if ( ref.current && - ref.current.matches(':focus-within') && + isFocusWithin(ref.current) && (!nodeContains(ref.current, target) || !target.closest('button, [role="button"]')) ) { state.selectFocusedDate(); diff --git a/packages/@react-aria/dialog/src/useDialog.ts b/packages/@react-aria/dialog/src/useDialog.ts index 238f3c0706a..3094022f80f 100644 --- a/packages/@react-aria/dialog/src/useDialog.ts +++ b/packages/@react-aria/dialog/src/useDialog.ts @@ -12,7 +12,7 @@ import {AriaDialogProps} from '@react-types/dialog'; import {DOMAttributes, FocusableElement, RefObject} from '@react-types/shared'; -import {filterDOMProps, useSlotId} from '@react-aria/utils'; +import {filterDOMProps, isFocusWithin, useSlotId} from '@react-aria/utils'; import {focusSafely} from '@react-aria/interactions'; import {useEffect, useRef} from 'react'; import {useOverlayFocusContain} from '@react-aria/overlays'; @@ -40,7 +40,7 @@ export function useDialog(props: AriaDialogProps, ref: RefObject { - if (ref.current && !ref.current.matches(':focus-within')) { + if (ref.current && !isFocusWithin(ref.current)) { focusSafely(ref.current); // Safari on iOS does not move the VoiceOver cursor to the dialog diff --git a/packages/@react-aria/grid/src/useGridCell.ts b/packages/@react-aria/grid/src/useGridCell.ts index 676a59ea28b..950a8f0e664 100644 --- a/packages/@react-aria/grid/src/useGridCell.ts +++ b/packages/@react-aria/grid/src/useGridCell.ts @@ -13,7 +13,7 @@ import {DOMAttributes, FocusableElement, Key, RefObject} from '@react-types/shared'; import {focusSafely, isFocusVisible} from '@react-aria/interactions'; import {getFocusableTreeWalker} from '@react-aria/focus'; -import {getScrollParent, mergeProps, nodeContains, scrollIntoViewport} from '@react-aria/utils'; +import {getScrollParent, isFocusWithin, mergeProps, nodeContains, scrollIntoViewport} from '@react-aria/utils'; import {GridCollection, GridNode} from '@react-types/grid'; import {gridMap} from './utils'; import {GridState} from '@react-stately/grid'; @@ -75,7 +75,7 @@ export function useGridCell>(props: GridCellProps let treeWalker = getFocusableTreeWalker(ref.current); if (focusMode === 'child') { // If focus is already on a focusable child within the cell, early return so we don't shift focus - if (ref.current.matches(':focus-within') && ref.current !== document.activeElement) { + if (isFocusWithin(ref.current) && ref.current !== document.activeElement) { return; } @@ -90,7 +90,7 @@ export function useGridCell>(props: GridCellProps if ( (keyWhenFocused.current != null && node.key !== keyWhenFocused.current) || - !ref.current.matches(':focus-within') + !isFocusWithin(ref.current) ) { focusSafely(ref.current); } diff --git a/packages/@react-aria/gridlist/src/useGridListItem.ts b/packages/@react-aria/gridlist/src/useGridListItem.ts index 211db1a1cad..9cae838885c 100644 --- a/packages/@react-aria/gridlist/src/useGridListItem.ts +++ b/packages/@react-aria/gridlist/src/useGridListItem.ts @@ -10,7 +10,7 @@ * governing permissions and limitations under the License. */ -import {chain, getScrollParent, mergeProps, nodeContains, scrollIntoViewport, useSlotId, useSyntheticLinkProps} from '@react-aria/utils'; +import {chain, getScrollParent, isFocusWithin, mergeProps, nodeContains, scrollIntoViewport, useSlotId, useSyntheticLinkProps} from '@react-aria/utils'; import {DOMAttributes, FocusableElement, Key, RefObject, Node as RSNode} from '@react-types/shared'; import {focusSafely, getFocusableTreeWalker} from '@react-aria/focus'; import {getRowId, listMap} from './utils'; @@ -79,7 +79,7 @@ export function useGridListItem(props: AriaGridListItemOptions, state: ListSt if ( ref.current !== null && ((keyWhenFocused.current != null && node.key !== keyWhenFocused.current) || - !ref.current.matches(':focus-within')) + !isFocusWithin(ref.current)) ) { focusSafely(ref.current); } diff --git a/packages/@react-aria/menu/src/useSubmenuTrigger.ts b/packages/@react-aria/menu/src/useSubmenuTrigger.ts index 6235e23a3d4..1be44f8a931 100644 --- a/packages/@react-aria/menu/src/useSubmenuTrigger.ts +++ b/packages/@react-aria/menu/src/useSubmenuTrigger.ts @@ -14,7 +14,7 @@ import {AriaMenuItemProps} from './useMenuItem'; import {AriaMenuOptions} from './useMenu'; import type {AriaPopoverProps, OverlayProps} from '@react-aria/overlays'; import {FocusableElement, FocusStrategy, KeyboardEvent, Node, PressEvent, RefObject} from '@react-types/shared'; -import {focusWithoutScrolling, nodeContains, useEvent, useId, useLayoutEffect} from '@react-aria/utils'; +import {focusWithoutScrolling, isFocusWithin, nodeContains, useEvent, useId, useLayoutEffect} from '@react-aria/utils'; import type {SubmenuTriggerState} from '@react-stately/menu'; import {useCallback, useRef} from 'react'; import {useLocale} from '@react-aria/i18n'; @@ -100,7 +100,7 @@ export function useSubmenuTrigger(props: AriaSubmenuTriggerProps, state: Subm let submenuKeyDown = (e: KeyboardEvent) => { // If focus is not within the menu, assume virtual focus is being used. // This means some other input element is also within the popover, so we shouldn't close the menu. - if (!e.currentTarget.matches(':focus-within')) { + if (!isFocusWithin(e.currentTarget)) { return; } diff --git a/packages/@react-aria/overlays/src/useOverlayPosition.ts b/packages/@react-aria/overlays/src/useOverlayPosition.ts index 53c29e5da12..7980f406e3f 100644 --- a/packages/@react-aria/overlays/src/useOverlayPosition.ts +++ b/packages/@react-aria/overlays/src/useOverlayPosition.ts @@ -12,10 +12,10 @@ import {calculatePosition, getRect, PositionResult} from './calculatePosition'; import {DOMAttributes, RefObject} from '@react-types/shared'; +import {isFocusWithin, useLayoutEffect, useResizeObserver} from '@react-aria/utils'; import {Placement, PlacementAxis, PositionProps} from '@react-types/overlays'; import {useCallback, useEffect, useRef, useState} from 'react'; import {useCloseOnScroll} from './useCloseOnScroll'; -import {useLayoutEffect, useResizeObserver} from '@react-aria/utils'; import {useLocale} from '@react-aria/i18n'; export interface AriaPositionProps extends PositionProps { @@ -154,7 +154,7 @@ export function useOverlayPosition(props: AriaPositionProps): PositionAria { // so it can be restored after repositioning. This way if the overlay height // changes, the focused element appears to stay in the same position. let anchor: ScrollAnchor | null = null; - if (scrollRef.current && scrollRef.current.matches(':focus-within')) { + if (scrollRef.current && isFocusWithin(scrollRef.current)) { let anchorRect = document.activeElement?.getBoundingClientRect(); let scrollRect = scrollRef.current.getBoundingClientRect(); // Anchor from the top if the offset is in the top half of the scrollable element, diff --git a/packages/@react-aria/selection/src/useSelectableCollection.ts b/packages/@react-aria/selection/src/useSelectableCollection.ts index e36f3443847..f0a072cf04d 100644 --- a/packages/@react-aria/selection/src/useSelectableCollection.ts +++ b/packages/@react-aria/selection/src/useSelectableCollection.ts @@ -10,7 +10,7 @@ * governing permissions and limitations under the License. */ -import {CLEAR_FOCUS_EVENT, FOCUS_EVENT, focusWithoutScrolling, getActiveElement, isCtrlKeyPressed, isTabbable, mergeProps, nodeContains, scrollIntoView, scrollIntoViewport, useEvent, useRouter, useUpdateLayoutEffect} from '@react-aria/utils'; +import {CLEAR_FOCUS_EVENT, FOCUS_EVENT, focusWithoutScrolling, getActiveElement, isCtrlKeyPressed, isFocusWithin, isTabbable, mergeProps, nodeContains, scrollIntoView, scrollIntoViewport, useEvent, useRouter, useUpdateLayoutEffect} from '@react-aria/utils'; import {dispatchVirtualFocus, getFocusableTreeWalker, moveVirtualFocus} from '@react-aria/focus'; import {DOMAttributes, FocusableElement, FocusStrategy, Key, KeyboardDelegate, RefObject} from '@react-types/shared'; import {flushSync} from 'react-dom'; @@ -314,7 +314,7 @@ export function useSelectableCollection(options: AriaSelectableCollectionOptions // If the active element is NOT tabbable but is contained by an element that IS tabbable (aka the cell), the browser will actually move focus to // the containing element. We need to special case this so that tab will move focus out of the grid instead of looping between // focusing the containing cell and back to the non-tabbable child element - if (next && (!next.matches(':focus-within') || (document.activeElement && !isTabbable(document.activeElement)))) { + if (next && (!isFocusWithin(next) || (document.activeElement && !isTabbable(document.activeElement)))) { focusWithoutScrolling(next); } } @@ -379,7 +379,7 @@ export function useSelectableCollection(options: AriaSelectableCollectionOptions let element = getItemElement(ref, manager.focusedKey); if (element instanceof HTMLElement) { // This prevents a flash of focus on the first/last element in the collection, or the collection itself. - if (!element.matches(':focus-within') && !shouldUseVirtualFocus) { + if (!isFocusWithin(element) && !shouldUseVirtualFocus) { focusWithoutScrolling(element); } diff --git a/packages/@react-aria/test-utils/src/checkboxgroup.ts b/packages/@react-aria/test-utils/src/checkboxgroup.ts index 28aec8efd48..7451b7ec9cb 100644 --- a/packages/@react-aria/test-utils/src/checkboxgroup.ts +++ b/packages/@react-aria/test-utils/src/checkboxgroup.ts @@ -12,6 +12,7 @@ import {act, within} from '@testing-library/react'; import {CheckboxGroupTesterOpts, UserOpts} from './types'; +import {nodeContains} from '@react-aria/utils'; import {pressElement} from './events'; interface TriggerCheckboxOptions { @@ -94,7 +95,7 @@ export class CheckboxGroupTester { throw new Error('Checkbox provided is not in the checkbox group.'); } - if (!this.checkboxgroup.matches(':focus-within')) { + if (!nodeContains(this.checkboxgroup, document.activeElement)) { act(() => checkboxes[0].focus()); } diff --git a/packages/@react-aria/test-utils/src/combobox.ts b/packages/@react-aria/test-utils/src/combobox.ts index aa6e09fc8f0..3dfb46c3946 100644 --- a/packages/@react-aria/test-utils/src/combobox.ts +++ b/packages/@react-aria/test-utils/src/combobox.ts @@ -12,6 +12,7 @@ import {act, waitFor, within} from '@testing-library/react'; import {ComboBoxTesterOpts, UserOpts} from './types'; +import {nodeContains} from '@react-aria/utils'; interface ComboBoxOpenOpts { /** @@ -176,7 +177,7 @@ export class ComboBoxTester { if (option.getAttribute('href') == null) { await waitFor(() => { - if (listbox.isConnected) { + if (nodeContains(document, listbox)) { throw new Error('Expected listbox element to not be in the document after selecting an option'); } else { return true; @@ -198,7 +199,7 @@ export class ComboBoxTester { await this.user.keyboard('[Escape]'); await waitFor(() => { - if (listbox.isConnected) { + if (nodeContains(document, listbox)) { throw new Error('Expected listbox element to not be in the document after selecting an option'); } else { return true; diff --git a/packages/@react-aria/test-utils/src/dialog.ts b/packages/@react-aria/test-utils/src/dialog.ts index a8c3d2278e5..10e3b22d0f5 100644 --- a/packages/@react-aria/test-utils/src/dialog.ts +++ b/packages/@react-aria/test-utils/src/dialog.ts @@ -12,6 +12,7 @@ import {act, waitFor, within} from '@testing-library/react'; import {DialogTesterOpts, UserOpts} from './types'; +import {nodeContains} from '@react-aria/utils'; interface DialogOpenOpts { /** @@ -96,7 +97,7 @@ export class DialogTester { } }); - if (dialog && document.activeElement !== this._trigger && dialog.matches(':focus-within')) { + if (dialog && document.activeElement !== this._trigger && nodeContains(dialog, document.activeElement)) { this._dialog = dialog; } else { throw new Error('New modal dialog doesnt contain the active element OR the active element is still the trigger. Uncertain if the proper modal dialog was found'); @@ -113,7 +114,7 @@ export class DialogTester { if (dialog) { await this.user.keyboard('[Escape]'); await waitFor(() => { - if (dialog.isConnected) { + if (nodeContains(document, dialog)) { throw new Error('Expected the dialog to not be in the document after closing it.'); } else { this._dialog = undefined; @@ -138,6 +139,6 @@ export class DialogTester { * Returns the dialog if present. */ get dialog(): HTMLElement | null { - return this._dialog && this._dialog.isConnected ? this._dialog : null; + return this._dialog && nodeContains(document, this._dialog) ? this._dialog : null; } } diff --git a/packages/@react-aria/test-utils/src/gridlist.ts b/packages/@react-aria/test-utils/src/gridlist.ts index a348da57cd9..ebf8af799b6 100644 --- a/packages/@react-aria/test-utils/src/gridlist.ts +++ b/packages/@react-aria/test-utils/src/gridlist.ts @@ -13,6 +13,7 @@ import {act, within} from '@testing-library/react'; import {getAltKey, getMetaKey, pressElement, triggerLongPress} from './events'; import {GridListTesterOpts, GridRowActionOpts, ToggleGridRowOpts, UserOpts} from './types'; +import {nodeContains} from '@react-aria/utils'; interface GridListToggleRowOpts extends ToggleGridRowOpts {} interface GridListRowActionOpts extends GridRowActionOpts {} @@ -66,13 +67,13 @@ export class GridListTester { throw new Error('Option provided is not in the gridlist'); } - if (document.activeElement !== this._gridlist && !this._gridlist.matches(':focus-within')) { + if (document.activeElement !== this._gridlist && !nodeContains(this._gridlist, document.activeElement)) { act(() => this._gridlist.focus()); } if (document.activeElement === this._gridlist) { await this.user.keyboard(`${selectionOnNav === 'none' ? `[${altKey}>]` : ''}[ArrowDown]${selectionOnNav === 'none' ? `[/${altKey}]` : ''}`); - } else if (this._gridlist.matches(':focus-within') && document.activeElement!.getAttribute('role') !== 'row') { + } else if (nodeContains(this._gridlist, document.activeElement) && document.activeElement!.getAttribute('role') !== 'row') { do { await this.user.keyboard('[ArrowLeft]'); } while (document.activeElement!.getAttribute('role') !== 'row'); diff --git a/packages/@react-aria/test-utils/src/listbox.ts b/packages/@react-aria/test-utils/src/listbox.ts index 240036151af..d8fcab4a772 100644 --- a/packages/@react-aria/test-utils/src/listbox.ts +++ b/packages/@react-aria/test-utils/src/listbox.ts @@ -13,6 +13,7 @@ import {act, within} from '@testing-library/react'; import {getAltKey, getMetaKey, pressElement, triggerLongPress} from './events'; import {ListBoxTesterOpts, UserOpts} from './types'; +import {nodeContains} from '@react-aria/utils'; interface ListBoxToggleOptionOpts { /** @@ -103,7 +104,7 @@ export class ListBoxTester { throw new Error('Option provided is not in the listbox'); } - if (document.activeElement !== this._listbox && !this._listbox.matches(':focus-within')) { + if (document.activeElement !== this._listbox && !nodeContains(this._listbox, document.activeElement)) { act(() => this._listbox.focus()); await this.user.keyboard(`${selectionOnNav === 'none' ? `[${altKey}>]` : ''}[ArrowDown]${selectionOnNav === 'none' ? `[/${altKey}]` : ''}`); } diff --git a/packages/@react-aria/test-utils/src/menu.ts b/packages/@react-aria/test-utils/src/menu.ts index 7724e0429e1..f3dcdd3bdd0 100644 --- a/packages/@react-aria/test-utils/src/menu.ts +++ b/packages/@react-aria/test-utils/src/menu.ts @@ -216,7 +216,7 @@ export class MenuTester { return; } - if (document.activeElement !== menu && !menu.matches(':focus-within')) { + if (document.activeElement !== menu && !nodeContains(menu, document.activeElement)) { act(() => menu.focus()); } @@ -263,7 +263,7 @@ export class MenuTester { // close. In React 16, focus actually makes it all the way to the root menu's submenu trigger so we need check the root menu if (this._isSubmenu) { await waitFor(() => { - if (document.activeElement === this.trigger || (this._rootMenu && this._rootMenu.matches(':focus-within'))) { + if (document.activeElement === this.trigger || nodeContains(this._rootMenu, document.activeElement)) { throw new Error('Expected focus after selecting an submenu option to move away from the original submenu trigger.'); } else { return true; @@ -379,7 +379,7 @@ export class MenuTester { } }); - if (menu.isConnected) { + if (nodeContains(document, menu)) { throw new Error('Expected the menu to not be in the document after closing it.'); } } diff --git a/packages/@react-aria/test-utils/src/radiogroup.ts b/packages/@react-aria/test-utils/src/radiogroup.ts index 21979fb8add..bdcbfce9730 100644 --- a/packages/@react-aria/test-utils/src/radiogroup.ts +++ b/packages/@react-aria/test-utils/src/radiogroup.ts @@ -12,6 +12,7 @@ import {act, within} from '@testing-library/react'; import {Direction, Orientation, RadioGroupTesterOpts, UserOpts} from './types'; +import {nodeContains} from '@react-aria/utils'; import {pressElement} from './events'; interface TriggerRadioOptions { @@ -94,7 +95,7 @@ export class RadioGroupTester { throw new Error('Radio provided is not in the radio group.'); } - if (!this.radiogroup.matches(':focus-within')) { + if (!nodeContains(this.radiogroup, document.activeElement)) { let selectedRadio = this.selectedRadio; if (selectedRadio != null) { act(() => selectedRadio.focus()); diff --git a/packages/@react-aria/test-utils/src/select.ts b/packages/@react-aria/test-utils/src/select.ts index 2e14bf3ca57..56a9372dfe5 100644 --- a/packages/@react-aria/test-utils/src/select.ts +++ b/packages/@react-aria/test-utils/src/select.ts @@ -11,6 +11,7 @@ */ import {act, waitFor, within} from '@testing-library/react'; +import {nodeContains} from '@react-aria/utils'; import {SelectTesterOpts, UserOpts} from './types'; interface SelectOpenOpts { @@ -110,7 +111,7 @@ export class SelectTester { } }); - if (listbox && listbox.isConnected) { + if (listbox && nodeContains(document, listbox)) { throw new Error('Expected the select element listbox to not be in the document after closing the dropdown.'); } } @@ -191,7 +192,7 @@ export class SelectTester { return; } - if (document.activeElement !== listbox && !listbox.matches(':focus-within')) { + if (document.activeElement !== listbox && !nodeContains(listbox, document.activeElement)) { act(() => listbox.focus()); } await this.keyboardNavigateToOption({option}); @@ -214,7 +215,7 @@ export class SelectTester { } }); - if (listbox.isConnected) { + if (nodeContains(document, listbox)) { throw new Error('Expected select element listbox to not be in the document after selecting an option'); } } diff --git a/packages/@react-aria/test-utils/src/table.ts b/packages/@react-aria/test-utils/src/table.ts index 289370198db..24071fd3b90 100644 --- a/packages/@react-aria/test-utils/src/table.ts +++ b/packages/@react-aria/test-utils/src/table.ts @@ -13,6 +13,7 @@ import {act, waitFor, within} from '@testing-library/react'; import {getAltKey, getMetaKey, pressElement, triggerLongPress} from './events'; import {GridRowActionOpts, TableTesterOpts, ToggleGridRowOpts, UserOpts} from './types'; +import {nodeContains} from '@react-aria/utils'; interface TableToggleRowOpts extends ToggleGridRowOpts {} interface TableToggleSortOpts { @@ -65,7 +66,7 @@ export class TableTester { } // Move focus into the table - if (document.activeElement !== this._table && !this._table.matches(':focus-within')) { + if (document.activeElement !== this._table && !nodeContains(this._table, document.activeElement)) { act(() => this._table.focus()); } @@ -74,14 +75,14 @@ export class TableTester { } // If focus is currently somewhere in the first row group (aka on a column), we want to keyboard navigate downwards till we reach the rows - if (this.rowGroups[0].matches(':focus-within')) { + if (nodeContains(this.rowGroups[0], document.activeElement)) { do { await this.user.keyboard('[ArrowDown]'); - } while (!this.rowGroups[1].matches(':focus-within')); + } while (!nodeContains(this.rowGroups[1], document.activeElement)); } // Move focus onto the row itself - if (this.rowGroups[1].matches(':focus-within') && document.activeElement!.getAttribute('role') !== 'row') { + if (nodeContains(this.rowGroups[1], document.activeElement) && document.activeElement!.getAttribute('role') !== 'row') { do { await this.user.keyboard('[ArrowLeft]'); } while (document.activeElement!.getAttribute('role') !== 'row'); @@ -222,7 +223,7 @@ export class TableTester { } await waitFor(() => { - if (menu.isConnected) { + if (nodeContains(document, menu)) { throw new Error('Expected table column menu listbox to not be in the document after selecting an option'); } else { return true; @@ -308,7 +309,7 @@ export class TableTester { await pressElement(this.user, within(menu).getAllByRole('menuitem')[action], interactionType); await waitFor(() => { - if (menu.isConnected) { + if (nodeContains(document, menu)) { throw new Error('Expected table column menu listbox to not be in the document after selecting an option'); } else { return true; diff --git a/packages/@react-aria/test-utils/src/tabs.ts b/packages/@react-aria/test-utils/src/tabs.ts index 46bfe860827..f87a2d9afd2 100644 --- a/packages/@react-aria/test-utils/src/tabs.ts +++ b/packages/@react-aria/test-utils/src/tabs.ts @@ -12,6 +12,7 @@ import {act, within} from '@testing-library/react'; import {Direction, Orientation, TabsTesterOpts, UserOpts} from './types'; +import {nodeContains} from '@react-aria/utils'; import {pressElement} from './events'; interface TriggerTabOptions { @@ -89,7 +90,7 @@ export class TabsTester { throw new Error('Tab provided is not in the tablist'); } - if (!this._tablist.matches(':focus-within')) { + if (!nodeContains(this._tablist, document.activeElement)) { let selectedTab = this.selectedTab; if (selectedTab != null) { act(() => selectedTab.focus()); @@ -142,7 +143,7 @@ export class TabsTester { } if (interactionType === 'keyboard') { - if (document.activeElement !== this._tablist && !this._tablist.matches(':focus-within')) { + if (document.activeElement !== this._tablist && !nodeContains(this._tablist, document.activeElement)) { act(() => this._tablist.focus()); } diff --git a/packages/@react-aria/test-utils/src/tree.ts b/packages/@react-aria/test-utils/src/tree.ts index 721dfb252f4..28a566d59ec 100644 --- a/packages/@react-aria/test-utils/src/tree.ts +++ b/packages/@react-aria/test-utils/src/tree.ts @@ -13,6 +13,7 @@ import {act, within} from '@testing-library/react'; import {BaseGridRowInteractionOpts, GridRowActionOpts, ToggleGridRowOpts, TreeTesterOpts, UserOpts} from './types'; import {getAltKey, getMetaKey, pressElement, triggerLongPress} from './events'; +import {nodeContains} from '@react-aria/utils'; interface TreeToggleExpansionOpts extends BaseGridRowInteractionOpts {} interface TreeToggleRowOpts extends ToggleGridRowOpts {} @@ -73,13 +74,13 @@ export class TreeTester { throw new Error('Option provided is not in the tree'); } - if (document.activeElement !== this._tree && !this._tree.matches(':focus-within')) { + if (document.activeElement !== this._tree && !nodeContains(this._tree, document.activeElement)) { act(() => this._tree.focus()); } if (document.activeElement === this.tree) { await this.user.keyboard(`${selectionOnNav === 'none' ? `[${altKey}>]` : ''}[ArrowDown]${selectionOnNav === 'none' ? `[/${altKey}]` : ''}`); - } else if (this._tree.matches(':focus-within') && document.activeElement!.getAttribute('role') !== 'row') { + } else if (nodeContains(this._tree, document.activeElement) && document.activeElement!.getAttribute('role') !== 'row') { do { await this.user.keyboard('[ArrowLeft]'); } while (document.activeElement!.getAttribute('role') !== 'row'); @@ -178,7 +179,7 @@ export class TreeTester { row, interactionType = this._interactionType } = opts; - if (!this.tree.matches(':focus-within')) { + if (!nodeContains(this.tree, document.activeElement)) { await act(async () => { this.tree.focus(); }); diff --git a/packages/@react-aria/utils/src/index.ts b/packages/@react-aria/utils/src/index.ts index 9da3461dd5b..be51b95cd7f 100644 --- a/packages/@react-aria/utils/src/index.ts +++ b/packages/@react-aria/utils/src/index.ts @@ -12,7 +12,7 @@ export {useId, mergeIds, useSlotId} from './useId'; export {chain} from './chain'; export {createShadowTreeWalker, ShadowTreeWalker} from './shadowdom/ShadowTreeWalker'; -export {getActiveElement, getEventTarget, nodeContains} from './shadowdom/DOMFunctions'; +export {getActiveElement, getEventTarget, nodeContains, isFocusWithin} from './shadowdom/DOMFunctions'; export {getOwnerDocument, getOwnerWindow, isShadowRoot} from './domHelpers'; export {mergeProps} from './mergeProps'; export {mergeRefs} from './mergeRefs'; diff --git a/packages/@react-aria/utils/src/shadowdom/DOMFunctions.ts b/packages/@react-aria/utils/src/shadowdom/DOMFunctions.ts index bb69beb6b08..12c7322e0fa 100644 --- a/packages/@react-aria/utils/src/shadowdom/DOMFunctions.ts +++ b/packages/@react-aria/utils/src/shadowdom/DOMFunctions.ts @@ -1,7 +1,7 @@ // Source: https://github.com/microsoft/tabster/blob/a89fc5d7e332d48f68d03b1ca6e344489d1c3898/src/Shadowdomize/DOMFunctions.ts#L16 /* eslint-disable rsp-rules/no-non-shadow-contains */ -import {isShadowRoot} from '../domHelpers'; +import {getOwnerWindow, isShadowRoot} from '../domHelpers'; import {shadowDOM} from '@react-stately/flags'; /** @@ -69,3 +69,24 @@ export function getEventTarget(event: T): Element { } return event.target as Element; } + +/** + * ShadowDOM safe fast version of node.contains(document.activeElement). + * @param node + * @returns + */ +export function isFocusWithin(node: Element | null | undefined): boolean { + if (!node) { + return false; + } + // Get the active element within the node's parent shadow root (or the document). Can return null. + let root = node.getRootNode(); + let ownerWindow = getOwnerWindow(node); + if (!(root instanceof ownerWindow.Document || root instanceof ownerWindow.ShadowRoot)) { + return false; + } + let activeElement = root.activeElement; + + // Check if the active element is within this node. These nodes are within the same shadow root. + return activeElement != null && node.contains(activeElement); +} diff --git a/packages/@react-spectrum/menu/src/ContextualHelpTrigger.tsx b/packages/@react-spectrum/menu/src/ContextualHelpTrigger.tsx index 46dca0194b9..4e246b65ec2 100644 --- a/packages/@react-spectrum/menu/src/ContextualHelpTrigger.tsx +++ b/packages/@react-spectrum/menu/src/ContextualHelpTrigger.tsx @@ -15,7 +15,7 @@ import {DOMRefValue, ItemProps, Key} from '@react-types/shared'; import {FocusScope} from '@react-aria/focus'; import {getInteractionModality} from '@react-aria/interactions'; import helpStyles from '@adobe/spectrum-css-temp/components/contextualhelp/vars.css'; -import {nodeContains} from '@react-aria/utils'; +import {isFocusWithin, nodeContains} from '@react-aria/utils'; import {Popover} from '@react-spectrum/overlays'; import React, {JSX, KeyboardEventHandler, ReactElement, useEffect, useRef, useState} from 'react'; import ReactDOM from 'react-dom'; @@ -99,7 +99,7 @@ function ContextualHelpTrigger(props: InternalMenuDialogTriggerProps): ReactElem setTraySubmenuAnimation('spectrum-TraySubmenu-exit'); setTimeout(() => { submenuTriggerState.close(); - if (parentMenuRef.current && !parentMenuRef.current.matches(':focus-within')) { + if (parentMenuRef.current && !isFocusWithin(parentMenuRef.current)) { parentMenuRef.current.focus(); } }, 220); // Matches transition duration diff --git a/packages/@react-spectrum/menu/src/SubmenuTrigger.tsx b/packages/@react-spectrum/menu/src/SubmenuTrigger.tsx index b27a563b4b3..bfb80cfcf2b 100644 --- a/packages/@react-spectrum/menu/src/SubmenuTrigger.tsx +++ b/packages/@react-spectrum/menu/src/SubmenuTrigger.tsx @@ -11,9 +11,9 @@ */ import {classNames, useIsMobileDevice} from '@react-spectrum/utils'; +import {isFocusWithin, mergeProps} from '@react-aria/utils'; import {Key} from '@react-types/shared'; import {MenuContext, SubmenuTriggerContext, useMenuStateContext} from './context'; -import {mergeProps} from '@react-aria/utils'; import {Popover} from './Popover'; import React, {type JSX, ReactElement, useRef} from 'react'; import ReactDOM from 'react-dom'; @@ -49,7 +49,7 @@ function SubmenuTrigger(props: SubmenuTriggerProps) { let isMobile = useIsMobileDevice(); let onBackButtonPress = () => { submenuTriggerState.close(); - if (parentMenuRef.current && !parentMenuRef.current.matches(':focus-within')) { + if (parentMenuRef.current && !isFocusWithin(parentMenuRef.current)) { parentMenuRef.current.focus(); } }; diff --git a/packages/@react-spectrum/menu/src/useOverlayPosition.ts b/packages/@react-spectrum/menu/src/useOverlayPosition.ts index 53c29e5da12..7980f406e3f 100644 --- a/packages/@react-spectrum/menu/src/useOverlayPosition.ts +++ b/packages/@react-spectrum/menu/src/useOverlayPosition.ts @@ -12,10 +12,10 @@ import {calculatePosition, getRect, PositionResult} from './calculatePosition'; import {DOMAttributes, RefObject} from '@react-types/shared'; +import {isFocusWithin, useLayoutEffect, useResizeObserver} from '@react-aria/utils'; import {Placement, PlacementAxis, PositionProps} from '@react-types/overlays'; import {useCallback, useEffect, useRef, useState} from 'react'; import {useCloseOnScroll} from './useCloseOnScroll'; -import {useLayoutEffect, useResizeObserver} from '@react-aria/utils'; import {useLocale} from '@react-aria/i18n'; export interface AriaPositionProps extends PositionProps { @@ -154,7 +154,7 @@ export function useOverlayPosition(props: AriaPositionProps): PositionAria { // so it can be restored after repositioning. This way if the overlay height // changes, the focused element appears to stay in the same position. let anchor: ScrollAnchor | null = null; - if (scrollRef.current && scrollRef.current.matches(':focus-within')) { + if (scrollRef.current && isFocusWithin(scrollRef.current)) { let anchorRect = document.activeElement?.getBoundingClientRect(); let scrollRect = scrollRef.current.getBoundingClientRect(); // Anchor from the top if the offset is in the top half of the scrollable element, diff --git a/packages/@react-spectrum/s2/src/TableView.tsx b/packages/@react-spectrum/s2/src/TableView.tsx index bcf34a444f5..38cfe17b600 100644 --- a/packages/@react-spectrum/s2/src/TableView.tsx +++ b/packages/@react-spectrum/s2/src/TableView.tsx @@ -59,7 +59,7 @@ import Close from '../s2wf-icons/S2_Icon_Close_20_N.svg'; import {ColumnSize} from '@react-types/table'; import {CustomDialog, DialogContainer} from '..'; import {DOMProps, DOMRef, DOMRefValue, forwardRefType, GlobalDOMAttributes, LinkDOMProps, LoadingState, Node} from '@react-types/shared'; -import {getActiveElement, getOwnerDocument, nodeContains, useLayoutEffect, useObjectRef} from '@react-aria/utils'; +import {getActiveElement, getOwnerDocument, isFocusWithin, nodeContains, useLayoutEffect, useObjectRef} from '@react-aria/utils'; import {GridNode} from '@react-types/grid'; import {IconContext} from './Icon'; // @ts-ignore @@ -1301,7 +1301,7 @@ function EditableCellInner(props: EditableCellProps & {isFocusVisible: boolean, onOpenChange={setIsOpen} ref={popoverRef} shouldCloseOnInteractOutside={() => { - if (!(!!popoverRef.current && popoverRef.current.matches(':focus-within'))) { + if (!isFocusWithin(popoverRef.current)) { return false; } formRef.current?.requestSubmit(); diff --git a/packages/@react-spectrum/table/src/TableViewBase.tsx b/packages/@react-spectrum/table/src/TableViewBase.tsx index 2ec2967f708..3778641017a 100644 --- a/packages/@react-spectrum/table/src/TableViewBase.tsx +++ b/packages/@react-spectrum/table/src/TableViewBase.tsx @@ -33,7 +33,7 @@ import {GridNode} from '@react-types/grid'; import {InsertionIndicator} from './InsertionIndicator'; // @ts-ignore import intlMessages from '../intl/*.json'; -import {isAndroid, mergeProps, scrollIntoView, scrollIntoViewport, useLoadMore} from '@react-aria/utils'; +import {isAndroid, isFocusWithin, mergeProps, scrollIntoView, scrollIntoViewport, useLoadMore} from '@react-aria/utils'; import {Item, Menu, MenuTrigger} from '@react-spectrum/menu'; import {LayoutInfo, Rect, ReusableView, useVirtualizerState} from '@react-stately/virtualizer'; import {layoutInfoToStyle, ScrollView, setScrollLeft, VirtualizerItem} from '@react-aria/virtualizer'; @@ -606,7 +606,7 @@ function TableVirtualizer(props: TableVirtualizerProps) { // only that it changes in a resize, and when that happens, we want to sync the body to the // header scroll position useEffect(() => { - if (getInteractionModality() === 'keyboard' && headerRef.current && headerRef.current.matches(':focus-within') && bodyRef.current) { + if (getInteractionModality() === 'keyboard' && headerRef.current && isFocusWithin(headerRef.current) && bodyRef.current) { scrollIntoView(headerRef.current, document.activeElement as HTMLElement); scrollIntoViewport(document.activeElement, {containingElement: domRef.current}); bodyRef.current.scrollLeft = headerRef.current.scrollLeft; diff --git a/packages/dev/eslint-plugin-rsp-rules/rules/faster-node-contains.js b/packages/dev/eslint-plugin-rsp-rules/rules/faster-node-contains.js index 7b55d4be05e..65cdcef2ab8 100644 --- a/packages/dev/eslint-plugin-rsp-rules/rules/faster-node-contains.js +++ b/packages/dev/eslint-plugin-rsp-rules/rules/faster-node-contains.js @@ -19,12 +19,32 @@ const plugin = { }, fixable: 'code', messages: { - useFocusWithin: 'Use element.matches(\':focus-within\') instead of nodeContains for activeElement checks.', + useFocusWithin: 'Use isFocusWithin(element) instead of nodeContains for activeElement checks.', useIsConnected: 'Use node.isConnected instead of nodeContains for document contains checks.' } }, create: (context) => { + let existingReactAriaUtilsImport = null; + let hasIsFocusWithinImport = false; + return { + // Track imports from @react-aria/utils + ImportDeclaration(node) { + if ( + node.source && + node.source.type === 'Literal' && + node.source.value === '@react-aria/utils' + ) { + existingReactAriaUtilsImport = node; + hasIsFocusWithinImport = node.specifiers.some( + spec => + spec.type === 'ImportSpecifier' && + spec.imported.type === 'Identifier' && + spec.imported.name === 'isFocusWithin' + ); + } + }, + // Detect nodeContains() function calls CallExpression(node) { if (node.callee.type === 'Identifier' && node.callee.name === 'nodeContains') { @@ -43,7 +63,46 @@ const plugin = { node, messageId: 'useFocusWithin', fix: (fixer) => { - return fixer.replaceText(node, `${elementText}.matches(':focus-within')`); + const fixes = [fixer.replaceText(node, `isFocusWithin(${elementText})`)]; + + // Add import if not present + if (!hasIsFocusWithinImport) { + if (existingReactAriaUtilsImport) { + const specifiers = existingReactAriaUtilsImport.specifiers; + if (specifiers.length > 0) { + const openBrace = sourceCode.getFirstToken( + existingReactAriaUtilsImport, + token => token.value === '{' + ); + if (openBrace) { + fixes.push( + fixer.insertTextAfter(openBrace, 'isFocusWithin, ') + ); + } + } + } else { + const programNode = context.sourceCode.ast; + const imports = programNode.body.filter( + n => n.type === 'ImportDeclaration' + ); + const importStatement = + "\nimport {isFocusWithin} from '@react-aria/utils';"; + + if (imports.length > 0) { + const lastImport = imports[imports.length - 1]; + fixes.push(fixer.insertTextAfter(lastImport, importStatement)); + } else { + fixes.push( + fixer.insertTextBefore( + programNode.body[0], + "import {isFocusWithin} from '@react-aria/utils';\n" + ) + ); + } + } + } + + return fixes; } }); } else if (isDocument(firstArg)) { diff --git a/packages/dev/eslint-plugin-rsp-rules/test/faster-node-contains.test-lint.js b/packages/dev/eslint-plugin-rsp-rules/test/faster-node-contains.test-lint.js index 709ff47148f..e68c07ce555 100644 --- a/packages/dev/eslint-plugin-rsp-rules/test/faster-node-contains.test-lint.js +++ b/packages/dev/eslint-plugin-rsp-rules/test/faster-node-contains.test-lint.js @@ -42,7 +42,8 @@ if (nodeContains(element, document.activeElement)) { console.log('contained'); }`, output: ` -if (element.matches(':focus-within')) { +import {isFocusWithin} from '@react-aria/utils'; +if (isFocusWithin(element)) { console.log('contained'); }`, errors: 1 @@ -55,6 +56,34 @@ if (nodeContains(document, other)) { output: ` if (other.isConnected) { console.log('connected'); +}`, + errors: 1 + }, + // When @react-aria/utils is already imported, add isFocusWithin to that import + { + code: ` +import {nodeContains} from '@react-aria/utils'; +if (nodeContains(element, document.activeElement)) { + console.log('contained'); +}`, + output: ` +import {isFocusWithin, nodeContains} from '@react-aria/utils'; +if (isFocusWithin(element)) { + console.log('contained'); +}`, + errors: 1 + }, + // When isFocusWithin is already imported, only replace the call + { + code: ` +import {isFocusWithin, nodeContains} from '@react-aria/utils'; +if (nodeContains(element, document.activeElement)) { + console.log('contained'); +}`, + output: ` +import {isFocusWithin, nodeContains} from '@react-aria/utils'; +if (isFocusWithin(element)) { + console.log('contained'); }`, errors: 1 } diff --git a/packages/react-aria-components/src/Popover.tsx b/packages/react-aria-components/src/Popover.tsx index 156249c0502..6d0befb0aaf 100644 --- a/packages/react-aria-components/src/Popover.tsx +++ b/packages/react-aria-components/src/Popover.tsx @@ -21,7 +21,7 @@ import { useContextProps, useRenderProps } from './utils'; -import {filterDOMProps, mergeProps, useEnterAnimation, useExitAnimation, useLayoutEffect} from '@react-aria/utils'; +import {filterDOMProps, isFocusWithin, mergeProps, useEnterAnimation, useExitAnimation, useLayoutEffect} from '@react-aria/utils'; import {focusSafely} from '@react-aria/interactions'; import {OverlayArrowContext} from './OverlayArrow'; import {OverlayTriggerProps, OverlayTriggerState, useOverlayTriggerState} from 'react-stately'; @@ -199,7 +199,7 @@ function PopoverInner({state, isExiting, UNSTABLE_portalContainer, clearContexts // Focus the popover itself on mount, unless a child element is already focused. // Skip this for submenus since hovering a submenutrigger should keep focus on the trigger useEffect(() => { - if (isDialog && props.trigger !== 'SubmenuTrigger' && ref.current && !ref.current.matches(':focus-within')) { + if (isDialog && props.trigger !== 'SubmenuTrigger' && ref.current && !isFocusWithin(ref.current)) { focusSafely(ref.current); } }, [isDialog, ref, props.trigger]); diff --git a/yarn.lock b/yarn.lock index e80bd3b40c8..955e24b9555 100644 --- a/yarn.lock +++ b/yarn.lock @@ -22926,17 +22926,10 @@ __metadata: languageName: node linkType: hard -"nwsapi@npm:2.2.23": - version: 2.2.23 - resolution: "nwsapi@npm:2.2.23" - checksum: 10c0/e44bfc9246baf659581206ed716d291a1905185247795fb8a302cb09315c943a31023b4ac4d026a5eaf32b2def51d77b3d0f9ebf4f3d35f70e105fcb6447c76e - languageName: node - linkType: hard - -"nwsapi@patch:nwsapi@npm%3A2.2.23#~/.yarn/patches/nwsapi-npm-2.2.23-aa3710d724.patch": - version: 2.2.23 - resolution: "nwsapi@patch:nwsapi@npm%3A2.2.23#~/.yarn/patches/nwsapi-npm-2.2.23-aa3710d724.patch::version=2.2.23&hash=3e65e8" - checksum: 10c0/1fb540bdc6ef46b13b1777b8b0777db0961fb857e4e8038653a25673131953bc3128cdcb8611edcc6d7a958f57b7a8714832273a8845ddd3203bbd04bf17fc0c +"nwsapi@npm:^2.2.2": + version: 2.2.4 + resolution: "nwsapi@npm:2.2.4" + checksum: 10c0/d6d297feed79e0fc984e7a9588fe20255a493c0645f35a3419b04662535311cc38126fa12d8e1a5ae8f18cc0de0ec0d36680e03fab44050f7117e0503ecc9c38 languageName: node linkType: hard From d772f29fb7b42c6de66531ab429b503d66259487 Mon Sep 17 00:00:00 2001 From: Robert Snow Date: Fri, 6 Feb 2026 10:39:38 +1100 Subject: [PATCH 4/4] remove perf tests --- focus-within-performance-test.html | 427 ----------------------------- isconnected-performance-test.html | 406 --------------------------- 2 files changed, 833 deletions(-) delete mode 100644 focus-within-performance-test.html delete mode 100644 isconnected-performance-test.html diff --git a/focus-within-performance-test.html b/focus-within-performance-test.html deleted file mode 100644 index 7729e84b9df..00000000000 --- a/focus-within-performance-test.html +++ /dev/null @@ -1,427 +0,0 @@ - - - - - - Focus-Within Performance Test - - - -

Focus-Within Performance Test

-

- This test compares the performance of three methods for checking if a node contains focus: -

-
    -
  • nodeContains(node, document.activeElement) - ShadowDOM-safe contains
  • -
  • node.matches(':focus-within')
  • -
  • isFocusWithin(node) - uses node.getRootNode().activeElement
  • -
- -
-

Configuration

-
- - -
-
- - -
-
- - -
-
- - -
- - -
- -
- - - - - - diff --git a/isconnected-performance-test.html b/isconnected-performance-test.html deleted file mode 100644 index e678a0f59ca..00000000000 --- a/isconnected-performance-test.html +++ /dev/null @@ -1,406 +0,0 @@ - - - - - - isConnected Performance Test - - - -

isConnected Performance Test

-

- This test compares the performance of document.contains(node) - vs node.isConnected for checking if a node is in the document. -

- -
-

Configuration

-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
- - -
- - - -
- - - - - -