Repository navigation
Description
Summary
An audit of @primer/react for runtime and bundle performance problems, ranked by impact. Each item below was checked against the code at 57c2ac594, and most were reproduced with a throwaway Vitest browser test (React 18.3.1, Chromium headless, React development build). Fixes must work on React 18 and 19, so they use only useSyncExternalStore, startTransition, useDeferredValue, React.memo, forwardRef, and the existing internal/hooks/useEffectCallback.
Absolute timings come from a development build, so treat them as relative. Re-render counts do not depend on the build.
Items are independent. Each can be its own PR, and the list can be split into separate issues if that is easier to track.
Status
Checked against main at 82d287d1c on 2026-11-09. No audit item has landed on main yet. Each item is marked done only after its fix merges.
Legend: 🟡 open PR · ⬜ not started · ⏸️ blocked on a decision
| Pri | Item | Status | Issue / PR | Next step |
|---|---|---|---|---|
| P0 | ActionMenu keydown leak | 🟡 | #8709 | Needs review. The failing test (react-18) check is an unrelated TreeView typeahead timeout, so re-run it. |
| P0 | useAnchoredPosition re-renders when nothing moved |
⬜ | #8713 | Assigned to @mattcosta7. Highest-impact open item: it affects every open overlay. |
| P0 | TableBulkActions MutationObserver every render |
⬜ | #8715 | Unclaimed. Small fix. |
| P1 | DataTable row toggle re-renders every cell | ⬜ | #8716 | Waiting on the benchmark |
| P1 | SelectPanel / FilteredActionList re-render every item | ⬜ | #8717 | Waiting on the benchmark. Draft #8641 removes the querySelector('live-region') but not the JSON.stringify. |
| P1 | TreeView ItemContext value not memoized |
⬜ | #8718 | Waiting on the benchmark. #8684 (merged) and #8682 (open) don't change the context value. |
| P1 | Every labelled IconButton mounts a TooltipV2 |
⬜ | #8719 | Unblocked, because #8562 merged on 2026-11-06 |
| P1 | No shared media-query store | ⬜ | #8720 | Unclaimed |
| P1 | Extend React Compiler coverage | 🟡 | #8721; PRs #8563, #8566, #8567, #8565 | Land the prerequisite PRs, then add the remaining files |
| P2 | Legacy JS theme in root ThemeProvider |
⏸️ | #8722 | Breaking change. Needs a decision on major-version timing. Could ship with #3246. |
| P2 | Unused hsluv and react-intersection-observer deps |
⬜ | #8723 | Unclaimed. Trivial. |
| P3 | :has([data-kbd-chord]) on every Button |
⬜ | #8724 | Unclaimed |
| P3 | ActionList * selectors on inactive and disabled items |
🟡 | #8682 | Needs review |
| P3 | usePaneWidth state sync per resize tick |
🟡 | #8685 (partial) | Needs review. After it merges, move the per-tick setMaxPaneWidth to the debounced end. |
| — | Repeatable benchmark (step 2 below) | ⬜ | #8714 | Unclaimed. Blocks before and after numbers for the P1 items. |
Suggested priority
- Merge Fix ActionMenu anchor keydown listener cleanup #8709 (P0 leak). It's done apart from review and a CI re-run.
- Fix
useAnchoredPosition(P0, useAnchoredPosition: skip re-renders when the overlay position is unchanged #8713). It affects every open ActionMenu, SelectPanel, Autocomplete and AnchoredOverlay. - Fix
TableBulkActions(P0, DataTable: TableBulkActions re-creates a MutationObserver on document.body every render #8715). It's a one-line change. - Land the React Compiler PRs, then do the remaining files in React Compiler: enable remaining overlay, list and focus files #8721: perf(react-compiler): enable ActionMenu #8563 and perf(Autocomplete): enable React Compiler for AutocompleteInput #8566 are approved, perf(Dialog): enable React Compiler #8567 needs review, and perf(LabelGroup): enable React Compiler #8565 is stacked on perf(Dialog): enable React Compiler #8567.
- Build the benchmark harness (Add repeatable performance benchmarks for list, tree and button stress cases #8714) before starting the P1 memoization work.
- P1 work in this order: IconButton tooltip (IconButton, Tooltip: reduce per-instance cost of the built-in tooltip #8719, now unblocked), shared media-query store (useMedia, useResponsiveValue: share MediaQueryList instances and listeners #8720), DataTable (DataTable: toggling one row's selection re-renders every cell #8716), SelectPanel and FilteredActionList (SelectPanel, FilteredActionList: typing re-renders every item #8717), TreeView context (TreeView: focusing a parent item re-renders its expanded subtree #8718).
- Review perf(ActionList, TreeView): avoid whole-subtree style invalidation on hover, focus and attribute changes #8682 and PageLayout: skip height-only viewport resizes and stop toggling containment on resize #8685 (P3) whenever there is capacity. They're independent of everything above.
- Anyone can take the P2 dependency cleanup (Remove unused hsluv and react-intersection-observer dependencies #8723) and the P3
kbd-chordchange (Button: replace :has([data-kbd-chord]) with a data attribute set from React #8724), which make good first contributions. - The legacy theme removal (ThemeProvider: stop bundling the legacy JS theme with the root entry #8722) waits for a major-version decision.
Not duplicated here
These were in flight when the audit was written.
- Merged: perf(hooks): stabilize Escape and outside-click callbacks #8562 (stable Escape and outside-click callbacks), fix(ActionList): preserve link item state across rerenders #8609 (LinkItem remount), perf(Breadcrumbs): rebuild overflow menu for fewer renders, cheaper layout and better SSR #8683 (Breadcrumbs overflow, replaces the closed perf(Breadcrumbs): remove render cascades; add CSS-driven overflow behind a feature flag #8610).
- Open: perf(react-compiler): enable ActionMenu #8563 (ActionMenu React Compiler; also covers ButtonBase from the closed perf(react-compiler): enable ButtonBase #8564), perf(Autocomplete): enable React Compiler for AutocompleteInput #8566 (AutocompleteInput), perf(Dialog): enable React Compiler #8567 (Dialog), and perf(LabelGroup): enable React Compiler #8565 (LabelGroup).
- Earlier merges already removed many
:has()selectors (perf(css): remove expensive :has() selectors for Safari style-recalc #8115, perf(PageHeader): replace 18 :has() selectors with hoisted data attributes #7901), moveduseMediatouseSyncExternalStore(perf(useMedia): read live matchMedia on first client render via useSyncExternalStore #8001), and cached the scroll-ancestor walk (perf(useAnchoredPosition): cache scrollable-ancestor walk per anchor #8116). - Related performance PRs merged since the audit, outside its scope: TreeView: reduce hydration and mount cost for large trees #8684 (TreeView mount and hydration), ActionMenu: Avoid passive anchor label updates #8611 (ActionMenu anchor label), Batch descendant registry updates outside effect commits #8612 (descendant registry batching), SelectPanel: Improve rendering performance #8613 (SelectPanel open rendering), and FilteredActionList: only reference the list from the input while it is rendered #8634 (FilteredActionList input).
P0: bugs that cost performance
- ActionMenu leaks a keydown handler on every open and close.
hooks/useMenuKeyboardNavigation.ts:112returns() => anchor?.addEventListener('keydown', handler)from its cleanup. It should callremoveEventListener.- Status: 🟡 Fix ActionMenu anchor keydown listener cleanup #8709 is open with the fix and a regression test.
- Measured on a real
ActionMenu: livekeydownhandlers on the anchor after each of 5 open/close cycles were 5, 7, 9, 11, 13. Two handlers leak per cycle. - Each stale handler closes over the menu container from that open, so detached menu DOM stays reachable for the life of the anchor (inferred from the closure, not heap-measured).
- Not observed: stale handlers moving focus in the real menu. They only did so in a harness where the container stayed attached.
- Fix is one word plus a regression test that opens and closes the menu several times and asserts the handler count.
- Open anchored overlays re-render when nothing moved.
hooks/useAnchoredPosition.ts:91-106always returns a new object from thesetPositionupdater, so React never bails out. The window-size observer at:142callsuseResizeObserverwith no target, which observesdocument.documentElement, so any page height change triggers an update.- Status: ⬜ Tracked in useAnchoredPosition: skip re-renders when the overlay position is unchanged #8713, assigned to @mattcosta7.
- Measured: an open
AnchoredOverlaycommitted 2 times across 5 document height changes below the anchor, with the position unchanged. The control, with no page change, committed 0 times in 300 ms. - Affects every open ActionMenu, SelectPanel, Autocomplete, and AnchoredOverlay.
- Fix: return
prevwhentop,left, andanchorSideare unchanged. Listen towindowresizefor viewport changes, asPageLayout/usePaneWidth.tsalready does. - Related, not measured:
updatePositionreadssettingsbut the deps array at:115leaves it out, so side or align changes while open may not reposition.
- DataTable bulk actions rebuild a
MutationObserverondocument.bodyevery render.DataTable/TableBulkActions.tsx:58-63is a layout effect with no dependency array.observeFocusedActionobservesbodywith{childList, subtree}.onFocusCaptureat:75already calls it. Fix: drop the effect or depend onselectedCount. Code verified, not measured.- Status: ⬜ Not started. Tracked in DataTable: TableBulkActions re-creates a MutationObserver on document.body every render #8715.
P1: costs that grow with list size or instance count
- Toggling one DataTable row re-renders every cell.
DataTable/useTable.ts:292-318creates new row and cell objects on every render, withselectedbaked in.TableRowandTableCellinTable.tsxare not memoized.- Status: ⬜ Not started. Tracked in DataTable: toggling one row's selection re-renders every cell #8716.
- Measured: 200 rows by 3 columns, mounting calls
renderCell600 times. Toggling one row's checkbox calls it 600 more times. - Passing a new
dataarray also re-sorts and rebuilds all rows (useTable.ts:175-229). - Fix: cache row objects by source row plus
selectedandselectable, add a memoized row component, and document stabledataandcolumns.
- SelectPanel and FilteredActionList re-render every item per keystroke.
FilteredActionList.tsx:622-650passes an inlineonSelecttoActionList.Item, andSelectPanel.tsx:688-730builds an inlineonActionper item. No ActionList, FilteredActionList, SelectPanel, DataTable, or TreeView component usesReact.memo(grep returns nothing).- Status: ⬜ Not started. Tracked in SelectPanel, FilteredActionList: typing re-renders every item #8717. SelectPanel: Improve rendering performance #8613 (merged) cut redundant renders when SelectPanel opens, not per keystroke. Draft refactor: use ariaNotify over @primer/live-region-element #8641 removes the
document.querySelector('live-region')call fromuseAnnouncements. - Measured: 300 items with a counting leading visual. Typing one character that matches every item re-rendered all 300.
FilteredActionList/useAnnouncements.tsxrunsJSON.stringifyover all items anddocument.querySelector('live-region')on every render, even with announcements disabled (code verified, not measured).- Fix:
React.memoonMappedActionListItemandActionList.Item, routeonSelectthrough a ref,useMemothe announcement state, and defer filtering withuseDeferredValue, asAutocomplete/AutocompleteMenu.tsx:229-236does. Memoization only pays off when consumers keep item identity stable, so measure with both stable and inlineitems.
- Status: ⬜ Not started. Tracked in SelectPanel, FilteredActionList: typing re-renders every item #8717. SelectPanel: Improve rendering performance #8613 (merged) cut redundant renders when SelectPanel opens, not per keystroke. Draft refactor: use ariaNotify over @primer/live-region-element #8641 removes the
- Focusing a TreeView parent re-renders its whole expanded subtree.
TreeView/TreeView.tsx:483-497passes a new object toItemContext.Provideron everyItemrender, andisFocusedstate at:294changes on focus and blur. The file is not React Compiler compiled.- Status: ⬜ Not started. Tracked in TreeView: focusing a parent item re-renders its expanded subtree #8718. The context value is still an inline object on
main, now at about:501. TreeView: reduce hydration and mount cost for large trees #8684 (merged) reduced mount and hydration cost, and perf(ActionList, TreeView): avoid whole-subtree style invalidation on hover, focus and attribute changes #8682 (open) changes only TreeView CSS. - Measured: summed update-commit time after tabbing to an expanded parent, median of 7 runs: 0.5 ms with 10 children, 2.7 ms with 100, 9.8 ms with 400. That is linear in subtree size.
- Fix:
useMemothe context value, or compile the file.
- Status: ⬜ Not started. Tracked in TreeView: focusing a parent item re-renders its expanded subtree #8718. The context value is still an inline object on
- Every labelled
IconButtonmounts a TooltipV2.Button/IconButton.tsx:40-60wraps the button in a Tooltip unlessunsafeDisableTooltipis set. Each instance registers a global Escape handler (TooltipV2/Tooltip.tsx:289) and runs its accessibility DOM scan on mount in production (:237-288;warningandinvariantare no-ops in production but their arguments are still computed).hooks/useOnEscapePress.tscallsObject.keys(registry)on every register and unregister.-
Status: ⬜ Not started. Tracked in IconButton, Tooltip: reduce per-instance cost of the built-in tooltip #8719. It was blocked on perf(hooks): stabilize Escape and outside-click callbacks #8562, which has now merged.
-
Measured, median of 5 runs, N IconButtons:
N mount, tooltip mount, none unmount, tooltip unmount, none 300 15 ms 9 ms 1.3 ms 0.3 ms 1000 48 ms 26 ms 10.1 ms 0.8 ms 3000 200 ms 73 ms 75.0 ms 2.8 ms -
Unmount is clearly super-linear (about 58x for 10x the items). Mount is mildly super-linear. At a few hundred buttons the cost is small.
-
Fix, after perf(hooks): stabilize Escape and outside-click callbacks #8562: subscribe to Escape only while the tooltip is open, wrap the accessibility scan in
__DEV__, and replaceObject.keys(registry).lengthwith a counter. Do not lazy-mount the tooltip element, becausearia-labelledbyneeds it in the DOM.
-
- No shared media-query store.
hooks/useResponsiveValue.ts:54-56always callsuseMediathree times, even when the value is not responsive.hooks/useMedia.ts:54callswindow.matchMedia(...)insidegetSnapshot, so every render allocates a newMediaQueryList.- Status: ⬜ Not started. Tracked in useMedia, useResponsiveValue: share MediaQueryList instances and listeners #8720.
- Measured: one closed
ActionMenuholds 9 livechangelisteners across 3 distinct queries. - Fix: a module-level
Map<query, MediaQueryList>with refcounted listeners, andgetSnapshotreading the cached.matches. Keep the hydration behavior from perf(useMedia): read live matchMedia on first client render via useSyncExternalStore #8001.
- Extend React Compiler coverage. After perf(react-compiler): enable ActionMenu #8563 to perf(Dialog): enable React Compiler #8567, the remaining hot files in
script/react-compiler.mjsunsupportedPatternsareFilteredActionList.tsx,SelectPanel.tsx,TreeView.tsx,AnchoredOverlay.tsx, and the hooksuseAnchoredPosition,useFocusTrap,useFocusZone,useResizeObserver. The build targets React 18 with a bundleduseMemofallback (rolldown.config.ts,utils/react-compiler-runtime.ts), so this is safe on both versions. Use the DataTable, SelectPanel, and TreeView measurements above as the before and after check.- Status: 🟡 The prerequisite PRs are still open: perf(react-compiler): enable ActionMenu #8563 (ActionMenu, approved), perf(Autocomplete): enable React Compiler for AutocompleteInput #8566 (AutocompleteInput, approved), perf(Dialog): enable React Compiler #8567 (Dialog, needs review), and perf(LabelGroup): enable React Compiler #8565 (LabelGroup, stacked on perf(Dialog): enable React Compiler #8567). The remaining files listed above are tracked in React Compiler: enable remaining overlay, list and focus files #8721.
P2: bundle weight
- The legacy JS theme ships with the root
ThemeProvider.ThemeProvider.tsx:5importstheme.ts, which importslegacy-theme/ts/color-schemes.ts(327 KB of source).index.ts:3also exportstheme.- Status: ⏸️ Tracked in ThemeProvider: stop bundling the legacy JS theme with the root entry #8722. Blocked on a decision about major-version timing. The import is still present on
main. This box was checked earlier, but no change has shipped. - Related: ThemeProvider: Deprecate redundant day and night color modes #3246 (deprecate the
dayandnightcolor modes) is another breakingThemeProviderchange planned for the next major. The two could ship together. - Measured with esbuild on
theme.tsalone: 275 KB minified, 20 KB gzipped. The gzip size is small because the data repeats, so parse and evaluation cost follows the 275 KB. - The per-provider
deepmerge(theme, scheme)is cheap: 0.4 to 0.6 ms warm and 1.5 to 3 ms on the first call (Node, 50 runs after warm-up, two runs). @primer/react/nextalready avoids the theme import, and the rootThemeProvideris already deprecated in favor of it. Removing the default theme from the root entry is a breaking change, so it needs a major version and a decision on timing.
- Status: ⏸️ Tracked in ThemeProvider: stop bundling the legacy JS theme with the root entry #8722. Blocked on a decision about major-version timing. The import is still present on
- Dependencies with no production use.
hsluvhas no imports anywhere in the repo exceptpackage.json.react-intersection-observeris imported only by two test files, yet is listed underdependencies. Both affect install size only.- Status: ⬜ Not started. Tracked in Remove unused hsluv and react-intersection-observer dependencies #8723.
historylooks removable (type imports only) but its types appear in public props such asto?: ToinBreadcrumbs.tsx:378, so it should stay.
P3: small and local
-
Button/ButtonBase.module.css:28and:217use:has([data-kbd-chord])on every Button. Set adata-*attribute from React instead, as ActionList already does.- Status: ⬜ Not started. Tracked in Button: replace :has([data-kbd-chord]) with a data attribute set from React #8724.
-
ActionList/ActionList.module.css:271,:280, and:346select every descendant (*) of inactive or disabled items. Set the color on the content wrapper and let children inherit.- Status: 🟡 perf(ActionList, TreeView): avoid whole-subtree style invalidation on hover, focus and attribute changes #8682 is open. It keeps the universal selectors but keys them on private classes, so they only invalidate when the item's own state changes.
-
PageLayout/usePaneWidth.ts:404-410callssetMaxPaneWidthinstartTransitionwhenever the computed max differs, which is likely every throttled tick while the window resizes. Keep the imperative writes per tick and sync React state once at the debounced end. The per-tick change is inferred, not measured.- Status: 🟡 Partly covered by open PageLayout: skip height-only viewport resizes and stop toggling containment on resize #8685. It skips height-only resizes and stops toggling containment during resize. Width changes still sync state on every throttled tick.
Suggested order and verification
The Status section above shows the current priority order. The original plan:
- P0 items, one small PR each, with a regression test where the behavior is observable.
- A repeatable benchmark (Add repeatable performance benchmarks for list, tree and button stress cases #8714) before the P1 work, so each PR reports before and after numbers. The measurements above came from throwaway tests that render with counting components and the React
Profiler. They can become Vitest browser benchmarks or Playwright traces over the existing stress stories (TreeView.features.stories.tsx,PageLayout.performance.stories.tsx), plus new stories for 1000-item SelectPanel typing, a 1000-row DataTable toggle, and a page of 1000 IconButtons. Run each on React 18 and 19. - P1 items in the order listed, then P2 and P3.
Not verified
- No measurement on React 19. The leak and re-render counts do not depend on the React version, but timings might.
- Timings are from a development build in headless Chromium on one machine, not a production build on throttled hardware.
- The retained-DOM effect of the keydown leak is inferred from the closure, not measured with a heap snapshot.
- The
useAnchoredPositionstale-settingscorrectness concern and theusePaneWidthper-tick state update are read from code only. - Consumer-side usage (real list sizes, whether
itemsanddataare stable) is unknown, and it determines the payoff of the memoization work.
Activity
fix: M3: merge the objects of one kind in a burst: mentions, sub-issu…