Skip to content

Performance audit: prioritized fixes for render cost, listener leaks, and bundle weight #8676

Open
0 of 12 issues completed

Description

@mattcosta7

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

  1. Merge Fix ActionMenu anchor keydown listener cleanup #8709 (P0 leak). It's done apart from review and a CI re-run.
  2. Fix useAnchoredPosition (P0, useAnchoredPosition: skip re-renders when the overlay position is unchanged #8713). It affects every open ActionMenu, SelectPanel, Autocomplete and AnchoredOverlay.
  3. Fix TableBulkActions (P0, DataTable: TableBulkActions re-creates a MutationObserver on document.body every render #8715). It's a one-line change.
  4. 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.
  5. Build the benchmark harness (Add repeatable performance benchmarks for list, tree and button stress cases #8714) before starting the P1 memoization work.
  6. 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).
  7. 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.
  8. Anyone can take the P2 dependency cleanup (Remove unused hsluv and react-intersection-observer dependencies #8723) and the P3 kbd-chord change (Button: replace :has([data-kbd-chord]) with a data attribute set from React #8724), which make good first contributions.
  9. 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.

P0: bugs that cost performance

  • ActionMenu leaks a keydown handler on every open and close. hooks/useMenuKeyboardNavigation.ts:112 returns () => anchor?.addEventListener('keydown', handler) from its cleanup. It should call removeEventListener.
    • Status: 🟡 Fix ActionMenu anchor keydown listener cleanup #8709 is open with the fix and a regression test.
    • Measured on a real ActionMenu: live keydown handlers 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-106 always returns a new object from the setPosition updater, so React never bails out. The window-size observer at :142 calls useResizeObserver with no target, which observes document.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 AnchoredOverlay committed 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 prev when top, left, and anchorSide are unchanged. Listen to window resize for viewport changes, as PageLayout/usePaneWidth.ts already does.
    • Related, not measured: updatePosition reads settings but the deps array at :115 leaves it out, so side or align changes while open may not reposition.
  • DataTable bulk actions rebuild a MutationObserver on document.body every render. DataTable/TableBulkActions.tsx:58-63 is a layout effect with no dependency array. observeFocusedAction observes body with {childList, subtree}. onFocusCapture at :75 already calls it. Fix: drop the effect or depend on selectedCount. Code verified, not measured.

P1: costs that grow with list size or instance count

P2: bundle weight

  • The legacy JS theme ships with the root ThemeProvider. ThemeProvider.tsx:5 imports theme.ts, which imports legacy-theme/ts/color-schemes.ts (327 KB of source). index.ts:3 also exports theme.
    • 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 day and night color modes) is another breaking ThemeProvider change planned for the next major. The two could ship together.
    • Measured with esbuild on theme.ts alone: 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/next already avoids the theme import, and the root ThemeProvider is 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.
  • Dependencies with no production use. hsluv has no imports anywhere in the repo except package.json. react-intersection-observer is imported only by two test files, yet is listed under dependencies. Both affect install size only.

P3: small and local

Suggested order and verification

The Status section above shows the current priority order. The original plan:

  1. P0 items, one small PR each, with a regression test where the behavior is observable.
  2. 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.
  3. 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 useAnchoredPosition stale-settings correctness concern and the usePaneWidth per-tick state update are read from code only.
  • Consumer-side usage (real list sizes, whether items and data are stable) is unknown, and it determines the payoff of the memoization work.

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions