Skip to content
Draft
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
---

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.
43 changes: 12 additions & 31 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 Down Expand Up @@ -139,7 +139,9 @@ export type ActionBarMenuProps = {
returnFocusRef?: React.RefObject<HTMLElement>
} & IconButtonProps

const ActionBarItemsRegistry = createDescendantRegistry<ChildProps | null>()
// Items opt into a single shared IntersectionObserver via `useRegisterOverflowObserver` instead of each item creating
// its own observer.
const ActionBarItemsRegistry = createDescendantRegistry<ChildProps | null>({overflow: {}})

const FOCUSABLE_ITEM_SELECTOR =
':is(button, a, input, [tabindex]):not(:disabled):not([data-overflowing]):not([data-more-button-inactive])'
Expand Down Expand Up @@ -203,6 +205,7 @@ export const ActionBar: React.FC<React.PropsWithChildren<ActionBarProps>> = ({
gap = 'condensed',
}) => {
const [childRegistry, setChildRegistry] = ActionBarItemsRegistry.useRegistryState()
const overflowContainerRef = useRef<HTMLDivElement>(null)

const overflowItems = useMemo(
() =>
Expand Down Expand Up @@ -233,10 +236,12 @@ export const ActionBar: React.FC<React.PropsWithChildren<ActionBarProps>> = ({
data-size={size}
data-has-overflow={overflowItems ? overflowItems.length > 0 : undefined}
>
<div className={styles.OverflowContainer}>
<div ref={overflowContainerRef} 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>
<ActionBarItemsRegistry.Provider setRegistry={setChildRegistry} rootRef={overflowContainerRef}>
{children}
</ActionBarItemsRegistry.Provider>
</div>
<ActionMenu>
<ActionMenu.Anchor>
Expand Down Expand Up @@ -310,33 +315,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 = ActionBarItemsRegistry.useRegisterOverflowObserver(ref, {disabled: isInGroup})

const isOverflowing = isGroupOverflowing || isItemOverflowing

Expand Down
2 changes: 1 addition & 1 deletion packages/react/src/UnderlineNav/UnderlineNav.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@ export const UnderlineNav = forwardRef(
data-has-overflow={isOverflowing ? 'true' : undefined}
>
<UnderlineItemList ref={listRef} role="list" className={classes.ItemsList}>
<UnderlineNavItemsRegistry.Provider setRegistry={setRegisteredItems}>
<UnderlineNavItemsRegistry.Provider setRegistry={setRegisteredItems} rootRef={listRef}>
{children}
</UnderlineNavItemsRegistry.Provider>
</UnderlineItemList>
Expand Down
23 changes: 4 additions & 19 deletions packages/react/src/UnderlineNav/UnderlineNavItem.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
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'
Expand All @@ -22,24 +22,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 = UnderlineNavItemsRegistry.useRegisterOverflowObserver(ref)

UnderlineNavItemsRegistry.useRegisterDescendant(isOverflowing ? allProps : null)

Expand Down
11 changes: 9 additions & 2 deletions packages/react/src/UnderlineNav/UnderlineNavItemsRegistry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,5 +53,12 @@ export type UnderlineNavItemProps = {
counter?: number | string
} & LinkProps

/** Registry of currently-overflowing underline items. If an item is not overflowing, its value will be `null`. */
export const UnderlineNavItemsRegistry = createDescendantRegistry<UnderlineNavItemProps | null>()
/**
* Registry of currently-overflowing underline items. If an item is not overflowing, its value will be `null`.
*
* Items opt into a single shared IntersectionObserver via `useRegisterOverflowObserver` instead of each item creating
* its own observer.
*/
export const UnderlineNavItemsRegistry = createDescendantRegistry<UnderlineNavItemProps | null>({
overflow: {},
})
Loading
Loading