Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/underline-nav-shared-overflow-observer.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@primer/react': patch
---

UnderlineNav, ActionBar: Detect item overflow with a single shared IntersectionObserver per component instead of one observer per item, reducing observer churn during resize. No public API changes.
40 changes: 11 additions & 29 deletions packages/react/src/ActionBar/ActionBar.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import {type RefObject, type MouseEventHandler, useContext} from 'react'
import React, {useState, useCallback, useRef, forwardRef, useMemo, useSyncExternalStore} from 'react'
import React, {useState, useCallback, useRef, forwardRef, useMemo} from 'react'
import {KebabHorizontalIcon} from '@primer/octicons-react'
import {ActionList, type ActionListItemProps} from '../ActionList'

Expand All @@ -11,6 +11,8 @@ import styles from './ActionBar.module.css'
import {clsx} from 'clsx'
import {useMergedRefs} from '../hooks'
import {createDescendantRegistry} from '../utils/descendant-registry'
import {OverflowObserverProvider} from '../internal/components/OverflowObserverProvider'
import {useIsClipped} from '../internal/hooks/useOverflowObserver'

type ChildProps =
| {
Expand Down Expand Up @@ -238,7 +240,11 @@ export const ActionBar: React.FC<React.PropsWithChildren<ActionBarProps>> = ({
<div className={styles.OverflowContainer}>
{/* An empty first element allows the real first item to wrap to the next line and get clipped. */}
<div className={styles.OverflowSpacer} />
<ActionBarItemsRegistry.Provider setRegistry={setChildRegistry}>{children}</ActionBarItemsRegistry.Provider>
<OverflowObserverProvider rootRef={containerRef}>
<ActionBarItemsRegistry.Provider setRegistry={setChildRegistry}>
{children}
</ActionBarItemsRegistry.Provider>
</OverflowObserverProvider>
</div>
<ActionMenu>
<ActionMenu.Anchor>
Expand Down Expand Up @@ -314,33 +320,9 @@ function useActionBarItem(ref: React.RefObject<HTMLElement | null>, registryProp
const isGroupOverflowing = useContext(ActionBarGroupContext)?.isOverflowing
const isInGroup = isGroupOverflowing !== undefined

const subscribeIntersectionObserver = useCallback(
(onChange: () => void) => {
// There's no need to register observers on items inside of a group
// since the entire group overflows at once
if (isInGroup) return () => {}

// Technically 1 should work as the threshold, but in some scenarios that
// doesn't seem to trigger correctly - probably because the browser still
// thinks a tiny bit of the button is not visible, since the container
// height is exactly the button height. So 75% should be more reliable.
const observer = new IntersectionObserver(() => onChange(), {threshold: 0.75})

if (ref.current) observer.observe(ref.current)
return () => observer.disconnect()
},
[ref, isInGroup],
)

const isItemOverflowing = useSyncExternalStore(
subscribeIntersectionObserver,
// Note: the IntersectionObserver 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 will move to the next row which
// increases its `offsetTop`
() => (ref.current ? ref.current.offsetTop > 0 : false),
() => false,
)
// 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 = useIsClipped(ref, {disabled: isInGroup})

const isOverflowing = isGroupOverflowing || isItemOverflowing

Expand Down
9 changes: 6 additions & 3 deletions packages/react/src/UnderlineNav/UnderlineNav.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {ActionList} from '../ActionList'
import {ActionMenu} from '../ActionMenu'
import CounterLabel from '../CounterLabel'
import {LoadingCounter, UnderlineItemList, UnderlineWrapper} from '../internal/components/UnderlineTabbedInterface'
import {OverflowObserverProvider} from '../internal/components/OverflowObserverProvider'
import {invariant} from '../utils/invariant'
import classes from './UnderlineNav.module.css'
import {UnderlineNavContext} from './UnderlineNavContext'
Expand Down Expand Up @@ -101,9 +102,11 @@ export const UnderlineNav = forwardRef(
data-has-overflow={isOverflowing ? 'true' : undefined}
>
<UnderlineItemList ref={listRef} role="list" className={classes.ItemsList}>
<UnderlineNavItemsRegistry.Provider setRegistry={setRegisteredItems}>
{children}
</UnderlineNavItemsRegistry.Provider>
<OverflowObserverProvider rootRef={navRef}>
<UnderlineNavItemsRegistry.Provider setRegistry={setRegisteredItems}>
{children}
</UnderlineNavItemsRegistry.Provider>
</OverflowObserverProvider>
</UnderlineItemList>

<div className={classes.MoreButtonContainer}>
Expand Down
24 changes: 5 additions & 19 deletions packages/react/src/UnderlineNav/UnderlineNavItem.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import React, {forwardRef, useRef, useContext, useCallback, useSyncExternalStore} from 'react'
import React, {forwardRef, useRef, useContext} from 'react'
import type {ForwardRefComponent as PolymorphicForwardRefComponent} from '../utils/polymorphic'
import {UnderlineNavContext} from './UnderlineNavContext'
import {UnderlineItem} from '../internal/components/UnderlineTabbedInterface'
import classes from './UnderlineNavItem.module.css'
import {type UnderlineNavItemProps, UnderlineNavItemsRegistry} from './UnderlineNavItemsRegistry'
import {useIsClipped} from '../internal/hooks/useOverflowObserver'

export const UnderlineNavItem = forwardRef((allProps, forwardedRef) => {
const {
Expand All @@ -22,24 +23,9 @@ export const UnderlineNavItem = forwardRef((allProps, forwardedRef) => {

const {loadingCounters} = useContext(UnderlineNavContext)

const isOverflowing = useSyncExternalStore(
useCallback(
onChange => {
const observer = new IntersectionObserver(() => onChange(), {
threshold: 1,
})
if (ref.current) observer.observe(ref.current)
return () => observer.disconnect()
},
[ref],
),
// Note: the IntersectionObserver 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 will move to the next row which
// increases its `offsetTop`
() => (ref.current ? ref.current.offsetTop > 0 : false),
() => false,
)
// Observe the wrapping `<li>` directly so a root-scoped IntersectionObserver can detect when the item is clipped
// onto the hidden next row.
const isOverflowing = useIsClipped(ref)

UnderlineNavItemsRegistry.useRegisterDescendant(isOverflowing ? allProps : null)

Expand Down
125 changes: 125 additions & 0 deletions packages/react/src/internal/components/OverflowObserverProvider.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
import {useCallback, useEffect, useRef, type ReactNode, type RefObject} from 'react'
import useIsomorphicLayoutEffect from '../../utils/useIsomorphicLayoutEffect'
import {OverflowObserverContext, type ObserveFn} from '../hooks/useOverflowObserver'

/**
* Owns a single `IntersectionObserver` shared by every descendant that calls `useIsClipped`, instead of each item
* creating its own observer. Each observed element maps to a set of change callbacks; one observer notification fans
* out to all of them.
*
* `rootRef` must point to the element that clips overflowing children (typically a single-row, `overflow: hidden`
* container). The observer is root-scoped to that element so children that wrap onto a clipped row are reported as
* overflowing. Until the root is attached the provider stays inert (it never falls back to the viewport, which would
* not reflect the container's clipping) and re-checks on later renders.
*/
export function OverflowObserverProvider({
children,
rootRef,
}: {
children: ReactNode
/** Clipping container used as the `IntersectionObserver` root for overflow detection. */
rootRef: RefObject<Element | null>
}) {
// Map of observed element -> set of subscriber callbacks.
const subscribersRef = useRef<Map<Element, Set<(isOverflowing: boolean) => void>>>(new Map())
const observedElementsRef = useRef<Set<Element>>(new Set())
const observerRef = useRef<IntersectionObserver | null>(null)
const observerRootRef = useRef<Element | null>(null)

// Lazily create the observer once the root is available so SSR / zero-item renders allocate nothing.
const getObserver = useCallback(() => {
if (!supportsIntersectionObserver()) return null

const root = rootRef.current
// Root-scoped overflow detection requires the clipping container. Stay inert until it's attached rather than
// falling back to a viewport-rooted observer, which wouldn't reflect the container's clipping.
if (root === null) return 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
const isOverflowing = getIsOverflowing(entry)
for (const cb of callbacks) cb(isOverflowing)
}
},
{root, threshold: [0, 1]},
)
observerRootRef.current = root
return observerRef.current
}, [rootRef])

const observeSubscribedElements = useCallback(() => {
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)
observedElementsRef.current.add(element)
}
}
}, [getObserver])

const observe = useCallback<ObserveFn>(
(element, onOverflowChange) => {
let callbacks = subscribersRef.current.get(element)
if (!callbacks) {
callbacks = new Set()
subscribersRef.current.set(element, callbacks)
}
callbacks.add(onOverflowChange)
observeSubscribedElements()

return () => {
const set = subscribersRef.current.get(element)
if (!set) return
set.delete(onOverflowChange)
if (set.size === 0) {
subscribersRef.current.delete(element)
observedElementsRef.current.delete(element)
observerRef.current?.unobserve(element)
}
}
},
[observeSubscribedElements],
)

useIsomorphicLayoutEffect(() => {
observeSubscribedElements()
})

useEffect(() => {
const subscribers = subscribersRef.current
const observedElements = observedElementsRef.current
return () => {
observerRef.current?.disconnect()
observerRef.current = null
observedElements.clear()
subscribers.clear()
}
}, [])

return <OverflowObserverContext.Provider value={observe}>{children}</OverflowObserverContext.Provider>
}

/**
* 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<IntersectionObserverEntry, 'intersectionRatio' | 'isIntersecting'>) {
return !entry.isIntersecting || entry.intersectionRatio < 1
}

function supportsIntersectionObserver() {
return typeof IntersectionObserver !== 'undefined'
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import {afterEach, describe, expect, it, vi} from 'vitest'
import {useRef} from 'react'
import {act, render} from '@testing-library/react'
import {OverflowObserverProvider} from '../../components/OverflowObserverProvider'
import {useIsClipped} from '../useOverflowObserver'

type Entry = Pick<IntersectionObserverEntry, 'target' | 'isIntersecting' | 'intersectionRatio'>

/** Minimal `IntersectionObserver` stub that records instances and lets tests drive callbacks. */
class MockIntersectionObserver {
static instances: MockIntersectionObserver[] = []
readonly observed = new Set<Element>()
callback: (entries: Entry[]) => void
options?: IntersectionObserverInit
constructor(callback: (entries: Entry[]) => void, options?: IntersectionObserverInit) {
this.callback = callback
this.options = options
MockIntersectionObserver.instances.push(this)
}
observe(element: Element) {
this.observed.add(element)
}
unobserve(element: Element) {
this.observed.delete(element)
}
disconnect() {
this.observed.clear()
}
trigger(entries: Entry[]) {
act(() => this.callback(entries))
}
}

function Item({disabled}: {disabled?: boolean}) {
const ref = useRef<HTMLLIElement>(null)
const isClipped = useIsClipped(ref, {disabled})
return <li ref={ref} data-testid="item" data-overflowing={isClipped} />
}

function Harness({disabled}: {disabled?: boolean}) {
const rootRef = useRef<HTMLUListElement>(null)
return (
<ul ref={rootRef}>
<OverflowObserverProvider rootRef={rootRef}>
<Item disabled={disabled} />
</OverflowObserverProvider>
</ul>
)
}

describe('useIsClipped', () => {
afterEach(() => {
vi.unstubAllGlobals()
MockIntersectionObserver.instances = []
})

it('reports false when there is no surrounding provider', () => {
const {getByTestId} = render(<Item />)
expect(getByTestId('item').getAttribute('data-overflowing')).toBe('false')
})

it('is inert and reports false when IntersectionObserver is unavailable', () => {
vi.stubGlobal('IntersectionObserver', undefined)
const {getByTestId} = render(<Harness />)
expect(getByTestId('item').getAttribute('data-overflowing')).toBe('false')
})

it('does not subscribe and reports false when disabled', () => {
vi.stubGlobal('IntersectionObserver', MockIntersectionObserver)
const {getByTestId} = render(<Harness disabled />)
expect(getByTestId('item').getAttribute('data-overflowing')).toBe('false')
// A disabled item should never be observed by the shared observer.
const item = getByTestId('item')
expect(MockIntersectionObserver.instances.every(o => !o.observed.has(item))).toBe(true)
})

it('reflects overflow state from the shared root-scoped observer', () => {
vi.stubGlobal('IntersectionObserver', MockIntersectionObserver)
const {getByTestId} = render(<Harness />)
const item = getByTestId('item')

const observer = MockIntersectionObserver.instances.at(-1)!
// The observer is created against the provided clipping root, not the viewport.
expect(observer.options?.root).not.toBeNull()
expect(observer.observed.has(item)).toBe(true)

// A clipped (wrapped) item is reported as overflowing.
observer.trigger([{target: item, isIntersecting: false, intersectionRatio: 0}])
expect(item.getAttribute('data-overflowing')).toBe('true')

// A fully visible item is not overflowing.
observer.trigger([{target: item, isIntersecting: true, intersectionRatio: 1}])
expect(item.getAttribute('data-overflowing')).toBe('false')
})
})
Loading
Loading