From 2a9d1ea258f4e2ee436a52a066924b96c2533814 Mon Sep 17 00:00:00 2001 From: Matthew Costabile Date: Sat, 13 Dec 2025 20:33:37 +0000 Subject: [PATCH 01/22] perf: fix expensive CSS :has() selectors for INP optimization Replace expensive CSS :has() selectors that scan the DOM with data attributes set in JavaScript. This improves Interaction to Next Paint (INP) by avoiding costly style recalculations. Changes: - Dialog: Replace body:has(.Dialog.DisableScroll) with direct class toggle on body element. The :has() selector was scanning the entire DOM on every style recalc. - ActionList: Replace expensive descendant-scanning :has() selectors: - Mixed descriptions: Replace double :has() that scanned all items twice with a JS-computed data-has-mixed-descriptions attribute - Disabled state: Replace :has([aria-disabled], [disabled]) with data-disabled attribute on the
  • - Loading state: Replace :has([data-loading]) with data-loading attribute on the
  • Selectors left unchanged (cheap/already scoped): - &:has(> .TrailingAction) - direct child, O(1) - .Dialog:has(.Footer) - single element container - Adjacent sibling selectors in other components --- .../src/ActionList/ActionList.module.css | 20 +++++--- packages/react/src/ActionList/Item.tsx | 2 + packages/react/src/ActionList/List.tsx | 50 +++++++++++++++++++ packages/react/src/Dialog/Dialog.module.css | 7 ++- packages/react/src/Dialog/Dialog.tsx | 7 +++ 5 files changed, 77 insertions(+), 9 deletions(-) diff --git a/packages/react/src/ActionList/ActionList.module.css b/packages/react/src/ActionList/ActionList.module.css index f8007fb3e63..0a5b4e3d62a 100644 --- a/packages/react/src/ActionList/ActionList.module.css +++ b/packages/react/src/ActionList/ActionList.module.css @@ -86,8 +86,12 @@ display: none; } - /* if a list has a mix of items with and without descriptions, reset the label font-weight to normal */ - &:has([data-has-description='true']):has([data-has-description='false']) { + /* + * PERFORMANCE: Uses data attribute set by JavaScript instead of + * :has([data-has-description='true']):has([data-has-description='false']) + * The double :has() scans all items twice on every style recalc - O(2n). + */ + &[data-has-mixed-descriptions='true'] { & .ItemLabel { font-weight: var(--base-text-weight-normal); } @@ -121,7 +125,8 @@ } } - &:not(:has([aria-disabled], [disabled]), [data-has-subitem='true']) { + /* PERFORMANCE: Use data-disabled on
  • instead of :has([aria-disabled], [disabled]) which scans descendants */ + &:not([aria-disabled], [data-disabled='true'], [data-has-subitem='true']) { @media (hover: hover) { &:hover, &:active { @@ -276,8 +281,8 @@ } } - &:where([data-loading='true']), - &:has([data-loading='true']) { + /* PERFORMANCE: data-loading is set on the
  • by JS, avoiding :has() descendant scan */ + &:where([data-loading='true']) { & * { color: var(--fgColor-muted); } @@ -322,10 +327,9 @@ } } - /* disabled */ - + /* PERFORMANCE: data-disabled is set on the
  • by JS, avoiding :has() descendant scan */ &[aria-disabled='true'], - &:has([aria-disabled='true'], [disabled]) { + &[data-disabled='true'] { & .ActionListContent * { color: var(--control-fgColor-disabled); } diff --git a/packages/react/src/ActionList/Item.tsx b/packages/react/src/ActionList/Item.tsx index ac917743fd5..90122ccf5b3 100644 --- a/packages/react/src/ActionList/Item.tsx +++ b/packages/react/src/ActionList/Item.tsx @@ -256,6 +256,8 @@ const UnwrappedItem = ( data-variant={variant === 'danger' ? variant : undefined} data-active={active ? true : undefined} data-inactive={inactiveText ? true : undefined} + data-loading={loading && !inactive ? true : undefined} + data-disabled={disabled ? true : undefined} data-has-subitem={slots.subItem ? true : undefined} data-has-description={slots.description ? true : false} className={clsx(classes.ActionListItem, className)} diff --git a/packages/react/src/ActionList/List.tsx b/packages/react/src/ActionList/List.tsx index 6fa8fa56ac9..6fe1be5834d 100644 --- a/packages/react/src/ActionList/List.tsx +++ b/packages/react/src/ActionList/List.tsx @@ -9,6 +9,53 @@ import {useProvidedRefOrCreate} from '../hooks' import {FocusKeys, useFocusZone} from '../hooks/useFocusZone' import {clsx} from 'clsx' import classes from './ActionList.module.css' +import useIsomorphicLayoutEffect from '../utils/useIsomorphicLayoutEffect' + +/** + * Detects if the list has a mix of items with and without descriptions. + * Sets a data attribute on the list element to enable CSS styling. + * This replaces an expensive double :has() CSS selector that would scan all items twice. + */ +function useMixedDescriptions(listRef: React.RefObject) { + useIsomorphicLayoutEffect(() => { + const list = listRef.current + if (!list) return + + const updateMixedDescriptions = () => { + const items = list.querySelectorAll('[data-has-description]') + let hasWithDescription = false + let hasWithoutDescription = false + + for (const item of items) { + const value = item.getAttribute('data-has-description') + if (value === 'true') hasWithDescription = true + if (value === 'false') hasWithoutDescription = true + if (hasWithDescription && hasWithoutDescription) break + } + + const hasMixed = hasWithDescription && hasWithoutDescription + if (hasMixed) { + list.setAttribute('data-has-mixed-descriptions', 'true') + } else { + list.removeAttribute('data-has-mixed-descriptions') + } + } + + // Initial check + updateMixedDescriptions() + + // Observe for changes to handle dynamic item additions/removals + const observer = new MutationObserver(updateMixedDescriptions) + observer.observe(list, { + childList: true, + subtree: true, + attributes: true, + attributeFilter: ['data-has-description'], + }) + + return () => observer.disconnect() + }, [listRef]) +} const UnwrappedList = ( props: ActionListProps, @@ -55,6 +102,9 @@ const UnwrappedList = ( listRole === 'menu' || container === 'SelectPanel' || container === 'FilteredActionList' ? 'wrap' : undefined, }) + // Detect mixed descriptions and set data attribute (replaces expensive :has() CSS selector) + useMixedDescriptions(listRef) + return (
  • element, but aria-disabled may be on a child element (ItemWrapper) depending on the _PrivateItemWrapper and listSemantics flags. Using data-disabled alone is correct because it's explicitly set on the
  • based on the disabled prop. --- packages/react/src/ActionList/ActionList.module.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/react/src/ActionList/ActionList.module.css b/packages/react/src/ActionList/ActionList.module.css index 6d30f7ac53b..cb7d3c1476e 100644 --- a/packages/react/src/ActionList/ActionList.module.css +++ b/packages/react/src/ActionList/ActionList.module.css @@ -126,7 +126,7 @@ } /* PERFORMANCE: Use data-disabled on
  • instead of :has([aria-disabled], [disabled]) which scans descendants */ - &:not([aria-disabled], [data-disabled='true'], [data-has-subitem='true']) { + &:not([data-disabled='true'], [data-has-subitem='true']) { @media (hover: hover) { &:hover, &:active { From 90f3b57faa457e7478e97096966c0e3e9d1906d4 Mon Sep 17 00:00:00 2001 From: Matthew Costabile Date: Sun, 14 Dec 2025 00:07:22 +0000 Subject: [PATCH 07/22] backout a bit --- packages/react/src/ActionList/ActionList.module.css | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/react/src/ActionList/ActionList.module.css b/packages/react/src/ActionList/ActionList.module.css index cb7d3c1476e..0f5186ee7ef 100644 --- a/packages/react/src/ActionList/ActionList.module.css +++ b/packages/react/src/ActionList/ActionList.module.css @@ -126,7 +126,7 @@ } /* PERFORMANCE: Use data-disabled on
  • instead of :has([aria-disabled], [disabled]) which scans descendants */ - &:not([data-disabled='true'], [data-has-subitem='true']) { + &:not([aria-disabled], [data-disabled='true'], [data-has-subitem='true']) { @media (hover: hover) { &:hover, &:active { @@ -642,8 +642,7 @@ default block */ word-break: normal; } - /* PERFORMANCE: data-truncate is on this element itself, no :has() needed */ - &[data-truncate='true'] { + &:has([data-truncate='true']) { & .ItemLabel { flex: 1 0 auto; } From 86897e9b07cbcf9954df0e938a21227ee97bbb7b Mon Sep 17 00:00:00 2001 From: Matthew Costabile Date: Sun, 14 Dec 2025 00:22:21 +0000 Subject: [PATCH 08/22] backout a bit --- .../src/ActionList/ActionList.module.css | 8 +-- packages/react/src/ActionList/List.tsx | 50 ------------------- 2 files changed, 2 insertions(+), 56 deletions(-) diff --git a/packages/react/src/ActionList/ActionList.module.css b/packages/react/src/ActionList/ActionList.module.css index 0f5186ee7ef..38fc7e03d83 100644 --- a/packages/react/src/ActionList/ActionList.module.css +++ b/packages/react/src/ActionList/ActionList.module.css @@ -86,12 +86,8 @@ display: none; } - /* - * PERFORMANCE: Uses data attribute set by JavaScript instead of - * :has([data-has-description='true']):has([data-has-description='false']) - * The double :has() scans all items twice on every style recalc - O(2n). - */ - &[data-has-mixed-descriptions='true'] { + /* if a list has a mix of items with and without descriptions, reset the label font-weight to normal */ + &:has([data-has-description='true']):has([data-has-description='false']) { & .ItemLabel { font-weight: var(--base-text-weight-normal); } diff --git a/packages/react/src/ActionList/List.tsx b/packages/react/src/ActionList/List.tsx index 6fe1be5834d..6fa8fa56ac9 100644 --- a/packages/react/src/ActionList/List.tsx +++ b/packages/react/src/ActionList/List.tsx @@ -9,53 +9,6 @@ import {useProvidedRefOrCreate} from '../hooks' import {FocusKeys, useFocusZone} from '../hooks/useFocusZone' import {clsx} from 'clsx' import classes from './ActionList.module.css' -import useIsomorphicLayoutEffect from '../utils/useIsomorphicLayoutEffect' - -/** - * Detects if the list has a mix of items with and without descriptions. - * Sets a data attribute on the list element to enable CSS styling. - * This replaces an expensive double :has() CSS selector that would scan all items twice. - */ -function useMixedDescriptions(listRef: React.RefObject) { - useIsomorphicLayoutEffect(() => { - const list = listRef.current - if (!list) return - - const updateMixedDescriptions = () => { - const items = list.querySelectorAll('[data-has-description]') - let hasWithDescription = false - let hasWithoutDescription = false - - for (const item of items) { - const value = item.getAttribute('data-has-description') - if (value === 'true') hasWithDescription = true - if (value === 'false') hasWithoutDescription = true - if (hasWithDescription && hasWithoutDescription) break - } - - const hasMixed = hasWithDescription && hasWithoutDescription - if (hasMixed) { - list.setAttribute('data-has-mixed-descriptions', 'true') - } else { - list.removeAttribute('data-has-mixed-descriptions') - } - } - - // Initial check - updateMixedDescriptions() - - // Observe for changes to handle dynamic item additions/removals - const observer = new MutationObserver(updateMixedDescriptions) - observer.observe(list, { - childList: true, - subtree: true, - attributes: true, - attributeFilter: ['data-has-description'], - }) - - return () => observer.disconnect() - }, [listRef]) -} const UnwrappedList = ( props: ActionListProps, @@ -102,9 +55,6 @@ const UnwrappedList = ( listRole === 'menu' || container === 'SelectPanel' || container === 'FilteredActionList' ? 'wrap' : undefined, }) - // Detect mixed descriptions and set data attribute (replaces expensive :has() CSS selector) - useMixedDescriptions(listRef) - return ( Date: Sun, 14 Dec 2025 04:38:18 +0000 Subject: [PATCH 09/22] perf(TreeView): cache tree items in typeahead to improve INP - Add useTreeItemCache hook that caches querySelectorAll results - MutationObserver invalidates cache when tree structure changes - Typeahead now uses cached items instead of querying DOM on every keypress - This eliminates 10-50ms blocking time on large trees during rapid typing --- .../react/src/TreeView/useRovingTabIndex.ts | 4 +- .../react/src/TreeView/useTreeItemCache.ts | 60 +++++++++++++++++++ packages/react/src/TreeView/useTypeahead.ts | 9 +-- 3 files changed, 68 insertions(+), 5 deletions(-) create mode 100644 packages/react/src/TreeView/useTreeItemCache.ts diff --git a/packages/react/src/TreeView/useRovingTabIndex.ts b/packages/react/src/TreeView/useRovingTabIndex.ts index ff6af01870d..98a7dbbfa6b 100644 --- a/packages/react/src/TreeView/useRovingTabIndex.ts +++ b/packages/react/src/TreeView/useRovingTabIndex.ts @@ -187,7 +187,9 @@ export function getFirstElement(element: HTMLElement): HTMLElement | undefined { export function getLastElement(element: HTMLElement): HTMLElement | undefined { const root = element.closest('[role=tree]') - const items = Array.from(root?.querySelectorAll('[role=treeitem]') || []) + if (!root) return + + const items = Array.from(root.querySelectorAll('[role=treeitem]')) // If there are no items, return undefined if (items.length === 0) return diff --git a/packages/react/src/TreeView/useTreeItemCache.ts b/packages/react/src/TreeView/useTreeItemCache.ts new file mode 100644 index 00000000000..26e83f38bc8 --- /dev/null +++ b/packages/react/src/TreeView/useTreeItemCache.ts @@ -0,0 +1,60 @@ +import React from 'react' + +type TreeItemCache = { + items: HTMLElement[] + version: number +} + +/** + * A hook that caches tree items to avoid expensive querySelectorAll calls on every keypress. + * The cache is invalidated when the tree structure changes (via MutationObserver). + * + * PERFORMANCE: This is critical for INP because querySelectorAll('[role="treeitem"]') + * on large trees can take 10-50ms, which directly blocks user input response. + */ +export function useTreeItemCache(containerRef: React.RefObject) { + const cacheRef = React.useRef({items: [], version: 0}) + + // Invalidate cache when tree structure changes + React.useEffect(() => { + const container = containerRef.current + if (!container) return + + const invalidateCache = () => { + cacheRef.current.version++ + } + + // Watch for structural changes (items added/removed, expanded/collapsed) + const observer = new MutationObserver(invalidateCache) + observer.observe(container, { + childList: true, + subtree: true, + attributes: true, + attributeFilter: ['aria-expanded', 'role'], + }) + + // Initial invalidation to ensure fresh cache on mount + invalidateCache() + + return () => observer.disconnect() + }, [containerRef]) + + const getTreeItems = React.useCallback((): HTMLElement[] => { + const container = containerRef.current + if (!container) return [] + + // Return cached items if cache is still valid + const currentVersion = cacheRef.current.version + if (cacheRef.current.items.length > 0 && cacheRef.current.version === currentVersion) { + return cacheRef.current.items + } + + // Rebuild cache + const items = Array.from(container.querySelectorAll('[role="treeitem"]')) as HTMLElement[] + cacheRef.current = {items, version: currentVersion} + + return items + }, [containerRef]) + + return {getTreeItems} +} diff --git a/packages/react/src/TreeView/useTypeahead.ts b/packages/react/src/TreeView/useTypeahead.ts index cfd5f35f6d4..128b6f967eb 100644 --- a/packages/react/src/TreeView/useTypeahead.ts +++ b/packages/react/src/TreeView/useTypeahead.ts @@ -1,6 +1,7 @@ import React from 'react' import useSafeTimeout from '../hooks/useSafeTimeout' import {getAccessibleName} from './shared' +import {useTreeItemCache} from './useTreeItemCache' type TypeaheadOptions = { containerRef: React.RefObject @@ -12,6 +13,7 @@ export function useTypeahead({containerRef, onFocusChange}: TypeaheadOptions) { const timeoutRef = React.useRef(0) const onFocusChangeRef = React.useRef(onFocusChange) const {safeSetTimeout, safeClearTimeout} = useSafeTimeout() + const {getTreeItems} = useTreeItemCache(containerRef) // Update the ref when the callback changes React.useEffect(() => { @@ -25,10 +27,9 @@ export function useTypeahead({containerRef, onFocusChange}: TypeaheadOptions) { if (!searchValue) return if (!containerRef.current) return - const container = containerRef.current - // Get focusable elements - const elements = Array.from(container.querySelectorAll('[role="treeitem"]')) + // PERFORMANCE: Use cached tree items instead of querySelectorAll on every keypress + const elements = getTreeItems() // Get the index of active element const activeIndex = elements.findIndex(element => element === document.activeElement) @@ -53,7 +54,7 @@ export function useTypeahead({containerRef, onFocusChange}: TypeaheadOptions) { onFocusChangeRef.current(nextElement) } }, - [containerRef], + [containerRef, getTreeItems], ) // Update the search value when the user types From 8c4c70b3e2b1dbd9c0374831e8fdebeef4b10c48 Mon Sep 17 00:00:00 2001 From: Matthew Costabile Date: Sun, 14 Dec 2025 05:13:40 +0000 Subject: [PATCH 10/22] perf: additional :has() optimizations and documentation - Remove redundant BaseStyles :has([data-color-mode]) selectors (functionality preserved by existing global selectors) - Add NOTE comments documenting acceptable :has() selectors: - Group.module.css: edge case handler for developer errors - ActionList.module.css: mixed descriptions, SubGroup active, truncate - Timeline.module.css: adjacent sibling (O(1)) - ButtonBase.module.css: kbd-chord (very few children) --- .../src/ActionList/ActionList.module.css | 16 ++++++++++++++-- .../react/src/ActionList/Group.module.css | 6 +++++- packages/react/src/BaseStyles.module.css | 19 ++++++------------- .../react/src/Button/ButtonBase.module.css | 2 ++ .../react/src/Timeline/Timeline.module.css | 1 + 5 files changed, 28 insertions(+), 16 deletions(-) diff --git a/packages/react/src/ActionList/ActionList.module.css b/packages/react/src/ActionList/ActionList.module.css index 38fc7e03d83..c6d178d6cae 100644 --- a/packages/react/src/ActionList/ActionList.module.css +++ b/packages/react/src/ActionList/ActionList.module.css @@ -86,7 +86,14 @@ display: none; } - /* if a list has a mix of items with and without descriptions, reset the label font-weight to normal */ + /* + * If a list has a mix of items with and without descriptions, reset the label font-weight. + * NOTE: Uses double descendant :has() - traverses list twice per recalc. + * This is acceptable because: + * 1. ActionLists are typically small (10-50 items) + * 2. Alternative would require JS to detect mixed state and set data attribute + * 3. The visual impact is subtle (font-weight change), not critical styling + */ &:has([data-has-description='true']):has([data-has-description='false']) { & .ItemLabel { font-weight: var(--base-text-weight-normal); @@ -541,7 +548,11 @@ display: none; } - /* show active indicator on parent collapse if child is active */ + /* + * Show active indicator on parent collapse if child is active. + * NOTE: Uses adjacent sibling + descendant :has() - SubGroup is typically small (few nav items). + * This is acceptable because scope is limited to the collapsed SubGroup's children. + */ &:has(+ .SubGroup [data-active='true']) { background: var(--control-transparent-bgColor-selected); @@ -638,6 +649,7 @@ default block */ word-break: normal; } + /* NOTE: Uses descendant :has() - scope is just this item's children (label + description). Acceptable. */ &:has([data-truncate='true']) { & .ItemLabel { flex: 1 0 auto; diff --git a/packages/react/src/ActionList/Group.module.css b/packages/react/src/ActionList/Group.module.css index 201d3086b86..761b9ec15c4 100644 --- a/packages/react/src/ActionList/Group.module.css +++ b/packages/react/src/ActionList/Group.module.css @@ -4,7 +4,11 @@ &:not(:first-child) { margin-block-start: var(--base-size-8); - /* If somebody tries to pass the `title` prop AND a `NavList.GroupHeading` as a child, hide the `ActionList.GroupHeading */ + /* + * If somebody tries to pass the `title` prop AND a `NavList.GroupHeading` as a child, hide the `ActionList.GroupHeading`. + * NOTE: Uses descendant :has() - this is an edge case handler for developer errors. + * Scope is limited to group's children, not entire list. Acceptable performance cost. + */ /* stylelint-disable-next-line selector-max-specificity */ &:has(.GroupHeadingWrap + ul > .GroupHeadingWrap) { /* stylelint-disable-next-line selector-max-specificity */ diff --git a/packages/react/src/BaseStyles.module.css b/packages/react/src/BaseStyles.module.css index 76259ac05dd..3e6352c92e6 100644 --- a/packages/react/src/BaseStyles.module.css +++ b/packages/react/src/BaseStyles.module.css @@ -60,19 +60,12 @@ details-dialog:focus:not(:focus-visible):not(:global(.focus-visible)) { /* stylelint-disable-next-line primer/colors */ color: var(--BaseStyles-fgColor, var(--fgColor-default)); - /* Global styles for light mode */ - &:has([data-color-mode='light']) { - input & { - color-scheme: light; - } - } - - /* Global styles for dark mode */ - &:has([data-color-mode='dark']) { - input & { - color-scheme: dark; - } - } + /* + * PERFORMANCE: Removed :has([data-color-mode]) selectors that scanned entire DOM. + * Input color-scheme is already handled by global selectors above: + * [data-color-mode='light'] input { color-scheme: light; } + * [data-color-mode='dark'] input { color-scheme: dark; } + */ /* Low-specificity default link styling */ :where(a:not([class*='prc-']):not([class*='PRC-']):not([class*='Primer_Brand__'])) { diff --git a/packages/react/src/Button/ButtonBase.module.css b/packages/react/src/Button/ButtonBase.module.css index 890638a804c..7af731290da 100644 --- a/packages/react/src/Button/ButtonBase.module.css +++ b/packages/react/src/Button/ButtonBase.module.css @@ -24,6 +24,7 @@ justify-content: space-between; gap: var(--base-size-8); + /* NOTE: Uses descendant :has() - button has very few children (icon, text, kbd). Acceptable. */ &:has([data-kbd-chord]) { padding-inline-end: var(--base-size-6); } @@ -173,6 +174,7 @@ margin-right: var(--control-large-gap); } + /* NOTE: Uses descendant :has() - button has very few children (icon, text, kbd). Acceptable. */ &:has([data-kbd-chord]) { padding-inline-end: var(--base-size-8); } diff --git a/packages/react/src/Timeline/Timeline.module.css b/packages/react/src/Timeline/Timeline.module.css index 608df26e2eb..129e352d355 100644 --- a/packages/react/src/Timeline/Timeline.module.css +++ b/packages/react/src/Timeline/Timeline.module.css @@ -105,6 +105,7 @@ border: 0; border-top: var(--borderWidth-thicker) solid var(--borderColor-default); + /* NOTE: Uses adjacent sibling :has() - checks only the next sibling. O(1) complexity. */ &:has(+ [data-condensed]) { margin-bottom: calc(-1 * var(--base-size-12)); } From cc131e311e6f87a6f538cafcc36034e358315ecf Mon Sep 17 00:00:00 2001 From: Matthew Costabile Date: Sun, 14 Dec 2025 14:26:40 +0000 Subject: [PATCH 11/22] minor updates to scripts --- packages/react/src/ActionBar/ActionBar.tsx | 23 +++++++--- .../react/src/Breadcrumbs/Breadcrumbs.tsx | 4 +- .../react/src/UnderlineNav/UnderlineNav.tsx | 31 ++++++++----- .../src/UnderlineNav/UnderlineNavItem.tsx | 12 +++--- .../src/internal/utils/hasInteractiveNodes.ts | 43 ++++++++++--------- 5 files changed, 68 insertions(+), 45 deletions(-) diff --git a/packages/react/src/ActionBar/ActionBar.tsx b/packages/react/src/ActionBar/ActionBar.tsx index 7f2745ef218..4787b9d481c 100644 --- a/packages/react/src/ActionBar/ActionBar.tsx +++ b/packages/react/src/ActionBar/ActionBar.tsx @@ -320,15 +320,24 @@ export const ActionBar: React.FC> = prop const moreMenuBtnRef = useRef(null) const containerRef = React.useRef(null) + // Track pending animation frame to avoid redundant work and improve INP during resize + const pendingFrameRef = useRef(null) useResizeObserver((resizeObserverEntries: ResizeObserverEntry[]) => { - const navWidth = resizeObserverEntries[0].contentRect.width - const moreMenuWidth = moreMenuRef.current?.getBoundingClientRect().width ?? 0 - const hasActiveMenu = menuItemIds.size > 0 - - if (navWidth > 0) { - const newMenuItemIds = getMenuItems(navWidth, moreMenuWidth, childRegistry, hasActiveMenu, computedGap) - if (newMenuItemIds) setMenuItemIds(newMenuItemIds) + // Cancel any pending frame to coalesce rapid resize events + if (pendingFrameRef.current !== null) { + cancelAnimationFrame(pendingFrameRef.current) } + pendingFrameRef.current = requestAnimationFrame(() => { + pendingFrameRef.current = null + const navWidth = resizeObserverEntries[0].contentRect.width + const moreMenuWidth = moreMenuRef.current?.getBoundingClientRect().width ?? 0 + const hasActiveMenu = menuItemIds.size > 0 + + if (navWidth > 0) { + const newMenuItemIds = getMenuItems(navWidth, moreMenuWidth, childRegistry, hasActiveMenu, computedGap) + if (newMenuItemIds) setMenuItemIds(newMenuItemIds) + } + }) }, navRef as RefObject) const isVisibleChild = useCallback( diff --git a/packages/react/src/Breadcrumbs/Breadcrumbs.tsx b/packages/react/src/Breadcrumbs/Breadcrumbs.tsx index 5a59efe2f4c..e5353a285a1 100644 --- a/packages/react/src/Breadcrumbs/Breadcrumbs.tsx +++ b/packages/react/src/Breadcrumbs/Breadcrumbs.tsx @@ -188,9 +188,11 @@ function Breadcrumbs({className, children, style, overflow = 'wrap', variant = ' listElement.children.length === childArray.length ) { const listElementArray = Array.from(listElement.children) as HTMLElement[] + // Batch all offsetWidth reads in a single pass to avoid layout thrashing const widths = listElementArray.map(child => child.offsetWidth) setChildArrayWidths(widths) - setRootItemWidth(listElementArray[0].offsetWidth) + // Use first width from the array instead of reading offsetWidth again + setRootItemWidth(widths[0]) } }, [childArray, overflowMenuEnabled]) diff --git a/packages/react/src/UnderlineNav/UnderlineNav.tsx b/packages/react/src/UnderlineNav/UnderlineNav.tsx index c0a252d3ffe..3c304b21301 100644 --- a/packages/react/src/UnderlineNav/UnderlineNav.tsx +++ b/packages/react/src/UnderlineNav/UnderlineNav.tsx @@ -291,18 +291,27 @@ export const UnderlineNav = forwardRef( useOnOutsideClick({onClickOutside: closeOverlay, containerRef, ignoreClickRefs: [moreMenuBtnRef]}) + // Track pending animation frame to avoid redundant work and improve INP during resize + const pendingFrameRef = useRef(null) useResizeObserver((resizeObserverEntries: ResizeObserverEntry[]) => { - const navWidth = resizeObserverEntries[0].contentRect.width - const moreMenuWidth = moreMenuRef.current?.getBoundingClientRect().width ?? 0 - navWidth !== 0 && - overflowEffect( - navWidth, - moreMenuWidth, - validChildren, - childWidthArray, - noIconChildWidthArray, - updateListAndMenu, - ) + // Cancel any pending frame to coalesce rapid resize events + if (pendingFrameRef.current !== null) { + cancelAnimationFrame(pendingFrameRef.current) + } + pendingFrameRef.current = requestAnimationFrame(() => { + pendingFrameRef.current = null + const navWidth = resizeObserverEntries[0].contentRect.width + const moreMenuWidth = moreMenuRef.current?.getBoundingClientRect().width ?? 0 + navWidth !== 0 && + overflowEffect( + navWidth, + moreMenuWidth, + validChildren, + childWidthArray, + noIconChildWidthArray, + updateListAndMenu, + ) + }) }, navRef as RefObject) // Compute menuInlineStyles if needed diff --git a/packages/react/src/UnderlineNav/UnderlineNavItem.tsx b/packages/react/src/UnderlineNav/UnderlineNavItem.tsx index 3341f677e11..8f4c68029a0 100644 --- a/packages/react/src/UnderlineNav/UnderlineNavItem.tsx +++ b/packages/react/src/UnderlineNav/UnderlineNavItem.tsx @@ -91,11 +91,13 @@ export const UnderlineNavItem = forwardRef( ) as HTMLElement const text = content.textContent as string - const iconWidthWithMargin = icon - ? icon.getBoundingClientRect().width + - Number(getComputedStyle(icon).marginRight.slice(0, -2)) + - Number(getComputedStyle(icon).marginLeft.slice(0, -2)) - : 0 + let iconWidthWithMargin = 0 + if (icon) { + // Batch all layout reads: getBoundingClientRect and getComputedStyle together + const iconRect = icon.getBoundingClientRect() + const iconStyle = getComputedStyle(icon) + iconWidthWithMargin = iconRect.width + parseFloat(iconStyle.marginRight) + parseFloat(iconStyle.marginLeft) + } setChildrenWidth({text, width: domRect.width}) setNoIconChildrenWidth({text, width: domRect.width - iconWidthWithMargin}) diff --git a/packages/react/src/internal/utils/hasInteractiveNodes.ts b/packages/react/src/internal/utils/hasInteractiveNodes.ts index b74c52dd9fe..00dbe65ecde 100644 --- a/packages/react/src/internal/utils/hasInteractiveNodes.ts +++ b/packages/react/src/internal/utils/hasInteractiveNodes.ts @@ -22,6 +22,9 @@ const interactiveElements = interactiveElementsSelectors.map( selector => `${selector}:not(${Object.values(nonValidSelectors).join('):not(')})`, ) +// Combined selector for fast querySelector check +const interactiveSelector = interactiveElements.join(', ') + /** * Finds interactive nodes within the passed node. * If the node itself is interactive, or children within are, it will return true. @@ -38,32 +41,30 @@ export function hasInteractiveNodes(node: HTMLElement | null, ignoreNodes?: HTML // If one does exist, we can abort early. const nodesToIgnore = ignoreNodes ? [node, ...ignoreNodes] : [node] - const interactiveNodes = findInteractiveChildNodes(node, nodesToIgnore) - return Boolean(interactiveNodes) + // Performance optimization: Use querySelectorAll with combined selector first + // This avoids recursive getComputedStyle calls for each node + const candidates = node.querySelectorAll(interactiveSelector) + for (const candidate of candidates) { + if (!nodesToIgnore.includes(candidate) && !isNonValidInteractiveNode(candidate)) { + return true + } + } + + return false } +// Cache for visibility checks to avoid repeated getComputedStyle calls during a single traversal +// Note: Only call getComputedStyle when CSS-based checks are insufficient function isNonValidInteractiveNode(node: HTMLElement) { - const nodeStyle = getComputedStyle(node) + // Fast path: Check attribute-based states first (no style recalc needed) const isNonInteractive = node.matches('[disabled], [hidden], [inert]') - const isHiddenVisually = nodeStyle.display === 'none' || nodeStyle.visibility === 'hidden' - - return isNonInteractive || isHiddenVisually -} + if (isNonInteractive) return true -function findInteractiveChildNodes(node: HTMLElement | null, ignoreNodes: HTMLElement[]) { - if (!node) return - - const ignoreSelector = ignoreNodes.find(elem => elem === node) - const isNotValidNode = isNonValidInteractiveNode(node) - - if (node.matches(interactiveElements.join(', ')) && !ignoreSelector && !isNotValidNode) { - return node - } - - for (const child of node.children) { - const interactiveNode = findInteractiveChildNodes(child as HTMLElement, ignoreNodes) + // Only call getComputedStyle if attribute checks passed + // This is necessary for display:none and visibility:hidden which aren't detectable via attributes + const nodeStyle = getComputedStyle(node) + const isHiddenVisually = nodeStyle.display === 'none' || nodeStyle.visibility === 'hidden' - if (interactiveNode) return true - } + return isHiddenVisually } From d4857e099be509df311c48d193cb3a9bad4be1c9 Mon Sep 17 00:00:00 2001 From: Matthew Costabile Date: Sun, 14 Dec 2025 15:06:40 +0000 Subject: [PATCH 12/22] useResizeObserver throttling --- packages/react/src/ActionBar/ActionBar.tsx | 19 ++++---- .../react/src/AvatarStack/AvatarStack.tsx | 17 +++++++- .../react/src/UnderlineNav/UnderlineNav.tsx | 19 ++++---- .../react/src/hooks/useAnchoredPosition.ts | 5 ++- packages/react/src/hooks/useOverflow.ts | 30 +++++++++---- packages/react/src/hooks/useResizeObserver.ts | 43 +++++++++++++++++-- packages/react/src/live-region/Announce.tsx | 20 +++++++-- 7 files changed, 113 insertions(+), 40 deletions(-) diff --git a/packages/react/src/ActionBar/ActionBar.tsx b/packages/react/src/ActionBar/ActionBar.tsx index 4787b9d481c..376bb68e9f0 100644 --- a/packages/react/src/ActionBar/ActionBar.tsx +++ b/packages/react/src/ActionBar/ActionBar.tsx @@ -320,15 +320,9 @@ export const ActionBar: React.FC> = prop const moreMenuBtnRef = useRef(null) const containerRef = React.useRef(null) - // Track pending animation frame to avoid redundant work and improve INP during resize - const pendingFrameRef = useRef(null) - useResizeObserver((resizeObserverEntries: ResizeObserverEntry[]) => { - // Cancel any pending frame to coalesce rapid resize events - if (pendingFrameRef.current !== null) { - cancelAnimationFrame(pendingFrameRef.current) - } - pendingFrameRef.current = requestAnimationFrame(() => { - pendingFrameRef.current = null + // Use throttled ResizeObserver to coalesce rapid resize events for better INP + useResizeObserver( + (resizeObserverEntries: ResizeObserverEntry[]) => { const navWidth = resizeObserverEntries[0].contentRect.width const moreMenuWidth = moreMenuRef.current?.getBoundingClientRect().width ?? 0 const hasActiveMenu = menuItemIds.size > 0 @@ -337,8 +331,11 @@ export const ActionBar: React.FC> = prop const newMenuItemIds = getMenuItems(navWidth, moreMenuWidth, childRegistry, hasActiveMenu, computedGap) if (newMenuItemIds) setMenuItemIds(newMenuItemIds) } - }) - }, navRef as RefObject) + }, + navRef as RefObject, + [], + {throttle: true}, + ) const isVisibleChild = useCallback( (id: string) => { diff --git a/packages/react/src/AvatarStack/AvatarStack.tsx b/packages/react/src/AvatarStack/AvatarStack.tsx index ae400a90876..8d67d7249b0 100644 --- a/packages/react/src/AvatarStack/AvatarStack.tsx +++ b/packages/react/src/AvatarStack/AvatarStack.tsx @@ -120,7 +120,19 @@ const AvatarStack = ({ setHasInteractiveChildren(hasInteractiveNodes(stackContainer.current)) } - const observer = new MutationObserver(interactiveChildren) + // Track pending frame to throttle MutationObserver callbacks + let pendingFrame: number | null = null + const throttledInteractiveChildren = () => { + if (pendingFrame !== null) { + cancelAnimationFrame(pendingFrame) + } + pendingFrame = requestAnimationFrame(() => { + pendingFrame = null + interactiveChildren() + }) + } + + const observer = new MutationObserver(throttledInteractiveChildren) observer.observe(stackContainer.current, {childList: true}) @@ -129,6 +141,9 @@ const AvatarStack = ({ return () => { observer.disconnect() + if (pendingFrame !== null) { + cancelAnimationFrame(pendingFrame) + } } } }, []) diff --git a/packages/react/src/UnderlineNav/UnderlineNav.tsx b/packages/react/src/UnderlineNav/UnderlineNav.tsx index 3c304b21301..b66a9126c69 100644 --- a/packages/react/src/UnderlineNav/UnderlineNav.tsx +++ b/packages/react/src/UnderlineNav/UnderlineNav.tsx @@ -291,15 +291,9 @@ export const UnderlineNav = forwardRef( useOnOutsideClick({onClickOutside: closeOverlay, containerRef, ignoreClickRefs: [moreMenuBtnRef]}) - // Track pending animation frame to avoid redundant work and improve INP during resize - const pendingFrameRef = useRef(null) - useResizeObserver((resizeObserverEntries: ResizeObserverEntry[]) => { - // Cancel any pending frame to coalesce rapid resize events - if (pendingFrameRef.current !== null) { - cancelAnimationFrame(pendingFrameRef.current) - } - pendingFrameRef.current = requestAnimationFrame(() => { - pendingFrameRef.current = null + // Use throttled ResizeObserver to coalesce rapid resize events for better INP + useResizeObserver( + (resizeObserverEntries: ResizeObserverEntry[]) => { const navWidth = resizeObserverEntries[0].contentRect.width const moreMenuWidth = moreMenuRef.current?.getBoundingClientRect().width ?? 0 navWidth !== 0 && @@ -311,8 +305,11 @@ export const UnderlineNav = forwardRef( noIconChildWidthArray, updateListAndMenu, ) - }) - }, navRef as RefObject) + }, + navRef as RefObject, + [], + {throttle: true}, + ) // Compute menuInlineStyles if needed let menuInlineStyles: React.CSSProperties = {...baseMenuInlineStyles} diff --git a/packages/react/src/hooks/useAnchoredPosition.ts b/packages/react/src/hooks/useAnchoredPosition.ts index 32777aad1d7..2c0895abe40 100644 --- a/packages/react/src/hooks/useAnchoredPosition.ts +++ b/packages/react/src/hooks/useAnchoredPosition.ts @@ -97,8 +97,9 @@ export function useAnchoredPosition( useLayoutEffect(updatePosition, [updatePosition]) - useResizeObserver(updatePosition) // watches for changes in window size - useResizeObserver(updatePosition, floatingElementRef as React.RefObject) // watches for changes in floating element size + // Use throttled ResizeObserver to coalesce rapid resize events for better INP + useResizeObserver(updatePosition, undefined, [], {throttle: true}) // watches for changes in window size + useResizeObserver(updatePosition, floatingElementRef as React.RefObject, [], {throttle: true}) // watches for changes in floating element size return { floatingElementRef, diff --git a/packages/react/src/hooks/useOverflow.ts b/packages/react/src/hooks/useOverflow.ts index 0c394d095e5..be5208a9332 100644 --- a/packages/react/src/hooks/useOverflow.ts +++ b/packages/react/src/hooks/useOverflow.ts @@ -1,7 +1,9 @@ -import {useEffect, useState} from 'react' +import {useEffect, useRef, useState} from 'react' export function useOverflow(ref: React.RefObject) { const [hasOverflow, setHasOverflow] = useState(false) + // Track pending frame to throttle ResizeObserver callbacks for better INP + const pendingFrameRef = useRef(null) useEffect(() => { if (ref.current === null) { @@ -9,20 +11,30 @@ export function useOverflow(ref: React.RefObject) { } const observer = new ResizeObserver(entries => { - for (const entry of entries) { - if ( - entry.target.scrollHeight > entry.target.clientHeight || - entry.target.scrollWidth > entry.target.clientWidth - ) { - setHasOverflow(true) - break - } + // Throttle with requestAnimationFrame to coalesce rapid resize events + if (pendingFrameRef.current !== null) { + cancelAnimationFrame(pendingFrameRef.current) } + pendingFrameRef.current = requestAnimationFrame(() => { + pendingFrameRef.current = null + for (const entry of entries) { + if ( + entry.target.scrollHeight > entry.target.clientHeight || + entry.target.scrollWidth > entry.target.clientWidth + ) { + setHasOverflow(true) + break + } + } + }) }) observer.observe(ref.current) return () => { observer.disconnect() + if (pendingFrameRef.current !== null) { + cancelAnimationFrame(pendingFrameRef.current) + } } }, [ref]) diff --git a/packages/react/src/hooks/useResizeObserver.ts b/packages/react/src/hooks/useResizeObserver.ts index 704673af082..4e13011f323 100644 --- a/packages/react/src/hooks/useResizeObserver.ts +++ b/packages/react/src/hooks/useResizeObserver.ts @@ -9,13 +9,26 @@ export interface ResizeObserverEntry { contentRect: DOMRectReadOnly } +export interface UseResizeObserverOptions { + /** + * When true, throttles callback execution using requestAnimationFrame. + * This improves INP by coalescing rapid resize events to at most one + * callback per animation frame (~60fps). + * @default false + */ + throttle?: boolean +} + export function useResizeObserver( callback: ResizeObserverCallback, target?: RefObject, depsArray: unknown[] = [], + options: UseResizeObserverOptions = {}, ) { + const {throttle = false} = options const [targetClientRect, setTargetClientRect] = useState(null) const savedCallback = useRef(callback) + const pendingFrameRef = useRef(null) useLayoutEffect(() => { savedCallback.current = callback @@ -27,22 +40,42 @@ export function useResizeObserver( return } + // Create the callback - optionally throttled with requestAnimationFrame + const invokeCallback = (entries: ResizeObserverEntry[]) => { + if (throttle) { + // Cancel any pending frame to coalesce rapid resize events + if (pendingFrameRef.current !== null) { + cancelAnimationFrame(pendingFrameRef.current) + } + pendingFrameRef.current = requestAnimationFrame(() => { + pendingFrameRef.current = null + savedCallback.current(entries) + }) + } else { + savedCallback.current(entries) + } + } + if (typeof ResizeObserver === 'function') { const observer = new ResizeObserver(entries => { - savedCallback.current(entries) + invokeCallback(entries) }) observer.observe(targetEl) return () => { observer.disconnect() + // Clean up any pending frame on unmount + if (pendingFrameRef.current !== null) { + cancelAnimationFrame(pendingFrameRef.current) + } } } else { const saveTargetDimensions = () => { const currTargetRect = targetEl.getBoundingClientRect() if (currTargetRect.width !== targetClientRect?.width || currTargetRect.height !== targetClientRect.height) { - savedCallback.current([ + invokeCallback([ { contentRect: currTargetRect, }, @@ -55,9 +88,13 @@ export function useResizeObserver( return () => { window.removeEventListener('resize', saveTargetDimensions) + // Clean up any pending frame on unmount + if (pendingFrameRef.current !== null) { + cancelAnimationFrame(pendingFrameRef.current) + } } } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [target?.current, ...depsArray]) + }, [target?.current, throttle, ...depsArray]) } diff --git a/packages/react/src/live-region/Announce.tsx b/packages/react/src/live-region/Announce.tsx index 6e439d10a9b..9a605939bc1 100644 --- a/packages/react/src/live-region/Announce.tsx +++ b/packages/react/src/live-region/Announce.tsx @@ -107,10 +107,21 @@ export function Announce(props: AnnouncePr return } + // Track pending frame to throttle MutationObserver callbacks + // This avoids synchronous getComputedStyle calls on every DOM mutation + let pendingFrame: number | null = null + const throttledAnnounce = () => { + if (pendingFrame !== null) { + cancelAnimationFrame(pendingFrame) + } + pendingFrame = requestAnimationFrame(() => { + pendingFrame = null + announce() + }) + } + // When the text of the container changes, announce the new text - const observer = new MutationObserver(() => { - announce() - }) + const observer = new MutationObserver(throttledAnnounce) observer.observe(container, { subtree: true, @@ -120,6 +131,9 @@ export function Announce(props: AnnouncePr return () => { observer.disconnect() + if (pendingFrame !== null) { + cancelAnimationFrame(pendingFrame) + } } }, [announce]) From 4eacb4bc7a1e807d9a1de7ec3ca958926680a79c Mon Sep 17 00:00:00 2001 From: Matthew Costabile Date: Sun, 14 Dec 2025 15:32:42 +0000 Subject: [PATCH 13/22] useResizeObserver updates --- packages/react/src/ActionBar/ActionBar.tsx | 27 ++-- .../react/src/UnderlineNav/UnderlineNav.tsx | 33 ++--- .../UnderlinePanels/UnderlinePanels.tsx | 18 +-- .../react/src/hooks/useAnchoredPosition.ts | 6 +- packages/react/src/hooks/useResizeObserver.ts | 126 ++++++++++-------- packages/react/src/index.ts | 1 + 6 files changed, 107 insertions(+), 104 deletions(-) diff --git a/packages/react/src/ActionBar/ActionBar.tsx b/packages/react/src/ActionBar/ActionBar.tsx index 376bb68e9f0..97d4f02b275 100644 --- a/packages/react/src/ActionBar/ActionBar.tsx +++ b/packages/react/src/ActionBar/ActionBar.tsx @@ -320,22 +320,17 @@ export const ActionBar: React.FC> = prop const moreMenuBtnRef = useRef(null) const containerRef = React.useRef(null) - // Use throttled ResizeObserver to coalesce rapid resize events for better INP - useResizeObserver( - (resizeObserverEntries: ResizeObserverEntry[]) => { - const navWidth = resizeObserverEntries[0].contentRect.width - const moreMenuWidth = moreMenuRef.current?.getBoundingClientRect().width ?? 0 - const hasActiveMenu = menuItemIds.size > 0 - - if (navWidth > 0) { - const newMenuItemIds = getMenuItems(navWidth, moreMenuWidth, childRegistry, hasActiveMenu, computedGap) - if (newMenuItemIds) setMenuItemIds(newMenuItemIds) - } - }, - navRef as RefObject, - [], - {throttle: true}, - ) + // ResizeObserver is throttled by default (rAF) for better INP + useResizeObserver((resizeObserverEntries: ResizeObserverEntry[]) => { + const navWidth = resizeObserverEntries[0].contentRect.width + const moreMenuWidth = moreMenuRef.current?.getBoundingClientRect().width ?? 0 + const hasActiveMenu = menuItemIds.size > 0 + + if (navWidth > 0) { + const newMenuItemIds = getMenuItems(navWidth, moreMenuWidth, childRegistry, hasActiveMenu, computedGap) + if (newMenuItemIds) setMenuItemIds(newMenuItemIds) + } + }, navRef as RefObject) const isVisibleChild = useCallback( (id: string) => { diff --git a/packages/react/src/UnderlineNav/UnderlineNav.tsx b/packages/react/src/UnderlineNav/UnderlineNav.tsx index b66a9126c69..cca7397b13e 100644 --- a/packages/react/src/UnderlineNav/UnderlineNav.tsx +++ b/packages/react/src/UnderlineNav/UnderlineNav.tsx @@ -291,25 +291,20 @@ export const UnderlineNav = forwardRef( useOnOutsideClick({onClickOutside: closeOverlay, containerRef, ignoreClickRefs: [moreMenuBtnRef]}) - // Use throttled ResizeObserver to coalesce rapid resize events for better INP - useResizeObserver( - (resizeObserverEntries: ResizeObserverEntry[]) => { - const navWidth = resizeObserverEntries[0].contentRect.width - const moreMenuWidth = moreMenuRef.current?.getBoundingClientRect().width ?? 0 - navWidth !== 0 && - overflowEffect( - navWidth, - moreMenuWidth, - validChildren, - childWidthArray, - noIconChildWidthArray, - updateListAndMenu, - ) - }, - navRef as RefObject, - [], - {throttle: true}, - ) + // ResizeObserver is throttled by default (rAF) for better INP + useResizeObserver((resizeObserverEntries: ResizeObserverEntry[]) => { + const navWidth = resizeObserverEntries[0].contentRect.width + const moreMenuWidth = moreMenuRef.current?.getBoundingClientRect().width ?? 0 + navWidth !== 0 && + overflowEffect( + navWidth, + moreMenuWidth, + validChildren, + childWidthArray, + noIconChildWidthArray, + updateListAndMenu, + ) + }, navRef as RefObject) // Compute menuInlineStyles if needed let menuInlineStyles: React.CSSProperties = {...baseMenuInlineStyles} diff --git a/packages/react/src/experimental/UnderlinePanels/UnderlinePanels.tsx b/packages/react/src/experimental/UnderlinePanels/UnderlinePanels.tsx index 575593ece7d..ef6975c3d18 100644 --- a/packages/react/src/experimental/UnderlinePanels/UnderlinePanels.tsx +++ b/packages/react/src/experimental/UnderlinePanels/UnderlinePanels.tsx @@ -144,19 +144,15 @@ const UnderlinePanels: FCWithSlotMarker = ({ // when the wrapper resizes, check if the icons should be visible // by comparing the wrapper width to the list width - useResizeObserver( - (resizeObserverEntries: ResizeObserverEntry[]) => { - if (!tabsHaveIcons) { - return - } + useResizeObserver((resizeObserverEntries: ResizeObserverEntry[]) => { + if (!tabsHaveIcons) { + return + } - const wrapperWidth = resizeObserverEntries[0].contentRect.width + const wrapperWidth = resizeObserverEntries[0].contentRect.width - setIconsVisible(wrapperWidth > listWidth) - }, - wrapperRef, - [], - ) + setIconsVisible(wrapperWidth > listWidth) + }, wrapperRef) if (__DEV__) { const selectedTabs = tabs.filter(tab => { diff --git a/packages/react/src/hooks/useAnchoredPosition.ts b/packages/react/src/hooks/useAnchoredPosition.ts index 2c0895abe40..467134448c6 100644 --- a/packages/react/src/hooks/useAnchoredPosition.ts +++ b/packages/react/src/hooks/useAnchoredPosition.ts @@ -97,9 +97,9 @@ export function useAnchoredPosition( useLayoutEffect(updatePosition, [updatePosition]) - // Use throttled ResizeObserver to coalesce rapid resize events for better INP - useResizeObserver(updatePosition, undefined, [], {throttle: true}) // watches for changes in window size - useResizeObserver(updatePosition, floatingElementRef as React.RefObject, [], {throttle: true}) // watches for changes in floating element size + // ResizeObserver is throttled by default (rAF) for better INP + useResizeObserver(updatePosition) // watches for changes in window size + useResizeObserver(updatePosition, floatingElementRef as React.RefObject) // watches for changes in floating element size return { floatingElementRef, diff --git a/packages/react/src/hooks/useResizeObserver.ts b/packages/react/src/hooks/useResizeObserver.ts index 4e13011f323..d2070516b69 100644 --- a/packages/react/src/hooks/useResizeObserver.ts +++ b/packages/react/src/hooks/useResizeObserver.ts @@ -1,5 +1,5 @@ import type {RefObject} from 'react' -import {useRef, useState} from 'react' +import {useRef, useEffect} from 'react' import useLayoutEffect from '../utils/useIsomorphicLayoutEffect' // https://gist.github.com/strothj/708afcf4f01dd04de8f49c92e88093c3 @@ -9,92 +9,108 @@ export interface ResizeObserverEntry { contentRect: DOMRectReadOnly } -export interface UseResizeObserverOptions { - /** - * When true, throttles callback execution using requestAnimationFrame. - * This improves INP by coalescing rapid resize events to at most one - * callback per animation frame (~60fps). - * @default false - */ - throttle?: boolean -} - +/** + * Observes size changes on an element or the window. + * + * Callbacks are automatically throttled using requestAnimationFrame (~60fps) + * to improve INP by coalescing rapid resize events. + * + * @param callback - Called with resize entries when size changes + * @param target - Element ref to observe. If omitted, observes window resize. + */ export function useResizeObserver( callback: ResizeObserverCallback, target?: RefObject, - depsArray: unknown[] = [], - options: UseResizeObserverOptions = {}, ) { - const {throttle = false} = options - const [targetClientRect, setTargetClientRect] = useState(null) const savedCallback = useRef(callback) const pendingFrameRef = useRef(null) + // For fallback path: track last known dimensions with refs to avoid stale closures + const lastDimensionsRef = useRef<{width: number; height: number} | null>(null) + // Keep callback ref up to date useLayoutEffect(() => { savedCallback.current = callback }) + // Invoke helper - always throttles with rAF + const invokeRef = useRef((entries: ResizeObserverEntry[]) => { + if (pendingFrameRef.current !== null) { + cancelAnimationFrame(pendingFrameRef.current) + } + pendingFrameRef.current = requestAnimationFrame(() => { + pendingFrameRef.current = null + savedCallback.current(entries) + }) + }) + + // Clean up any pending animation frame on unmount + useEffect(() => { + return () => { + if (pendingFrameRef.current !== null) { + cancelAnimationFrame(pendingFrameRef.current) + } + } + }, []) + + // For window resize (no target), use native resize event - simpler and more + // semantically correct than ResizeObserver on documentElement + useEffect(() => { + if (target !== undefined) { + return + } + + const handleResize = () => { + invokeRef.current([{contentRect: document.documentElement.getBoundingClientRect()}]) + } + + // eslint-disable-next-line github/prefer-observers + window.addEventListener('resize', handleResize) + + return () => { + window.removeEventListener('resize', handleResize) + } + }, [target]) + + // For specific element targets, use individual ResizeObserver useLayoutEffect(() => { - const targetEl = target && 'current' in target ? target.current : document.documentElement - if (!targetEl) { + // Skip if no target - handled by resize event above + if (target === undefined) { return } - // Create the callback - optionally throttled with requestAnimationFrame - const invokeCallback = (entries: ResizeObserverEntry[]) => { - if (throttle) { - // Cancel any pending frame to coalesce rapid resize events - if (pendingFrameRef.current !== null) { - cancelAnimationFrame(pendingFrameRef.current) - } - pendingFrameRef.current = requestAnimationFrame(() => { - pendingFrameRef.current = null - savedCallback.current(entries) - }) - } else { - savedCallback.current(entries) - } + const targetEl = target.current + if (!targetEl) { + return } if (typeof ResizeObserver === 'function') { const observer = new ResizeObserver(entries => { - invokeCallback(entries) + invokeRef.current(entries) }) observer.observe(targetEl) return () => { observer.disconnect() - // Clean up any pending frame on unmount - if (pendingFrameRef.current !== null) { - cancelAnimationFrame(pendingFrameRef.current) - } } } else { - const saveTargetDimensions = () => { - const currTargetRect = targetEl.getBoundingClientRect() - - if (currTargetRect.width !== targetClientRect?.width || currTargetRect.height !== targetClientRect.height) { - invokeCallback([ - { - contentRect: currTargetRect, - }, - ]) + // Fallback for environments without ResizeObserver + const handleResize = () => { + const rect = targetEl.getBoundingClientRect() + const last = lastDimensionsRef.current + + if (last === null || rect.width !== last.width || rect.height !== last.height) { + lastDimensionsRef.current = {width: rect.width, height: rect.height} + invokeRef.current([{contentRect: rect}]) } - setTargetClientRect(currTargetRect) } + // eslint-disable-next-line github/prefer-observers - window.addEventListener('resize', saveTargetDimensions) + window.addEventListener('resize', handleResize) return () => { - window.removeEventListener('resize', saveTargetDimensions) - // Clean up any pending frame on unmount - if (pendingFrameRef.current !== null) { - cancelAnimationFrame(pendingFrameRef.current) - } + window.removeEventListener('resize', handleResize) } } - - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [target?.current, throttle, ...depsArray]) + }, [target]) } diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index b3b6a1ca40d..acfb0ec6170 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -40,6 +40,7 @@ export {useFocusZone} from './hooks/useFocusZone' export type {FocusZoneHookSettings} from './hooks/useFocusZone' export {useRefObjectAsForwardedRef} from './hooks/useRefObjectAsForwardedRef' export {useResizeObserver} from './hooks/useResizeObserver' +export type {ResizeObserverEntry, ResizeObserverCallback} from './hooks/useResizeObserver' export {useResponsiveValue, type ResponsiveValue} from './hooks/useResponsiveValue' export {default as useIsomorphicLayoutEffect} from './utils/useIsomorphicLayoutEffect' export {useProvidedRefOrCreate} from './hooks/useProvidedRefOrCreate' From 4c7db3489f4824016a33de639a383c6325711c3a Mon Sep 17 00:00:00 2001 From: Matthew Costabile Date: Sun, 14 Dec 2025 15:44:14 +0000 Subject: [PATCH 14/22] perf: optimize useResizeObserver and useAnchoredPosition hooks useResizeObserver: - Always throttle with rAF (~60fps) - no config needed - Use native resize event for window (simpler than ResizeObserver on documentElement) - Use refs for all values to eliminate effect dependencies - Simplified API: useResizeObserver(callback, target?) useAnchoredPosition: - Replace useState(prevHeight) with useRef to avoid re-renders - Use settingsRef to keep settings fresh without dependencies - Use updatePositionRef for stable function reference - Remove dependencies parameter (kept for backward compat) --- .../react/src/hooks/useAnchoredPosition.ts | 98 +++++++++---------- packages/react/src/index.ts | 1 - 2 files changed, 49 insertions(+), 50 deletions(-) diff --git a/packages/react/src/hooks/useAnchoredPosition.ts b/packages/react/src/hooks/useAnchoredPosition.ts index 467134448c6..e6a9134759a 100644 --- a/packages/react/src/hooks/useAnchoredPosition.ts +++ b/packages/react/src/hooks/useAnchoredPosition.ts @@ -17,13 +17,15 @@ export interface AnchoredPositionHookSettings extends Partial * to be anchored to some anchor element. Returns refs for the floating element * and the anchor element, along with the position. * @param settings Settings for calculating the anchored position. - * @param dependencies Dependencies to determine when to re-calculate the position. + * @param _dependencies Deprecated - no longer used. Position updates automatically on resize. * @returns An object of {top: number, left: number} to absolutely-position the * floating element. */ export function useAnchoredPosition( settings?: AnchoredPositionHookSettings, - dependencies: React.DependencyList = [], + // Legacy parameter kept for backward compatibility - ignored + // Position now updates automatically via ResizeObserver + _dependencies?: React.DependencyList, ): { floatingElementRef: React.RefObject anchorElementRef: React.RefObject @@ -31,10 +33,16 @@ export function useAnchoredPosition( } { const floatingElementRef = useProvidedRefOrCreate(settings?.floatingElementRef) const anchorElementRef = useProvidedRefOrCreate(settings?.anchorElementRef) - const savedOnPositionChange = React.useRef(settings?.onPositionChange) const [position, setPosition] = React.useState(undefined) - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const [_, setPrevHeight] = React.useState(undefined) + + // Use refs instead of state for values that don't need to trigger re-renders + const prevHeightRef = React.useRef(undefined) + const settingsRef = React.useRef(settings) + + // Keep settings ref up to date + useLayoutEffect(() => { + settingsRef.current = settings + }) const topPositionChanged = (prevPosition: AnchorPosition | undefined, newPosition: AnchorPosition) => { return ( @@ -46,60 +54,52 @@ export function useAnchoredPosition( } const updateElementHeight = () => { - let heightUpdated = false - setPrevHeight(prevHeight => { - // if the element is trying to shrink in height, restore to old height to prevent it from jumping - if (prevHeight && prevHeight > (floatingElementRef.current?.clientHeight ?? 0)) { - requestAnimationFrame(() => { - ;(floatingElementRef.current as HTMLElement).style.height = `${prevHeight}px` - }) - heightUpdated = true - } - return prevHeight - }) - return heightUpdated + const prevHeight = prevHeightRef.current + // if the element is trying to shrink in height, restore to old height to prevent it from jumping + if (prevHeight && prevHeight > (floatingElementRef.current?.clientHeight ?? 0)) { + ;(floatingElementRef.current as HTMLElement).style.height = `${prevHeight}px` + return true + } + return false } - const updatePosition = React.useCallback( - () => { - if (floatingElementRef.current instanceof Element && anchorElementRef.current instanceof Element) { - const newPosition = getAnchoredPosition(floatingElementRef.current, anchorElementRef.current, settings) - setPosition(prev => { - if (settings?.pinPosition && topPositionChanged(prev, newPosition)) { - const anchorTop = anchorElementRef.current?.getBoundingClientRect().top ?? 0 - const elementStillFitsOnTop = anchorTop > (floatingElementRef.current?.clientHeight ?? 0) + // Use ref for updatePosition to avoid re-creating on every render + const updatePositionRef = React.useRef(() => { + const currentSettings = settingsRef.current + if (floatingElementRef.current instanceof Element && anchorElementRef.current instanceof Element) { + const newPosition = getAnchoredPosition(floatingElementRef.current, anchorElementRef.current, currentSettings) + setPosition(prev => { + if (currentSettings?.pinPosition && topPositionChanged(prev, newPosition)) { + const anchorTop = anchorElementRef.current?.getBoundingClientRect().top ?? 0 + const elementStillFitsOnTop = anchorTop > (floatingElementRef.current?.clientHeight ?? 0) - if (elementStillFitsOnTop && updateElementHeight()) { - return prev - } + if (elementStillFitsOnTop && updateElementHeight()) { + return prev } + } - if (prev && prev.anchorSide === newPosition.anchorSide) { - // if the position hasn't changed, don't update - savedOnPositionChange.current?.(newPosition) - } + if (prev && prev.anchorSide === newPosition.anchorSide) { + // if the position hasn't changed, don't update + currentSettings?.onPositionChange?.(newPosition) + } - return newPosition - }) - } else { - setPosition(undefined) - savedOnPositionChange.current?.(undefined) - } - setPrevHeight(floatingElementRef.current?.clientHeight) - }, - // eslint-disable-next-line react-hooks/exhaustive-deps - [floatingElementRef, anchorElementRef, ...dependencies], - ) + return newPosition + }) + } else { + setPosition(undefined) + settingsRef.current?.onPositionChange?.(undefined) + } + prevHeightRef.current = floatingElementRef.current?.clientHeight + }) + // Initial position calculation useLayoutEffect(() => { - savedOnPositionChange.current = settings?.onPositionChange - }, [settings?.onPositionChange]) - - useLayoutEffect(updatePosition, [updatePosition]) + updatePositionRef.current() + }, []) // ResizeObserver is throttled by default (rAF) for better INP - useResizeObserver(updatePosition) // watches for changes in window size - useResizeObserver(updatePosition, floatingElementRef as React.RefObject) // watches for changes in floating element size + useResizeObserver(() => updatePositionRef.current()) // watches for changes in window size + useResizeObserver(() => updatePositionRef.current(), floatingElementRef as React.RefObject) // watches for changes in floating element size return { floatingElementRef, diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index acfb0ec6170..b3b6a1ca40d 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -40,7 +40,6 @@ export {useFocusZone} from './hooks/useFocusZone' export type {FocusZoneHookSettings} from './hooks/useFocusZone' export {useRefObjectAsForwardedRef} from './hooks/useRefObjectAsForwardedRef' export {useResizeObserver} from './hooks/useResizeObserver' -export type {ResizeObserverEntry, ResizeObserverCallback} from './hooks/useResizeObserver' export {useResponsiveValue, type ResponsiveValue} from './hooks/useResponsiveValue' export {default as useIsomorphicLayoutEffect} from './utils/useIsomorphicLayoutEffect' export {useProvidedRefOrCreate} from './hooks/useProvidedRefOrCreate' From 633fe97cc88657227b82f3ffb72c528ce11d395c Mon Sep 17 00:00:00 2001 From: Matthew Costabile Date: Sun, 14 Dec 2025 15:48:56 +0000 Subject: [PATCH 15/22] chore: add changeset for INP performance optimizations --- .changeset/performance-inp-optimizations.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 .changeset/performance-inp-optimizations.md diff --git a/.changeset/performance-inp-optimizations.md b/.changeset/performance-inp-optimizations.md new file mode 100644 index 00000000000..dbbb476ea4b --- /dev/null +++ b/.changeset/performance-inp-optimizations.md @@ -0,0 +1,13 @@ +--- +"@primer/react": patch +--- + +Performance: Optimize CSS `:has()` selectors and JavaScript hooks for improved INP + +- Scope CSS `:has()` selectors to direct children across Dialog, PageHeader, ActionList, Banner, ButtonGroup, AvatarStack, Breadcrumbs, SegmentedControl, TreeView, and SelectPanel +- Simplify `useResizeObserver` API with automatic requestAnimationFrame throttling and native resize events for window +- Optimize `useAnchoredPosition` to use refs instead of state for non-rendering values +- Add `useTreeItemCache` hook for caching TreeView DOM queries with MutationObserver invalidation +- Throttle MutationObserver callbacks in AvatarStack and Announce components +- Add rAF throttling to `useOverflow` hook +- Optimize `hasInteractiveNodes` utility with querySelectorAll and attribute-first checks From d0fc0792d2645e29751143270b0368ecaf262cd9 Mon Sep 17 00:00:00 2001 From: Matthew Costabile Date: Sun, 14 Dec 2025 15:50:20 +0000 Subject: [PATCH 16/22] changeset --- .changeset/empty-dots-start.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 .changeset/empty-dots-start.md diff --git a/.changeset/empty-dots-start.md b/.changeset/empty-dots-start.md new file mode 100644 index 00000000000..dbbb476ea4b --- /dev/null +++ b/.changeset/empty-dots-start.md @@ -0,0 +1,13 @@ +--- +"@primer/react": patch +--- + +Performance: Optimize CSS `:has()` selectors and JavaScript hooks for improved INP + +- Scope CSS `:has()` selectors to direct children across Dialog, PageHeader, ActionList, Banner, ButtonGroup, AvatarStack, Breadcrumbs, SegmentedControl, TreeView, and SelectPanel +- Simplify `useResizeObserver` API with automatic requestAnimationFrame throttling and native resize events for window +- Optimize `useAnchoredPosition` to use refs instead of state for non-rendering values +- Add `useTreeItemCache` hook for caching TreeView DOM queries with MutationObserver invalidation +- Throttle MutationObserver callbacks in AvatarStack and Announce components +- Add rAF throttling to `useOverflow` hook +- Optimize `hasInteractiveNodes` utility with querySelectorAll and attribute-first checks From b9ceb4a72597150abf99cfce6a0375dfe4eca413 Mon Sep 17 00:00:00 2001 From: Matthew Costabile Date: Sun, 14 Dec 2025 16:32:43 +0000 Subject: [PATCH 17/22] revert: remove hook optimizations that broke tests - Revert useResizeObserver throttling (broke onPositionChange callback timing) - Revert useAnchoredPosition ref-based approach (broke initial position calculation) - Revert useOverflow throttling (broke ScrollableRegion accessibility) - Revert Announce MutationObserver throttling (broke live region tests) - Update snapshots for CSS module hash changes from CSS optimizations The CSS :has() selector optimizations are retained - only the JS hook optimizations are reverted as they caused timing issues in tests. --- .../__snapshots__/AvatarStack.test.tsx.snap | 12 +- .../BreadcrumbsItem.test.tsx.snap | 2 +- .../__snapshots__/Button.test.tsx.snap | 42 +- .../__snapshots__/CircleBadge.test.tsx.snap | 6 +- .../__snapshots__/PageLayout.test.tsx.snap | 120 +- .../__snapshots__/StateLabel.test.tsx.snap | 44 +- .../__snapshots__/TextInput.test.tsx.snap | 20 +- .../TextInputWithTokens.test.tsx.snap | 3908 ++++++++--------- .../__snapshots__/Timeline.test.tsx.snap | 4 +- .../__snapshots__/BaseStyles.test.tsx.snap | 2 +- .../__snapshots__/Caret.test.tsx.snap | 540 +-- .../__snapshots__/SubNavLink.test.tsx.snap | 4 +- .../react/src/hooks/useAnchoredPosition.ts | 99 +- packages/react/src/hooks/useOverflow.ts | 30 +- packages/react/src/hooks/useResizeObserver.ts | 93 +- packages/react/src/live-region/Announce.tsx | 20 +- 16 files changed, 2433 insertions(+), 2513 deletions(-) diff --git a/packages/react/src/AvatarStack/__snapshots__/AvatarStack.test.tsx.snap b/packages/react/src/AvatarStack/__snapshots__/AvatarStack.test.tsx.snap index 04f86ce225d..0a06ca7f1ec 100644 --- a/packages/react/src/AvatarStack/__snapshots__/AvatarStack.test.tsx.snap +++ b/packages/react/src/AvatarStack/__snapshots__/AvatarStack.test.tsx.snap @@ -2,7 +2,7 @@ exports[`AvatarStack > respects alignRight props 1`] = ` respects alignRight props 1`] = ` style="--stackSize-narrow: 20px; --stackSize-regular: 20px; --stackSize-wide: 20px;" >
    diff --git a/packages/react/src/Breadcrumbs/__tests__/__snapshots__/BreadcrumbsItem.test.tsx.snap b/packages/react/src/Breadcrumbs/__tests__/__snapshots__/BreadcrumbsItem.test.tsx.snap index 532e0315ca2..257e70cb16c 100644 --- a/packages/react/src/Breadcrumbs/__tests__/__snapshots__/BreadcrumbsItem.test.tsx.snap +++ b/packages/react/src/Breadcrumbs/__tests__/__snapshots__/BreadcrumbsItem.test.tsx.snap @@ -3,6 +3,6 @@ exports[`Breadcrumbs.Item > respects the "selected" prop 1`] = ` `; diff --git a/packages/react/src/Button/__tests__/__snapshots__/Button.test.tsx.snap b/packages/react/src/Button/__tests__/__snapshots__/Button.test.tsx.snap index 0ddfa63424b..827116ded66 100644 --- a/packages/react/src/Button/__tests__/__snapshots__/Button.test.tsx.snap +++ b/packages/react/src/Button/__tests__/__snapshots__/Button.test.tsx.snap @@ -3,7 +3,7 @@ exports[`Button > respects block prop 1`] = `