Skip to content

Extract reusable "descendant registry" pattern from ActionBar, with new support for reordering elements#7585

Open
iansan5653 wants to merge 11 commits intomainfrom
descendant-registry-pattern
Open

Extract reusable "descendant registry" pattern from ActionBar, with new support for reordering elements#7585
iansan5653 wants to merge 11 commits intomainfrom
descendant-registry-pattern

Conversation

@iansan5653
Copy link
Contributor

@iansan5653 iansan5653 commented Feb 23, 2026

I previously introduced a 'registry' pattern ActionBar, which uses context to allow items to register themselves even if deeply nested in the React component tree. This is somewhat similar to the useSlots pattern except that it doesn't rely on Children operations, so it works with items wrapped in other components and fragments. However, it has the disadvantage of not working during SSR (because it relies on effects). This is not an issue for components like ActionBar where the data is being collected for a not-initially-visible overflow menu.

There is a flaw in the current implementation, however: it always preserves the order from the initial render. So if an item is added to the middle or beginning after the initial render, the order of overflow items in the menu will be incorrect.

In addition, I'd like to reuse this pattern in the work I'm doing on UnderlineNav.

So this PR extracts the pattern out to be reusable and fixes the ordering bug. The new approach is essentially the same when updating a value or removing an item, but the key difference is that we rebuild the registry from scratch when adding a new item. We do this by updating a state value that triggers all the registration effects to re-run.

To avoid many consecutive state updates when triggering a rebuild, we use a working ref to build the map, then only commit it to state at the end of the process. This is a significant performance improvement over the previous approach, which would trigger a state update and re-render once for each descendant that registers.


This is somewhat comparable to the deprecated useSlots pattern, however it has several improvements to try to solve for the challenges we've faces with that solution:

  1. It's more flexible and generic, maintaining a simple map of data registered via hooks rather than trying to track specific components. It's not children-focused but rather can be used for any kind of data
  2. The flexibility also makes it easier to work with, since the storage mechanism is just a stateful Map
  3. It is more efficient, minimizing renders when handling updates, unmounts, and the initial render
  4. It's fully React Compiler compatible with no ESLint violations
  5. It provides a useful fallback for SSR environments

Note that this does not mean that we should just use this new pattern everywhere that we use slots today. There's still a case for the useSlots Children-based approach when we need SSR compatibility. This pattern is primarily useful for cases where rendered UI depending on the data is not immediately visible, like in overflow menus or non-visible DOM attributes.

Usage

The pattern works like this: First, we will instantiate a new registry via createDescendantRegistry (comparable to createContext). The type of the entries can be anything you want:

const ExampleItemsRegistry = createDescendantRegistry<string>()

Then, the top-level component instantiates the registry state and provides the context to its children. The registry is a Map so that the component can access the unique item IDs to use as keys if necessary. The registry is initially undefined until built for the first time:

function ExampleContainer({children}) {
  const [registeredItems, setRegisteredItems] = ExampleItemsRegistry.useRegistryState()

  return (
    <>
      <ExampleItemsRegistry.Provider setRegistry={setRegisteredItems}>
        {children}
      </ExampleItemsRegistry.Provider>

      {registeredItems === undefined ? "Loading..." : `These items are registered: ${Array.from(registeredItems.values()).join(", ")}`}
    </>
  )
}

Finally, item components register themselves:

function ExampleItem({name}) {
  ExampleItemsRegistry.useRegisterDescendant(name)

  return <>{name}</>
}

Now, the container component has a complete, ordered registry of all ExampleItem components contained inside of it.

Changelog

Fixes

  • Fixes order of items in ActionBar overflow menu when new items are added after initial render
  • Improves initial render performance of ActionBar

Rollout strategy

  • None: these changes are internal only

Testing & Reviewing

Merge checklist

@changeset-bot
Copy link

changeset-bot bot commented Feb 23, 2026

🦋 Changeset detected

Latest commit: f818594

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
@primer/react Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@iansan5653 iansan5653 changed the title Implement reusable "descendant registry" pattern from ActionBar, with new support for reordering elements Extract reusable "descendant registry" pattern from ActionBar, with new support for reordering elements Feb 23, 2026
@github-actions github-actions bot added the integration-tests: recommended This change needs to be tested for breaking changes. See https://arc.net/l/quote/tdmpakpm label Feb 23, 2026
@github-actions
Copy link
Contributor

👋 Hi, this pull request contains changes to the source code that github/github-ui depends on. If you are GitHub staff, test these changes with github/github-ui using the integration workflow. Or, apply the integration-tests: skipped manually label to skip these checks.

@iansan5653
Copy link
Contributor Author

@copilot Write unit tests for descendant-registry.tsx covering at least the following scenarios:

  • descendant items inside of other components
  • descendant items inside of React fragments
  • descendant items being added to the middle of the children after initial render
  • items being dropped after initial render
  • order of items changing after initial render, using key to maintain component mount
  • deep descendants added to beginning of tree after initial render

Copy link
Contributor

Copilot AI commented Feb 23, 2026

@iansan5653 I've opened a new pull request, #7586, to work on those changes. Once the pull request is ready, I'll request review from you.

… new support for reordering elements (#7586)

Co-authored-by: copilot-swe-agent[bot] <[email protected]>
Co-authored-by: iansan5653 <[email protected]>
@primer
Copy link
Contributor

primer bot commented Feb 24, 2026

🤖 Lint issues have been automatically fixed and committed to this PR.

@iansan5653 iansan5653 added the skip changeset This change does not need a changelog label Feb 25, 2026
@iansan5653 iansan5653 self-assigned this Feb 25, 2026
@iansan5653 iansan5653 marked this pull request as ready for review February 25, 2026 20:01
@iansan5653 iansan5653 requested a review from a team as a code owner February 25, 2026 20:01
@iansan5653 iansan5653 removed the skip changeset This change does not need a changelog label Feb 25, 2026
Extract reusable 'descendant registry' pattern from ActionBar, adding support for reordering elements. Fixes a bug with menu item order and improves initial render performance.
@github-actions github-actions bot temporarily deployed to storybook-preview-7585 February 25, 2026 20:06 Inactive
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR extracts a reusable "descendant registry" pattern from ActionBar into a new utility (descendant-registry.tsx) and fixes an ordering bug where items added to the middle or beginning of the component tree after initial render would appear in the wrong order. The new implementation rebuilds the entire registry when new items are added (to ensure correct ordering) but uses a working ref to batch all descendant registrations into a single state update, significantly improving performance over the previous approach.

Changes:

  • Introduces createDescendantRegistry<T>() utility for tracking deeply nested children via context and effects
  • Fixes ordering issues by triggering a full registry rebuild when new descendants are added
  • Refactors ActionBar to use the new registry pattern, replacing the old registerChild/unregisterChild approach
  • Adds comprehensive tests for the new registry pattern covering ordering, updates, and unmounting

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated 4 comments.

File Description
packages/react/src/utils/descendant-registry.tsx New utility implementing the descendant registry pattern with proper ordering support
packages/react/src/utils/__tests__/descendant-registry.test.tsx Test suite covering registry behavior including ordering edge cases
packages/react/src/ActionBar/ActionBar.tsx Refactored to use new registry utility, removing old registration logic and adding useWidth helper
Comments suppressed due to low confidence (3)

packages/react/src/ActionBar/ActionBar.tsx:566

  • Including widthRef in the dependency array is unnecessary because refs are stable objects that never change identity. Refs should not be included in effect or callback dependency arrays. Remove widthRef from the dependency array.
      [ariaLabel, overflowIcon, icon, items, returnFocusRef, widthRef],

packages/react/src/ActionBar/ActionBar.tsx:592

  • Including widthRef in the dependency array is unnecessary because refs are stable objects that never change identity. Refs should not be included in effect or callback dependency arrays. Remove widthRef from the dependency array.
  const registryProps = useCallback((): ChildProps => ({type: 'divider', width: widthRef.current}), [widthRef])

packages/react/src/ActionBar/ActionBar.tsx:494

  • Including widthRef in the dependency array is unnecessary because refs are stable objects that never change identity. Refs should not be included in effect or callback dependency arrays. Remove widthRef from the dependency array.
      [props, disabled, onClick, groupId, widthRef],

@primer-integration
Copy link

👋 Hi from github/github-ui! Your integration PR is ready: https://github.com/github/github-ui/pull/14585

@siddharthkp
Copy link
Member

Sounds good!

Can you go a little into what this means for UnderlineNav? If you'd like, we can save that conversation for a different PR

it has the disadvantage of not working during SSR (because it relies on effects). This is not an issue for components like ActionBar where the data is being collected for a not-initially-visible overflow menu.

In addition, I'd like to reuse this pattern in the work I'm doing on UnderlineNav.

It provides a useful fallback for SSR environments

@iansan5653
Copy link
Contributor Author

iansan5653 commented Feb 26, 2026

Hmm, looks like integration CI is failing because of an unrelated change to PageLayout? Not sure how to unblock that:

Property 'Sidebar' does not exist on type 'FC<PropsWithChildren<SplitPageLayoutProps>> & { Header: FC<PropsWithChildren<PageLayoutHeaderProps>>; Content: FC<...>; Pane: FC<...>; Footer: FC<...>; }'.

Can you go a little into what this means for UnderlineNav?

Sure! UnderlineNav is currently using React.Children to collect the underline items for display in the overflow menu. But the overflow menu isn't initially visible, so we don't need SSR for that. This is essentially the same pattern as ActionBar, so we should be able to reuse the same registry pattern there. That would allow consumers to wrap items in fragments, abstract them out to reusable components, etc.

You can see my work in progress PR here #7506

@primer-integration
Copy link

Integration test results from github/github-ui:

Failed  CI   Failed
Failed  VRT   Failed
Passed  Projects   Passed

CI check runs linting, type checking, and unit tests. Check the workflow logs for specific failures.

VRT check ensures that when visual differences are detected, the PR cannot proceed until someone acknowledges the changes by adding the "visual difference acknowledged" label.

Need help? If you believe this failure is unrelated to your changes, please reach out to the Primer team for assistance.

@siddharthkp
Copy link
Member

siddharthkp commented Feb 27, 2026

Hmm, looks like integration CI is failing because of an unrelated change to PageLayout? Not sure how to unblock that

Strange because the CI for main is passing in github-ui: https://github.com/github/github-ui/pull/14190. Have you debugged this in a codespace yet?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

integration-tests: recommended This change needs to be tested for breaking changes. See https://arc.net/l/quote/tdmpakpm

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants