fix: reduce mode detection cost on DOM mutations#239
Draft
TrevorBurnham wants to merge 1 commit into
Draft
Conversation
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
force-pushed
the
perf/mode-detector-mutation-fanout
branch
from
July 27, 2026 13:57
d36b926 to
d58d712
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Description
The visual-mode detectors (
useCurrentMode,useDensityMode,useReducedMotion) share oneMutationObserverondocument.bodywith{attributes: true, subtree: true}. On every attribute mutation anywhere in the document, all subscribers wake and each runs its ownfindUpUntilwalk 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.
Popoveris the common case: it callsusePortalModeClasses, 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,400classListreads.The trigger is routine.
focus-visibletogglesbody[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:
attributeFilter: ['class']on the observer. A mode is only ever expressed as a class name, soclassis the attribute whose change signals a mode change. Unrelated attribute mutations, including thefocus-visibletoggle, 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 watcheschildList, and a second observer watchesclassondocumentElement. Both are cases the previous code only handled by luck; there are now tests for them.Wake subscribers for a
childListchange only if it moved one. WatchingchildListon 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 aforeignObjectand 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.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 exportedisMotionDisableddeliberately 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.
readscountsDOMTokenList.containscalls, which is engine-independent; the per-row constant is what transfers.One click carrying a single
data-awsui-focus-visibleflip:One
classmutation, whichattributeFiltercannot skip, at 800 rows: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
MutationObserveralready coalesces synchronous mutations. 1, 5, and 10 mutations in one task all produced identical read counts and timings.childListto the observer costs nothing measurable: mounting 800 rows takes 894–918 ms with this change against a 898–914 ms baseline.childListgate 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:unitpasses (459 tests). New tests insrc/internal/visual-mode/__tests__/use-visual-mode-performance.test.tsxcover:classattribute mutationclassListreads per additional subscriber, independent of ancestor depth<body><body>, ondocumentElementforeignObjectwhose<svg>is movedisMotionDisabledreturning the live answer when called from an effect during a fan-outEach 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.