From c6d0a12d7dacc7bc988ba166c9a489c8e98c398f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 23 Jun 2026 01:31:29 +0000 Subject: [PATCH 1/8] Initial plan From 6286dc88cd342f01359e7a55e2eda6a254efd7d7 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 23 Jun 2026 01:40:24 +0000 Subject: [PATCH 2/8] feat: switch overflow observers to entry signals Co-authored-by: mattcosta7 <8616962+mattcosta7@users.noreply.github.com> --- .../underline-nav-shared-overflow-observer.md | 2 +- packages/react/src/ActionBar/ActionBar.tsx | 21 ++-- .../react/src/UnderlineNav/UnderlineNav.tsx | 2 +- .../src/UnderlineNav/UnderlineNavItem.tsx | 5 +- .../UnderlineNav/UnderlineNavItemsRegistry.ts | 6 +- .../__tests__/descendant-registry.test.tsx | 77 +++++++++--- .../react/src/utils/descendant-registry.tsx | 119 ++++++++++++------ 7 files changed, 160 insertions(+), 72 deletions(-) diff --git a/.changeset/underline-nav-shared-overflow-observer.md b/.changeset/underline-nav-shared-overflow-observer.md index 087b9fc0a1c..da950989c4e 100644 --- a/.changeset/underline-nav-shared-overflow-observer.md +++ b/.changeset/underline-nav-shared-overflow-observer.md @@ -2,4 +2,4 @@ "@primer/react": patch --- -Internal: `UnderlineNav` and `ActionBar` overflow detection now shares a single `IntersectionObserver` per component (owned by the descendant registry) instead of creating one observer per item, and the registry coalesces rebuilds so multiple items crossing the overflow boundary in the same frame trigger a single rebuild. This reduces re-render churn and observer allocations during resize. No public API changes. +Internal: `UnderlineNav` and `ActionBar` overflow detection now uses a shared root-scoped `IntersectionObserver` per component, and the registry coalesces same-frame rebuilds. This reduces observer churn during resize with no public API changes. diff --git a/packages/react/src/ActionBar/ActionBar.tsx b/packages/react/src/ActionBar/ActionBar.tsx index f61dfdd5b03..d0827364ca5 100644 --- a/packages/react/src/ActionBar/ActionBar.tsx +++ b/packages/react/src/ActionBar/ActionBar.tsx @@ -139,11 +139,9 @@ export type ActionBarMenuProps = { returnFocusRef?: React.RefObject } & IconButtonProps -// Items opt into a single shared IntersectionObserver (threshold 0.75) via `useRegisterOverflowObserver` instead of -// each item creating its own observer. 0.75 is used (rather than 1) because in some scenarios a threshold of 1 doesn't -// trigger correctly - the browser still thinks a tiny bit of the button is not visible since the container height is -// exactly the button height. See `useActionBarItem`. -const ActionBarItemsRegistry = createDescendantRegistry({overflow: {threshold: 0.75}}) +// Items opt into a single shared IntersectionObserver via `useRegisterOverflowObserver` instead of each item creating +// its own observer. +const ActionBarItemsRegistry = createDescendantRegistry({overflow: {}}) const FOCUSABLE_ITEM_SELECTOR = ':is(button, a, input, [tabindex]):not(:disabled):not([data-overflowing]):not([data-more-button-inactive])' @@ -207,6 +205,7 @@ export const ActionBar: React.FC> = ({ gap = 'condensed', }) => { const [childRegistry, setChildRegistry] = ActionBarItemsRegistry.useRegistryState() + const overflowContainerRef = useRef(null) const overflowItems = useMemo( () => @@ -237,10 +236,12 @@ export const ActionBar: React.FC> = ({ data-size={size} data-has-overflow={overflowItems ? overflowItems.length > 0 : undefined} > -
+
{/* An empty first element allows the real first item to wrap to the next line and get clipped. */}
- {children} + + {children} +
@@ -314,12 +315,8 @@ function useActionBarItem(ref: React.RefObject, registryProp const isGroupOverflowing = useContext(ActionBarGroupContext)?.isOverflowing const isInGroup = isGroupOverflowing !== undefined - // Subscribe to the registry's shared IntersectionObserver to detect overflow. The observer is just being used as a - // trigger to re-check `offsetTop > 0`; this is fast and simpler than checking visibility from the observed entry. - // When an item wraps, it moves to the next row which increases its `offsetTop`. - // // There's no need to observe items inside of a group since the entire group overflows at once, so `disabled` skips - // subscription for grouped items (the snapshot returns `false`, matching the previous early-return behavior). + // subscription for grouped items and always reports `false` for the child item itself. const isItemOverflowing = ActionBarItemsRegistry.useRegisterOverflowObserver(ref, {disabled: isInGroup}) const isOverflowing = isGroupOverflowing || isItemOverflowing diff --git a/packages/react/src/UnderlineNav/UnderlineNav.tsx b/packages/react/src/UnderlineNav/UnderlineNav.tsx index ca085e71689..bf95c79284e 100644 --- a/packages/react/src/UnderlineNav/UnderlineNav.tsx +++ b/packages/react/src/UnderlineNav/UnderlineNav.tsx @@ -101,7 +101,7 @@ export const UnderlineNav = forwardRef( data-has-overflow={isOverflowing ? 'true' : undefined} > - + {children} diff --git a/packages/react/src/UnderlineNav/UnderlineNavItem.tsx b/packages/react/src/UnderlineNav/UnderlineNavItem.tsx index e2104a0233e..3acf653a25e 100644 --- a/packages/react/src/UnderlineNav/UnderlineNavItem.tsx +++ b/packages/react/src/UnderlineNav/UnderlineNavItem.tsx @@ -22,9 +22,8 @@ export const UnderlineNavItem = forwardRef((allProps, forwardedRef) => { const {loadingCounters} = useContext(UnderlineNavContext) - // Subscribe to the registry's shared IntersectionObserver. The observer is just being used as a trigger to - // re-check `offsetTop > 0`; this is fast and simpler than checking visibility from the observed entry. When an - // item wraps, it moves to the next (clipped) row, which increases its `offsetTop`. + // Observe the wrapping `
  • ` directly so a root-scoped IntersectionObserver can detect when the item is clipped + // onto the hidden next row. const isOverflowing = UnderlineNavItemsRegistry.useRegisterOverflowObserver(ref) UnderlineNavItemsRegistry.useRegisterDescendant(isOverflowing ? allProps : null) diff --git a/packages/react/src/UnderlineNav/UnderlineNavItemsRegistry.ts b/packages/react/src/UnderlineNav/UnderlineNavItemsRegistry.ts index 75a3b525863..f85bcd608c6 100644 --- a/packages/react/src/UnderlineNav/UnderlineNavItemsRegistry.ts +++ b/packages/react/src/UnderlineNav/UnderlineNavItemsRegistry.ts @@ -56,9 +56,9 @@ export type UnderlineNavItemProps = { /** * Registry of currently-overflowing underline items. If an item is not overflowing, its value will be `null`. * - * Items opt into a single shared IntersectionObserver (threshold 1) via `useRegisterOverflowObserver` instead of - * each item creating its own observer. + * Items opt into a single shared IntersectionObserver via `useRegisterOverflowObserver` instead of each item creating + * its own observer. */ export const UnderlineNavItemsRegistry = createDescendantRegistry({ - overflow: {threshold: 1}, + overflow: {}, }) diff --git a/packages/react/src/utils/__tests__/descendant-registry.test.tsx b/packages/react/src/utils/__tests__/descendant-registry.test.tsx index 39fa167a191..67079262df2 100644 --- a/packages/react/src/utils/__tests__/descendant-registry.test.tsx +++ b/packages/react/src/utils/__tests__/descendant-registry.test.tsx @@ -49,7 +49,7 @@ describe('createDescendantRegistry', () => { - , + ) expect(getByTestId('registry-values').textContent).toBe('a,b,c') @@ -65,7 +65,7 @@ describe('createDescendantRegistry', () => { - , + ) expect(getByTestId('registry-values').textContent).toBe('a,b,c') @@ -302,13 +302,15 @@ describe('createDescendantRegistry coalesced rebuilds', () => { describe('createDescendantRegistry shared IntersectionObserver', () => { // Capture every IntersectionObserver instance and its observed elements so we can assert a single shared observer // is used and drive its callback manually. + type MockEntry = Pick type FakeObserver = { callback: IntersectionObserverCallback + options?: IntersectionObserverInit observed: Set observe: ReturnType unobserve: ReturnType disconnect: ReturnType - trigger: () => void + emit: (entries: MockEntry[]) => void } let observers: FakeObserver[] = [] @@ -316,6 +318,7 @@ describe('createDescendantRegistry shared IntersectionObserver', () => { observers = [] class MockIntersectionObserver { callback: IntersectionObserverCallback + options?: IntersectionObserverInit observed = new Set() observe = vi.fn((el: Element) => { this.observed.add(el) @@ -326,14 +329,15 @@ describe('createDescendantRegistry shared IntersectionObserver', () => { disconnect = vi.fn(() => { this.observed.clear() }) - constructor(callback: IntersectionObserverCallback) { + constructor(callback: IntersectionObserverCallback, options?: IntersectionObserverInit) { this.callback = callback + this.options = options observers.push(this as unknown as FakeObserver) - ;(this as unknown as FakeObserver).trigger = () => { - const entries = Array.from(this.observed).map( - target => ({target}) as IntersectionObserverEntry, + ;(this as unknown as FakeObserver).emit = entries => { + this.callback( + entries.map(entry => entry as IntersectionObserverEntry), + this as unknown as IntersectionObserver, ) - this.callback(entries, this as unknown as IntersectionObserver) } } takeRecords() { @@ -353,11 +357,18 @@ describe('createDescendantRegistry shared IntersectionObserver', () => { */ function createOverflowRegistry() { const {Provider, useRegistryState, useRegisterDescendant, useRegisterOverflowObserver} = - createDescendantRegistry({overflow: {threshold: 1}}) + createDescendantRegistry({overflow: {}}) function RegistryParent({children}: {children: React.ReactNode}) { const [, setRegistry] = useRegistryState() - return {children} + const rootRef = useRef(null) + return ( +
    + + {children} + +
    + ) } function Item({value}: {value: string}) { @@ -370,10 +381,10 @@ describe('createDescendantRegistry shared IntersectionObserver', () => { return {RegistryParent, Item} } - it('creates a single shared observer for all items rather than one per item', () => { + it('creates a single shared observer for all items with the provided root element', () => { const {RegistryParent, Item} = createOverflowRegistry() - render( + const {getByTestId} = render( @@ -383,6 +394,7 @@ describe('createDescendantRegistry shared IntersectionObserver', () => { // Exactly one observer instance, observing all three elements. expect(observers).toHaveLength(1) + expect(observers[0].options?.root).toBe(getByTestId('observer-root')) expect(observers[0].observed.size).toBe(3) }) @@ -397,15 +409,17 @@ describe('createDescendantRegistry shared IntersectionObserver', () => { , ) - // Simulate all items wrapping to a clipped row (offsetTop > 0). jsdom reports 0 for offsetTop, so stub it. const items = ['a', 'b', 'c'].map(v => getByTestId(`item-${v}`)) - for (const el of items) { - Object.defineProperty(el, 'offsetTop', {configurable: true, value: 10}) - } // One observer notification should update all three items via fan-out. act(() => { - observers[0].trigger() + observers[0].emit( + items.map(target => ({ + target, + isIntersecting: false, + intersectionRatio: 0, + })), + ) }) for (const el of items) { @@ -413,6 +427,34 @@ describe('createDescendantRegistry shared IntersectionObserver', () => { } }) + it('updates an item from the observed entry without reading layout', () => { + const {RegistryParent, Item} = createOverflowRegistry() + + const {getByTestId} = render( + + + , + ) + + const item = getByTestId('item-a') + Object.defineProperty(item, 'offsetTop', { + configurable: true, + get() { + throw new Error('offsetTop should not be read') + }, + }) + + act(() => { + observers[0].emit([{target: item, isIntersecting: false, intersectionRatio: 0}]) + }) + expect(item).toHaveAttribute('data-overflowing', 'true') + + act(() => { + observers[0].emit([{target: item, isIntersecting: true, intersectionRatio: 1}]) + }) + expect(item).toHaveAttribute('data-overflowing', 'false') + }) + it('unobserves an element from the shared observer when its item unmounts', async () => { const {RegistryParent, Item} = createOverflowRegistry() @@ -441,6 +483,7 @@ describe('createDescendantRegistry shared IntersectionObserver', () => { // Still the same single observer, now observing only the two remaining items. expect(observers).toHaveLength(1) expect(observers[0].observed.size).toBe(2) + expect(observers[0].unobserve).toHaveBeenCalled() }) it('disconnects the shared observer when the provider unmounts', async () => { diff --git a/packages/react/src/utils/descendant-registry.tsx b/packages/react/src/utils/descendant-registry.tsx index 9aa862a17e4..5b9e605e670 100644 --- a/packages/react/src/utils/descendant-registry.tsx +++ b/packages/react/src/utils/descendant-registry.tsx @@ -19,6 +19,8 @@ export interface ProviderProps { children: ReactNode /** State setter from `useRegistryState`. */ setRegistry: Dispatch | undefined>> + /** Clipping container used as the `IntersectionObserver` root for overflow detection. */ + rootRef?: RefObject } interface DescendantRegistryContext { @@ -28,7 +30,7 @@ interface DescendantRegistryContext { } /** Subscribe a single observed element to the shared IntersectionObserver. Returns an unsubscribe function. */ -type ObserveFn = (element: Element, onChange: () => void) => () => void +type ObserveFn = (element: Element, onOverflowChange: (isOverflowing: boolean) => void) => () => void interface OverflowObserverContext { /** Subscribe an element. `null` when no shared observer is configured for this registry. */ @@ -67,12 +69,6 @@ export function createDescendantRegistry(options?: { * `useRegisterOverflowObserver` to subscribe to a single observer rather than each creating their own. */ overflow?: { - /** - * IntersectionObserver threshold. The observer is only used as a *trigger* to re-check `offsetTop > 0`, - * so the exact value mainly affects reliability at the wrap boundary. - * @default 1 - */ - threshold?: number } }) { const Context = createContext>({ @@ -83,7 +79,6 @@ export function createDescendantRegistry(options?: { const ObserverContext = createContext(noopObserve) - const overflowThreshold = options?.overflow?.threshold ?? 1 const overflowEnabled = options?.overflow !== undefined /** @@ -110,12 +105,12 @@ export function createDescendantRegistry(options?: { /** * Subscribe an element to the registry's shared IntersectionObserver and derive whether it is currently overflowing - * (i.e. has wrapped to a clipped row, so `offsetTop > 0`). Falls back to a per-item observer if no shared observer is - * configured for this registry, so the hook is always safe to call. + * from the observed entry. Falls back to a per-item observer when no shared observer is configured for this registry, + * so the hook is always safe to call. * - * The IntersectionObserver is only used as a cheap *trigger* to re-check `offsetTop`; the actual overflow detection - * relies on `flex-wrap` + `overflow: hidden` pushing items to a clipped second row, not on viewport intersection. - * That's why there is no `root` option on the observer. + * If the root-scoped `intersectionRatio` signal proves unreliable at the wrap boundary, fall back to caching + * `ref.current.offsetTop > 0` inside the observer callback (NOT in getSnapshot) and returning the cached value. + * This mirrors the historical reason `ActionBar` used a looser threshold for the offsetTop approach. * * @param ref Ref to the element whose overflow state should be tracked. * @param options.disabled When true, skips observer subscription entirely and always reports `false`. Useful for @@ -124,6 +119,9 @@ export function createDescendantRegistry(options?: { function useRegisterOverflowObserver(ref: RefObject, options?: {disabled?: boolean}) { const disabled = options?.disabled ?? false const {observe} = useContext(ObserverContext) + const isOverflowingRef = useRef(false) + + if (disabled) isOverflowingRef.current = false const subscribe = useCallback( (onChange: () => void) => { @@ -131,27 +129,36 @@ export function createDescendantRegistry(options?: { const element = ref.current if (!element) return () => {} + const updateOverflowState = (isOverflowing: boolean) => { + if (isOverflowing !== isOverflowingRef.current) { + isOverflowingRef.current = isOverflowing + onChange() + } + } + // Prefer the provider's shared observer; fall back to a local observer when none is configured. - if (observe) return observe(element, onChange) + if (observe) return observe(element, updateOverflowState) - const observer = new IntersectionObserver(() => onChange(), {threshold: overflowThreshold}) + if (typeof IntersectionObserver === 'undefined') return () => {} + + const observer = new IntersectionObserver(entries => { + for (const entry of entries) { + if (entry.target === element) updateOverflowState(getIsOverflowing(entry)) + } + }, {threshold: [0, 1]}) observer.observe(element) return () => observer.disconnect() }, [ref, observe, disabled], ) - return useSyncExternalStore( - subscribe, - () => (!disabled && ref.current ? ref.current.offsetTop > 0 : false), - () => false, - ) + return useSyncExternalStore(subscribe, () => isOverflowingRef.current, () => false) } const unsetValue = Symbol('unset') /** Provide context for registering descendant components. This only needs to wrap `children`. */ - function Provider({children, setRegistry}: ProviderProps) { + function Provider({children, setRegistry, rootRef}: ProviderProps) { const workingRegistryRef = useRef | 'queued' | 'idle'>('queued') /** State value to trigger a re-render and force all descendants to re-register. This ensures everything remains ordered. */ @@ -252,7 +259,11 @@ export function createDescendantRegistry(options?: { return ( - {overflowEnabled ? {children} : children} + {overflowEnabled ? ( + {children} + ) : ( + children + )} ) } @@ -261,57 +272,91 @@ export function createDescendantRegistry(options?: { * Owns a single IntersectionObserver shared by every descendant that calls `useRegisterOverflowObserver`. * Each observed element maps to a set of change callbacks; one observer notification fans out to all of them. */ - function OverflowObserverProvider({children}: {children: ReactNode}) { + function OverflowObserverProvider({ + children, + rootRef, + }: { + children: ReactNode + rootRef?: RefObject + }) { // Map of observed element -> set of subscriber callbacks. - const subscribersRef = useRef void>>>(new Map()) + const subscribersRef = useRef void>>>(new Map()) + const observedElementsRef = useRef>(new Set()) const observerRef = useRef(null) + const observerRootRef = useRef(null) // Lazily create the observer on first subscribe so SSR / zero-item renders allocate nothing. const getObserver = useCallback(() => { - if (observerRef.current) return observerRef.current if (typeof IntersectionObserver === 'undefined') return null + if (rootRef && rootRef.current === null) return null + + const root = rootRef?.current ?? null + if (observerRef.current && observerRootRef.current === root) return observerRef.current + + observerRef.current?.disconnect() + observedElementsRef.current.clear() + observerRef.current = new IntersectionObserver( entries => { for (const entry of entries) { const callbacks = subscribersRef.current.get(entry.target) if (!callbacks) continue - for (const cb of callbacks) cb() + const isOverflowing = getIsOverflowing(entry) + for (const cb of callbacks) cb(isOverflowing) } }, - {threshold: overflowThreshold}, + {root, threshold: [0, 1]}, ) + observerRootRef.current = root return observerRef.current - }, []) + }, [rootRef]) + + const observeSubscribedElements = useCallback(() => { + const observer = getObserver() + if (!observer) return + + for (const element of subscribersRef.current.keys()) { + if (!observedElementsRef.current.has(element)) { + observer.observe(element) + observedElementsRef.current.add(element) + } + } + }, [getObserver]) const observe = useCallback( - (element, onChange) => { - const observer = getObserver() + (element, onOverflowChange) => { let callbacks = subscribersRef.current.get(element) if (!callbacks) { callbacks = new Set() subscribersRef.current.set(element, callbacks) - observer?.observe(element) } - callbacks.add(onChange) + callbacks.add(onOverflowChange) + observeSubscribedElements() return () => { const set = subscribersRef.current.get(element) if (!set) return - set.delete(onChange) + set.delete(onOverflowChange) if (set.size === 0) { subscribersRef.current.delete(element) - observer?.unobserve(element) + observedElementsRef.current.delete(element) + observerRef.current?.unobserve(element) } } }, - [getObserver], + [observeSubscribedElements], ) + useIsomorphicLayoutEffect(() => { + observeSubscribedElements() + }) + useEffect(() => { const subscribers = subscribersRef.current return () => { observerRef.current?.disconnect() observerRef.current = null + observedElementsRef.current.clear() subscribers.clear() } }, []) @@ -323,3 +368,7 @@ export function createDescendantRegistry(options?: { return {Provider, useRegistryState, useRegisterDescendant, useRegisterOverflowObserver} } + +function getIsOverflowing(entry: Pick) { + return !entry.isIntersecting || entry.intersectionRatio < 1 +} From bafede5c4f61bbe245dabe2ef750cb09837b8450 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 23 Jun 2026 01:52:20 +0000 Subject: [PATCH 3/8] test: update shared overflow observer coverage Co-authored-by: mattcosta7 <8616962+mattcosta7@users.noreply.github.com> --- .../__tests__/descendant-registry.test.tsx | 13 ++++--- .../react/src/utils/descendant-registry.tsx | 39 +++++++++---------- 2 files changed, 25 insertions(+), 27 deletions(-) diff --git a/packages/react/src/utils/__tests__/descendant-registry.test.tsx b/packages/react/src/utils/__tests__/descendant-registry.test.tsx index 67079262df2..8d8f63c2cc6 100644 --- a/packages/react/src/utils/__tests__/descendant-registry.test.tsx +++ b/packages/react/src/utils/__tests__/descendant-registry.test.tsx @@ -49,7 +49,7 @@ describe('createDescendantRegistry', () => { - + , ) expect(getByTestId('registry-values').textContent).toBe('a,b,c') @@ -65,7 +65,7 @@ describe('createDescendantRegistry', () => { - + , ) expect(getByTestId('registry-values').textContent).toBe('a,b,c') @@ -233,7 +233,7 @@ describe('createDescendantRegistry coalesced rebuilds', () => { - , + ) } @@ -282,7 +282,7 @@ describe('createDescendantRegistry coalesced rebuilds', () => { - , + ) } @@ -356,8 +356,9 @@ describe('createDescendantRegistry shared IntersectionObserver', () => { * assert how many were updated by a single observer callback (the fan-out). */ function createOverflowRegistry() { - const {Provider, useRegistryState, useRegisterDescendant, useRegisterOverflowObserver} = - createDescendantRegistry({overflow: {}}) + const {Provider, useRegistryState, useRegisterDescendant, useRegisterOverflowObserver} = createDescendantRegistry< + string | null + >({overflow: {}}) function RegistryParent({children}: {children: React.ReactNode}) { const [, setRegistry] = useRegistryState() diff --git a/packages/react/src/utils/descendant-registry.tsx b/packages/react/src/utils/descendant-registry.tsx index 5b9e605e670..1526ede0cdb 100644 --- a/packages/react/src/utils/descendant-registry.tsx +++ b/packages/react/src/utils/descendant-registry.tsx @@ -68,8 +68,7 @@ export function createDescendantRegistry(options?: { * Configure a shared IntersectionObserver owned by the `Provider`. When set, descendants can call * `useRegisterOverflowObserver` to subscribe to a single observer rather than each creating their own. */ - overflow?: { - } + overflow?: object }) { const Context = createContext>({ register: () => () => {}, @@ -141,18 +140,25 @@ export function createDescendantRegistry(options?: { if (typeof IntersectionObserver === 'undefined') return () => {} - const observer = new IntersectionObserver(entries => { - for (const entry of entries) { - if (entry.target === element) updateOverflowState(getIsOverflowing(entry)) - } - }, {threshold: [0, 1]}) + const observer = new IntersectionObserver( + entries => { + for (const entry of entries) { + if (entry.target === element) updateOverflowState(getIsOverflowing(entry)) + } + }, + {threshold: [0, 1]}, + ) observer.observe(element) return () => observer.disconnect() }, [ref, observe, disabled], ) - return useSyncExternalStore(subscribe, () => isOverflowingRef.current, () => false) + return useSyncExternalStore( + subscribe, + () => isOverflowingRef.current, + () => false, + ) } const unsetValue = Symbol('unset') @@ -259,11 +265,7 @@ export function createDescendantRegistry(options?: { return ( - {overflowEnabled ? ( - {children} - ) : ( - children - )} + {overflowEnabled ? {children} : children} ) } @@ -272,13 +274,7 @@ export function createDescendantRegistry(options?: { * Owns a single IntersectionObserver shared by every descendant that calls `useRegisterOverflowObserver`. * Each observed element maps to a set of change callbacks; one observer notification fans out to all of them. */ - function OverflowObserverProvider({ - children, - rootRef, - }: { - children: ReactNode - rootRef?: RefObject - }) { + function OverflowObserverProvider({children, rootRef}: {children: ReactNode; rootRef?: RefObject}) { // Map of observed element -> set of subscriber callbacks. const subscribersRef = useRef void>>>(new Map()) const observedElementsRef = useRef>(new Set()) @@ -353,10 +349,11 @@ export function createDescendantRegistry(options?: { useEffect(() => { const subscribers = subscribersRef.current + const observedElements = observedElementsRef.current return () => { observerRef.current?.disconnect() observerRef.current = null - observedElementsRef.current.clear() + observedElements.clear() subscribers.clear() } }, []) From 4887f6081f931a13b2952cb8b9941f332356f762 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 23 Jun 2026 01:54:54 +0000 Subject: [PATCH 4/8] docs: clarify overflow observer fallback Co-authored-by: mattcosta7 <8616962+mattcosta7@users.noreply.github.com> --- .../src/utils/__tests__/descendant-registry.test.tsx | 1 + packages/react/src/utils/descendant-registry.tsx | 9 ++++++--- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/packages/react/src/utils/__tests__/descendant-registry.test.tsx b/packages/react/src/utils/__tests__/descendant-registry.test.tsx index 8d8f63c2cc6..227336e6dd8 100644 --- a/packages/react/src/utils/__tests__/descendant-registry.test.tsx +++ b/packages/react/src/utils/__tests__/descendant-registry.test.tsx @@ -302,6 +302,7 @@ describe('createDescendantRegistry coalesced rebuilds', () => { describe('createDescendantRegistry shared IntersectionObserver', () => { // Capture every IntersectionObserver instance and its observed elements so we can assert a single shared observer // is used and drive its callback manually. + /** Minimal `IntersectionObserverEntry` shape needed to drive overflow updates in tests. */ type MockEntry = Pick type FakeObserver = { callback: IntersectionObserverCallback diff --git a/packages/react/src/utils/descendant-registry.tsx b/packages/react/src/utils/descendant-registry.tsx index 1526ede0cdb..597ed56fa3d 100644 --- a/packages/react/src/utils/descendant-registry.tsx +++ b/packages/react/src/utils/descendant-registry.tsx @@ -120,8 +120,6 @@ export function createDescendantRegistry(options?: { const {observe} = useContext(ObserverContext) const isOverflowingRef = useRef(false) - if (disabled) isOverflowingRef.current = false - const subscribe = useCallback( (onChange: () => void) => { if (disabled) return () => {} @@ -156,7 +154,7 @@ export function createDescendantRegistry(options?: { return useSyncExternalStore( subscribe, - () => isOverflowingRef.current, + () => (disabled ? false : isOverflowingRef.current), () => false, ) } @@ -366,6 +364,11 @@ export function createDescendantRegistry(options?: { return {Provider, useRegistryState, useRegisterDescendant, useRegisterOverflowObserver} } +/** + * Treat any target that is not fully visible within the observer root as overflowing. Wrapped items should be fully + * clipped (`isIntersecting: false`, `intersectionRatio: 0`), but partial ratios also count as overflowing to guard + * against sub-pixel boundary cases. + */ function getIsOverflowing(entry: Pick) { return !entry.isIntersecting || entry.intersectionRatio < 1 } From ef231bb2f5a1b6a76434b87f5c3c1fd6745c7135 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 23 Jun 2026 01:56:50 +0000 Subject: [PATCH 5/8] refactor: document shared observer lifecycle Co-authored-by: mattcosta7 <8616962+mattcosta7@users.noreply.github.com> --- packages/react/src/utils/descendant-registry.tsx | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/packages/react/src/utils/descendant-registry.tsx b/packages/react/src/utils/descendant-registry.tsx index 597ed56fa3d..cc3a295486a 100644 --- a/packages/react/src/utils/descendant-registry.tsx +++ b/packages/react/src/utils/descendant-registry.tsx @@ -136,7 +136,7 @@ export function createDescendantRegistry(options?: { // Prefer the provider's shared observer; fall back to a local observer when none is configured. if (observe) return observe(element, updateOverflowState) - if (typeof IntersectionObserver === 'undefined') return () => {} + if (!supportsIntersectionObserver()) return () => {} const observer = new IntersectionObserver( entries => { @@ -281,7 +281,7 @@ export function createDescendantRegistry(options?: { // Lazily create the observer on first subscribe so SSR / zero-item renders allocate nothing. const getObserver = useCallback(() => { - if (typeof IntersectionObserver === 'undefined') return null + if (!supportsIntersectionObserver()) return null if (rootRef && rootRef.current === null) return null const root = rootRef?.current ?? null @@ -309,6 +309,8 @@ export function createDescendantRegistry(options?: { const observer = getObserver() if (!observer) return + // When the root ref becomes available or changes, re-check every subscribed element so they are all attached to + // the latest shared observer instance. for (const element of subscribersRef.current.keys()) { if (!observedElementsRef.current.has(element)) { observer.observe(element) @@ -372,3 +374,7 @@ export function createDescendantRegistry(options?: { function getIsOverflowing(entry: Pick) { return !entry.isIntersecting || entry.intersectionRatio < 1 } + +function supportsIntersectionObserver() { + return typeof IntersectionObserver !== 'undefined' +} From 0dd066d28a939b77329e86f16a3405d0d54a0dfa Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 23 Jun 2026 11:51:12 +0000 Subject: [PATCH 6/8] Use callback-ref shared observer; drop per-item fallback Co-authored-by: mattcosta7 <8616962+mattcosta7@users.noreply.github.com> --- packages/react/src/ActionBar/ActionBar.tsx | 46 ++++++---- .../src/UnderlineNav/UnderlineNavItem.tsx | 12 ++- .../__tests__/descendant-registry.test.tsx | 11 ++- .../react/src/utils/descendant-registry.tsx | 91 +++++++++++-------- 4 files changed, 96 insertions(+), 64 deletions(-) diff --git a/packages/react/src/ActionBar/ActionBar.tsx b/packages/react/src/ActionBar/ActionBar.tsx index d0827364ca5..e88fc3e1513 100644 --- a/packages/react/src/ActionBar/ActionBar.tsx +++ b/packages/react/src/ActionBar/ActionBar.tsx @@ -1,5 +1,5 @@ import {type RefObject, type MouseEventHandler, useContext} from 'react' -import React, {useState, useCallback, useRef, forwardRef, useMemo} from 'react' +import React, {useState, useCallback, useRef, useEffect, forwardRef, useMemo} from 'react' import {KebabHorizontalIcon} from '@primer/octicons-react' import {ActionList, type ActionListItemProps} from '../ActionList' @@ -311,32 +311,30 @@ export const ActionBar: React.FC> = ({ ) } -function useActionBarItem(ref: React.RefObject, registryProps: ChildProps) { +function useActionBarItem(registryProps: ChildProps) { const isGroupOverflowing = useContext(ActionBarGroupContext)?.isOverflowing const isInGroup = isGroupOverflowing !== undefined // There's no need to observe items inside of a group since the entire group overflows at once, so `disabled` skips // subscription for grouped items and always reports `false` for the child item itself. - const isItemOverflowing = ActionBarItemsRegistry.useRegisterOverflowObserver(ref, {disabled: isInGroup}) + const [isItemOverflowing, registerOverflowRef] = ActionBarItemsRegistry.useRegisterOverflowObserver({ + disabled: isInGroup, + }) const isOverflowing = isGroupOverflowing || isItemOverflowing ActionBarItemsRegistry.useRegisterDescendant(isOverflowing ? registryProps : null) - return {isOverflowing, dataOverflowingAttr: isOverflowing ? '' : undefined} + return {isOverflowing, dataOverflowingAttr: isOverflowing ? '' : undefined, registerOverflowRef} } export const ActionBarIconButton = forwardRef( ({disabled, onClick, ...props}: ActionBarIconButtonProps, forwardedRef) => { - const ref = useRef(null) - const mergedRef = useMergedRefs(forwardedRef, ref) - const {size} = React.useContext(ActionBarContext) const {['aria-label']: ariaLabel, icon} = props - const {dataOverflowingAttr} = useActionBarItem( - ref, + const {dataOverflowingAttr, registerOverflowRef} = useActionBarItem( useMemo( (): ChildProps => ({ type: 'action', @@ -349,6 +347,8 @@ export const ActionBarIconButton = forwardRef( ), ) + const mergedRef = useMergedRefs(forwardedRef, registerOverflowRef) + const clickHandler = useCallback( (event: React.MouseEvent) => { if (disabled) return @@ -377,15 +377,19 @@ const ActionBarGroupContext = React.createContext<{ export const ActionBarGroup = forwardRef(({children}: React.PropsWithChildren, forwardedRef) => { const backupRef = useRef(null) - const ref = (forwardedRef ?? backupRef) as RefObject - const {dataOverflowingAttr, isOverflowing} = useActionBarItem( - ref, + const {dataOverflowingAttr, isOverflowing, registerOverflowRef} = useActionBarItem( useMemo((): ChildProps => ({type: 'group'}), []), ) + const mergedRef = useMergedRefs((forwardedRef ?? backupRef) as RefObject, registerOverflowRef) return ( -
    +
    {children}
    @@ -402,8 +406,7 @@ export const ActionBarMenu = forwardRef( const [menuOpen, setMenuOpen] = useState(false) - const {dataOverflowingAttr} = useActionBarItem( - ref, + const {dataOverflowingAttr, registerOverflowRef} = useActionBarItem( useMemo( (): ChildProps => ({ type: 'menu', @@ -416,6 +419,13 @@ export const ActionBarMenu = forwardRef( ), ) + // The anchor element is only reachable through `anchorRef` (a RefObject required by ActionMenu), so bridge it to + // the shared observer's callback ref once the anchor is attached. + useEffect(() => { + registerOverflowRef(ref.current) + return () => registerOverflowRef(null) + }, [registerOverflowRef, ref]) + return ( @@ -438,15 +448,13 @@ export const ActionBarMenu = forwardRef( ) export const VerticalDivider = () => { - const ref = useRef(null) - const {dataOverflowingAttr} = useActionBarItem( - ref, + const {dataOverflowingAttr, registerOverflowRef} = useActionBarItem( useMemo((): ChildProps => ({type: 'divider'}), []), ) return (
  • ` directly so a root-scoped IntersectionObserver can detect when the item is clipped // onto the hidden next row. - const isOverflowing = UnderlineNavItemsRegistry.useRegisterOverflowObserver(ref) + const [isOverflowing, registerOverflowRef] = UnderlineNavItemsRegistry.useRegisterOverflowObserver() UnderlineNavItemsRegistry.useRegisterDescendant(isOverflowing ? allProps : null) @@ -46,7 +44,11 @@ export const UnderlineNavItem = forwardRef((allProps, forwardedRef) => { ) return ( -
  • +
  • { } function Item({value}: {value: string}) { - const ref = useRef(null) - const isOverflowing = useRegisterOverflowObserver(ref) + const [isOverflowing, registerOverflowRef] = useRegisterOverflowObserver() useRegisterDescendant(isOverflowing ? value : null) - return
    + return ( +
    + ) } return {RegistryParent, Item} diff --git a/packages/react/src/utils/descendant-registry.tsx b/packages/react/src/utils/descendant-registry.tsx index cc3a295486a..01e95bfebb2 100644 --- a/packages/react/src/utils/descendant-registry.tsx +++ b/packages/react/src/utils/descendant-registry.tsx @@ -39,6 +39,8 @@ interface OverflowObserverContext { const noopObserve: OverflowObserverContext = {observe: null} +const noop = () => {} + /** * Create a "descendant registry" for a component. This allows a parent to store and track an ordered registry of * child components, even if they are deeply nested in the tree. For example, a menu component can use this to track @@ -103,60 +105,67 @@ export function createDescendantRegistry(options?: { } /** - * Subscribe an element to the registry's shared IntersectionObserver and derive whether it is currently overflowing - * from the observed entry. Falls back to a per-item observer when no shared observer is configured for this registry, - * so the hook is always safe to call. + * Subscribe an element to the registry's shared, root-scoped IntersectionObserver and derive whether it is currently + * overflowing from the observed entry. + * + * Returns a tuple of the current overflow state and a callback ref. Attach the callback ref to the element whose + * overflow should be tracked: it subscribes the node to the shared observer when the element mounts and unsubscribes + * when it unmounts (or is replaced by a different node). Using a callback ref instead of reading `ref.current` at + * subscribe time means the hook always observes the element that is actually attached to the DOM. * - * If the root-scoped `intersectionRatio` signal proves unreliable at the wrap boundary, fall back to caching - * `ref.current.offsetTop > 0` inside the observer callback (NOT in getSnapshot) and returning the cached value. - * This mirrors the historical reason `ActionBar` used a looser threshold for the offsetTop approach. + * This requires the registry to be created with the `overflow` option so a shared observer is configured. When no + * shared observer is configured the hook is inert and always reports `false`. * - * @param ref Ref to the element whose overflow state should be tracked. * @param options.disabled When true, skips observer subscription entirely and always reports `false`. Useful for * items whose overflow is determined by an ancestor (e.g. ActionBar items inside an overflowing group). */ - function useRegisterOverflowObserver(ref: RefObject, options?: {disabled?: boolean}) { + function useRegisterOverflowObserver(options?: {disabled?: boolean}): [boolean, (node: HTMLElement | null) => void] { const disabled = options?.disabled ?? false const {observe} = useContext(ObserverContext) const isOverflowingRef = useRef(false) + const notifyChangeRef = useRef<() => void>(noop) + // Cleanup for the current subscription to the shared observer; cleared when the element detaches. + const unobserveRef = useRef<(() => void) | null>(null) + + const setOverflowing = useCallback((isOverflowing: boolean) => { + if (isOverflowing !== isOverflowingRef.current) { + isOverflowingRef.current = isOverflowing + notifyChangeRef.current() + } + }, []) - const subscribe = useCallback( - (onChange: () => void) => { - if (disabled) return () => {} - const element = ref.current - if (!element) return () => {} - - const updateOverflowState = (isOverflowing: boolean) => { - if (isOverflowing !== isOverflowingRef.current) { - isOverflowingRef.current = isOverflowing - onChange() - } + // Callback ref: subscribe the node on attach and unsubscribe on detach (or when `disabled`/`observe` change, which + // produces a new ref callback and causes React to re-run it with `null` then the node). + const registerOverflowRef = useCallback( + (node: HTMLElement | null) => { + // Tear down any previous subscription first so detaching or swapping the node never leaks an observer entry. + unobserveRef.current?.() + unobserveRef.current = null + + if (disabled || node === null || observe === null) { + setOverflowing(false) + return } - // Prefer the provider's shared observer; fall back to a local observer when none is configured. - if (observe) return observe(element, updateOverflowState) - - if (!supportsIntersectionObserver()) return () => {} - - const observer = new IntersectionObserver( - entries => { - for (const entry of entries) { - if (entry.target === element) updateOverflowState(getIsOverflowing(entry)) - } - }, - {threshold: [0, 1]}, - ) - observer.observe(element) - return () => observer.disconnect() + unobserveRef.current = observe(node, setOverflowing) }, - [ref, observe, disabled], + [observe, disabled, setOverflowing], ) - return useSyncExternalStore( + const subscribe = useCallback((onStoreChange: () => void) => { + notifyChangeRef.current = onStoreChange + return () => { + notifyChangeRef.current = noop + } + }, []) + + const isOverflowing = useSyncExternalStore( subscribe, - () => (disabled ? false : isOverflowingRef.current), + () => isOverflowingRef.current, () => false, ) + + return [isOverflowing, registerOverflowRef] } const unsetValue = Symbol('unset') @@ -343,10 +352,18 @@ export function createDescendantRegistry(options?: { [observeSubscribedElements], ) + // Re-check subscribed elements on every commit. The layout effect covers updates that happen while the provider's + // own root element is already attached, while the passive effect covers the initial mount: callback refs run during + // commit *before* an ancestor `rootRef` element attaches, so the observer (which needs the root) can only be created + // once refs higher in the tree have been set, which is guaranteed by passive-effect timing. useIsomorphicLayoutEffect(() => { observeSubscribedElements() }) + useEffect(() => { + observeSubscribedElements() + }) + useEffect(() => { const subscribers = subscribersRef.current const observedElements = observedElementsRef.current From a2eb5887d908d643a21d33330132883cc8b8050e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 23 Jun 2026 12:02:54 +0000 Subject: [PATCH 7/8] Revert callback-ref conversion; keep per-item fallback removal Co-authored-by: mattcosta7 <8616962+mattcosta7@users.noreply.github.com> --- packages/react/src/ActionBar/ActionBar.tsx | 46 ++++++++----------- .../src/UnderlineNav/UnderlineNavItem.tsx | 12 ++--- .../__tests__/descendant-registry.test.tsx | 11 ++--- 3 files changed, 27 insertions(+), 42 deletions(-) diff --git a/packages/react/src/ActionBar/ActionBar.tsx b/packages/react/src/ActionBar/ActionBar.tsx index e88fc3e1513..d0827364ca5 100644 --- a/packages/react/src/ActionBar/ActionBar.tsx +++ b/packages/react/src/ActionBar/ActionBar.tsx @@ -1,5 +1,5 @@ import {type RefObject, type MouseEventHandler, useContext} from 'react' -import React, {useState, useCallback, useRef, useEffect, forwardRef, useMemo} from 'react' +import React, {useState, useCallback, useRef, forwardRef, useMemo} from 'react' import {KebabHorizontalIcon} from '@primer/octicons-react' import {ActionList, type ActionListItemProps} from '../ActionList' @@ -311,30 +311,32 @@ export const ActionBar: React.FC> = ({ ) } -function useActionBarItem(registryProps: ChildProps) { +function useActionBarItem(ref: React.RefObject, registryProps: ChildProps) { const isGroupOverflowing = useContext(ActionBarGroupContext)?.isOverflowing const isInGroup = isGroupOverflowing !== undefined // There's no need to observe items inside of a group since the entire group overflows at once, so `disabled` skips // subscription for grouped items and always reports `false` for the child item itself. - const [isItemOverflowing, registerOverflowRef] = ActionBarItemsRegistry.useRegisterOverflowObserver({ - disabled: isInGroup, - }) + const isItemOverflowing = ActionBarItemsRegistry.useRegisterOverflowObserver(ref, {disabled: isInGroup}) const isOverflowing = isGroupOverflowing || isItemOverflowing ActionBarItemsRegistry.useRegisterDescendant(isOverflowing ? registryProps : null) - return {isOverflowing, dataOverflowingAttr: isOverflowing ? '' : undefined, registerOverflowRef} + return {isOverflowing, dataOverflowingAttr: isOverflowing ? '' : undefined} } export const ActionBarIconButton = forwardRef( ({disabled, onClick, ...props}: ActionBarIconButtonProps, forwardedRef) => { + const ref = useRef(null) + const mergedRef = useMergedRefs(forwardedRef, ref) + const {size} = React.useContext(ActionBarContext) const {['aria-label']: ariaLabel, icon} = props - const {dataOverflowingAttr, registerOverflowRef} = useActionBarItem( + const {dataOverflowingAttr} = useActionBarItem( + ref, useMemo( (): ChildProps => ({ type: 'action', @@ -347,8 +349,6 @@ export const ActionBarIconButton = forwardRef( ), ) - const mergedRef = useMergedRefs(forwardedRef, registerOverflowRef) - const clickHandler = useCallback( (event: React.MouseEvent) => { if (disabled) return @@ -377,19 +377,15 @@ const ActionBarGroupContext = React.createContext<{ export const ActionBarGroup = forwardRef(({children}: React.PropsWithChildren, forwardedRef) => { const backupRef = useRef(null) - const {dataOverflowingAttr, isOverflowing, registerOverflowRef} = useActionBarItem( + const ref = (forwardedRef ?? backupRef) as RefObject + const {dataOverflowingAttr, isOverflowing} = useActionBarItem( + ref, useMemo((): ChildProps => ({type: 'group'}), []), ) - const mergedRef = useMergedRefs((forwardedRef ?? backupRef) as RefObject, registerOverflowRef) return ( -
    +
    {children}
    @@ -406,7 +402,8 @@ export const ActionBarMenu = forwardRef( const [menuOpen, setMenuOpen] = useState(false) - const {dataOverflowingAttr, registerOverflowRef} = useActionBarItem( + const {dataOverflowingAttr} = useActionBarItem( + ref, useMemo( (): ChildProps => ({ type: 'menu', @@ -419,13 +416,6 @@ export const ActionBarMenu = forwardRef( ), ) - // The anchor element is only reachable through `anchorRef` (a RefObject required by ActionMenu), so bridge it to - // the shared observer's callback ref once the anchor is attached. - useEffect(() => { - registerOverflowRef(ref.current) - return () => registerOverflowRef(null) - }, [registerOverflowRef, ref]) - return ( @@ -448,13 +438,15 @@ export const ActionBarMenu = forwardRef( ) export const VerticalDivider = () => { - const {dataOverflowingAttr, registerOverflowRef} = useActionBarItem( + const ref = useRef(null) + const {dataOverflowingAttr} = useActionBarItem( + ref, useMemo((): ChildProps => ({type: 'divider'}), []), ) return (
  • ` directly so a root-scoped IntersectionObserver can detect when the item is clipped // onto the hidden next row. - const [isOverflowing, registerOverflowRef] = UnderlineNavItemsRegistry.useRegisterOverflowObserver() + const isOverflowing = UnderlineNavItemsRegistry.useRegisterOverflowObserver(ref) UnderlineNavItemsRegistry.useRegisterDescendant(isOverflowing ? allProps : null) @@ -44,11 +46,7 @@ export const UnderlineNavItem = forwardRef((allProps, forwardedRef) => { ) return ( -
  • +
  • { } function Item({value}: {value: string}) { - const [isOverflowing, registerOverflowRef] = useRegisterOverflowObserver() + const ref = useRef(null) + const isOverflowing = useRegisterOverflowObserver(ref) useRegisterDescendant(isOverflowing ? value : null) - return ( -
    - ) + return
    } return {RegistryParent, Item} From 7b2cead6a7fba503c7f2eb0fb6f7da5b5656caff Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 23 Jun 2026 12:07:31 +0000 Subject: [PATCH 8/8] Revert callback-ref conversion in registry hook; keep fallback removal Co-authored-by: mattcosta7 <8616962+mattcosta7@users.noreply.github.com> --- .../react/src/utils/descendant-registry.tsx | 69 +++++-------------- 1 file changed, 19 insertions(+), 50 deletions(-) diff --git a/packages/react/src/utils/descendant-registry.tsx b/packages/react/src/utils/descendant-registry.tsx index 01e95bfebb2..2b2b86ab81d 100644 --- a/packages/react/src/utils/descendant-registry.tsx +++ b/packages/react/src/utils/descendant-registry.tsx @@ -39,8 +39,6 @@ interface OverflowObserverContext { const noopObserve: OverflowObserverContext = {observe: null} -const noop = () => {} - /** * Create a "descendant registry" for a component. This allows a parent to store and track an ordered registry of * child components, even if they are deeply nested in the tree. For example, a menu component can use this to track @@ -108,64 +106,43 @@ export function createDescendantRegistry(options?: { * Subscribe an element to the registry's shared, root-scoped IntersectionObserver and derive whether it is currently * overflowing from the observed entry. * - * Returns a tuple of the current overflow state and a callback ref. Attach the callback ref to the element whose - * overflow should be tracked: it subscribes the node to the shared observer when the element mounts and unsubscribes - * when it unmounts (or is replaced by a different node). Using a callback ref instead of reading `ref.current` at - * subscribe time means the hook always observes the element that is actually attached to the DOM. - * * This requires the registry to be created with the `overflow` option so a shared observer is configured. When no * shared observer is configured the hook is inert and always reports `false`. * + * @param ref Ref to the element whose overflow state should be tracked. * @param options.disabled When true, skips observer subscription entirely and always reports `false`. Useful for * items whose overflow is determined by an ancestor (e.g. ActionBar items inside an overflowing group). */ - function useRegisterOverflowObserver(options?: {disabled?: boolean}): [boolean, (node: HTMLElement | null) => void] { + function useRegisterOverflowObserver(ref: RefObject, options?: {disabled?: boolean}) { const disabled = options?.disabled ?? false const {observe} = useContext(ObserverContext) const isOverflowingRef = useRef(false) - const notifyChangeRef = useRef<() => void>(noop) - // Cleanup for the current subscription to the shared observer; cleared when the element detaches. - const unobserveRef = useRef<(() => void) | null>(null) - - const setOverflowing = useCallback((isOverflowing: boolean) => { - if (isOverflowing !== isOverflowingRef.current) { - isOverflowingRef.current = isOverflowing - notifyChangeRef.current() - } - }, []) - // Callback ref: subscribe the node on attach and unsubscribe on detach (or when `disabled`/`observe` change, which - // produces a new ref callback and causes React to re-run it with `null` then the node). - const registerOverflowRef = useCallback( - (node: HTMLElement | null) => { - // Tear down any previous subscription first so detaching or swapping the node never leaks an observer entry. - unobserveRef.current?.() - unobserveRef.current = null - - if (disabled || node === null || observe === null) { - setOverflowing(false) - return + const subscribe = useCallback( + (onChange: () => void) => { + if (disabled) return () => {} + const element = ref.current + // The hook only tracks overflow through the provider's shared observer. When no shared observer is configured + // (or the element isn't attached yet) the hook is inert and reports `false`. + if (!element || observe === null) return () => {} + + const updateOverflowState = (isOverflowing: boolean) => { + if (isOverflowing !== isOverflowingRef.current) { + isOverflowingRef.current = isOverflowing + onChange() + } } - unobserveRef.current = observe(node, setOverflowing) + return observe(element, updateOverflowState) }, - [observe, disabled, setOverflowing], + [ref, observe, disabled], ) - const subscribe = useCallback((onStoreChange: () => void) => { - notifyChangeRef.current = onStoreChange - return () => { - notifyChangeRef.current = noop - } - }, []) - - const isOverflowing = useSyncExternalStore( + return useSyncExternalStore( subscribe, - () => isOverflowingRef.current, + () => (disabled ? false : isOverflowingRef.current), () => false, ) - - return [isOverflowing, registerOverflowRef] } const unsetValue = Symbol('unset') @@ -352,18 +329,10 @@ export function createDescendantRegistry(options?: { [observeSubscribedElements], ) - // Re-check subscribed elements on every commit. The layout effect covers updates that happen while the provider's - // own root element is already attached, while the passive effect covers the initial mount: callback refs run during - // commit *before* an ancestor `rootRef` element attaches, so the observer (which needs the root) can only be created - // once refs higher in the tree have been set, which is guaranteed by passive-effect timing. useIsomorphicLayoutEffect(() => { observeSubscribedElements() }) - useEffect(() => { - observeSubscribedElements() - }) - useEffect(() => { const subscribers = subscribersRef.current const observedElements = observedElementsRef.current