Skip to content

fix: reduce mode detection cost on DOM mutations#239

Draft
TrevorBurnham wants to merge 1 commit into
cloudscape-design:mainfrom
TrevorBurnham:perf/mode-detector-mutation-fanout
Draft

fix: reduce mode detection cost on DOM mutations#239
TrevorBurnham wants to merge 1 commit into
cloudscape-design:mainfrom
TrevorBurnham:perf/mode-detector-mutation-fanout

Conversation

@TrevorBurnham

@TrevorBurnham TrevorBurnham commented Jul 26, 2026

Copy link
Copy Markdown
Contributor

Description

The visual-mode detectors (useCurrentMode, useDensityMode, useReducedMotion) share one MutationObserver on document.body with {attributes: true, subtree: true}. On every attribute mutation anywhere in the document, all subscribers wake and each runs its own findUpUntil walk to the document root. Cost per mutation is O(subscribers × ancestor depth), with no attribute filtering and no sharing between subscribers.

This is fine for a handful of subscribers but degrades on pages that mount many. Popover is the common case: it calls usePortalModeClasses, which subscribes two detectors, so a table with a popover in every cell mounts thousands of subscribers. Measured on a table of 800 rows × 8 popovers (6,400 popovers, 12,800 subscribers), a single attribute mutation performed 486,400 classList reads.

The trigger is routine. focus-visible toggles body[data-awsui-focus-visible] on every keydown and mousedown, so any click following a keystroke pays the full fan-out — for an attribute that cannot affect a detected mode.

Three changes:

  1. attributeFilter: ['class'] on the observer. A mode is only ever expressed as a class name, so class is the attribute whose change signals a mode change. Unrelated attribute mutations, including the focus-visible toggle, no longer wake any subscriber.

    Filtering removes churn the detectors were previously relying on by accident. An unfiltered observer fires for any attribute change in the subtree, which meant mode changes it never actually watched — a class on <html>, or an element being moved into a differently-themed subtree — were corrected incidentally by whatever unrelated mutation happened to come next. To keep those working, the observer also watches childList, and a second observer watches class on documentElement. Both are cases the previous code only handled by luck; there are now tests for them.

  2. Wake subscribers for a childList change only if it moved one. Watching childList on the whole body subtree would otherwise fire on every node insertion on the page, nearly all of it ordinary rendering that changes nobody's mode. A move matters exactly when a subscriber sits at or below a moved node, so the module tracks which nodes lie on some subscriber's ancestor chain and tests the reported nodes against that — one map lookup each, rather than re-walking every subscriber per mutation. Unrelated churn is the worst case for such a walk, since it can only conclude "nobody moved" after walking everyone.

    Chains are tracked by node identity, so an <svg> between a foreignObject and its subscriber needs no special handling. Chains are rebuilt lazily on the next read after anything reshapes one, so a burst of mutations costs one rebuild rather than one per mutation, and a subscriber that unmounts while already detached cannot corrupt the counts by unwinding the wrong chain.

  3. Memoize ancestor lookups within a mutation flush. Walk up once and record the result for every element on the traversed path, so subscribers sharing ancestors resolve in constant time. Cost per flush goes from O(subscribers × depth) to O(distinct paths).

    The cache lives only for one fan-out and is discarded in a finally. All subscribers run synchronously inside the observer callback, before React processes any effect in the batch, so they share one consistent view of the DOM. Only the internal detectors read the cache: the exported isMotionDisabled deliberately does not, because React runs effects while the cache is populated, and an effect that mutates a mode class and then calls it synchronously must see the live DOM.

These are complementary: the filters remove irrelevant wake-ups entirely, and the memoization reduces the cost of the mutations that do get through.

Measurements

Real Chrome (headless 131), production build, table of N rows × 8 popovers. reads counts DOMTokenList.contains calls, which is engine-independent; the per-row constant is what transfers.

One click carrying a single data-awsui-focus-visible flip:

rows subscribers before after reads before reads after
100 1,600 11.3 ms 7.6 ms 60,800 0
400 6,400 31.9 ms 19.6 ms 243,200 0
800 12,800 54.4 ms 33.9 ms 486,400 0

One class mutation, which attributeFilter cannot skip, at 800 rows:

ancestor depth above the table before after
baseline 22.8 ms · 486,400 reads 17.9 ms · 105,656 reads
+25 wrapper elements 49.0 ms · 1,126,400 reads 15.8 ms · 105,756 reads

Reads per row drop from 608 to 132, and from 1,408 to 132 in the deeper tree — cost stops growing with ancestor depth.

The residual time after the fix is browser style recalc from the attribute write itself, not detector work.

Notes

  • Deferring the fan-out to a microtask or animation frame was considered and is not included: MutationObserver already coalesces synchronous mutations. 1, 5, and 10 mutations in one task all produced identical read counts and timings.
  • Sharing a single result across all subscribers, rather than per ancestor path, would be faster still but incorrect — a mode class can be applied to an intermediate ancestor, so subscribers in different subtrees can legitimately resolve differently. The added tests cover that case.
  • Adding childList to the observer costs nothing measurable: mounting 800 rows takes 894–918 ms with this change against a 898–914 ms baseline.
  • The childList gate is what keeps that true. With 1,600 subscribers mounted, an unrelated node insertion costs 0.038 ms via chain-membership lookup; testing the same thing by walking every subscriber's chain cost 1.22 ms and grew with subscriber count.

Testing

npm run test:unit passes (459 tests). New tests in src/internal/visual-mode/__tests__/use-visual-mode-performance.test.tsx cover:

  • no subscriber wake-up for a non-class attribute mutation
  • no subscriber wake-up for node insertions that move no subscriber
  • bounded marginal classList reads per additional subscriber, independent of ancestor depth
  • no cache reuse across separate flushes (repeated mode switches resolve correctly)
  • subscribers in sibling subtrees resolving to different modes
  • a mode class applied to an intermediate ancestor rather than <body>
  • a mode class applied above <body>, on documentElement
  • an element moved into a subtree with a different mode, with no class changing
  • a move split across two flushes, arriving as an insertion with no matching removal
  • a subscriber inside a foreignObject whose <svg> is moved
  • two subscribers sharing one ref, where only one unmounts
  • a subscriber that unmounts while already detached, leaving another still tracked
  • isMotionDisabled returning the live answer when called from an effect during a fan-out

Each of these fails against some intermediate version of this change; they are regression guards, not descriptions.

Existing behavior tests for all three detectors pass unchanged. Verified separately in Chrome that dynamic light↔dark and comfortable↔compact switching still works, including when the class is applied to an intermediate ancestor.

Scope the visual-mode MutationObserver to the mutations that can actually change
a mode, and share ancestor lookups between subscribers within a single flush.
@TrevorBurnham
TrevorBurnham force-pushed the perf/mode-detector-mutation-fanout branch from d36b926 to d58d712 Compare July 27, 2026 13:57
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant