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.
Not duplicated here
Already in flight: #8562 (stable Escape and outside-click callbacks), #8563 to #8567 (React Compiler for ActionMenu, ButtonBase, LabelGroup, Autocomplete, Dialog), #8609 (LinkItem remount), #8610 (Breadcrumbs overflow). Recent merges already removed many :has() selectors (#8115, #7901), moved useMedia to useSyncExternalStore (#8001), and cached the scroll-ancestor walk (#8116).
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.- 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.
- Measured on a real
- 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.- 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.
- Measured: an open
- 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.
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.- 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.
- Measured: 200 rows by 3 columns, mounting calls
- 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).- 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.
- 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.- 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.
- 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.-
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.- 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.
- Measured: one closed
- 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.
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.- 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.
- Measured with esbuild 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.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. -
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. -
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.
Suggested order and verification
- P0 items, one small PR each, with a regression test where the behavior is observable.
- A repeatable benchmark 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