Skip to content

Commit d58d712

Browse files
committed
fix: reduce mode detection cost on DOM mutations
Scope the visual-mode MutationObserver to the mutations that can actually change a mode, and share ancestor lookups between subscribers within a single flush.
1 parent ab5c72f commit d58d712

2 files changed

Lines changed: 572 additions & 9 deletions

File tree

Lines changed: 328 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,328 @@
1+
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
import React, { useLayoutEffect, useRef, useState } from 'react';
5+
import { isMotionDisabled, useCurrentMode, useDensityMode, useReducedMotion } from '../index';
6+
import { render, screen } from '@testing-library/react';
7+
import { mutate } from './utils';
8+
9+
function ModeRender({ testId }: { testId: string }) {
10+
const ref = useRef(null);
11+
const colorMode = useCurrentMode(ref);
12+
const densityMode = useDensityMode(ref);
13+
return (
14+
<div ref={ref} data-testid={testId}>
15+
{colorMode}-{densityMode}
16+
</div>
17+
);
18+
}
19+
20+
/** Renders `count` detectors under a shared ancestor chain of the given depth. */
21+
function ManyDetectors({ count, depth }: { count: number; depth: number }) {
22+
let tree = (
23+
<>
24+
{Array.from({ length: count }, (_, i) => (
25+
<ModeRender key={i} testId={`detector-${i}`} />
26+
))}
27+
</>
28+
);
29+
for (let i = 0; i < depth; i++) {
30+
tree = <div className={`level-${i}`}>{tree}</div>;
31+
}
32+
return tree;
33+
}
34+
35+
function spyOnClassListReads() {
36+
const spy = jest.fn();
37+
const descriptor = Object.getOwnPropertyDescriptor(Element.prototype, 'classList')!;
38+
jest.spyOn(Element.prototype, 'classList', 'get').mockImplementation(function (this: Element) {
39+
spy();
40+
return descriptor.get!.call(this);
41+
});
42+
return spy;
43+
}
44+
45+
afterEach(() => {
46+
jest.restoreAllMocks();
47+
// Reset shared state explicitly rather than in each test, so that one failing assertion
48+
// cannot leave a mode class behind and cascade into unrelated failures.
49+
document.documentElement.className = '';
50+
document.body.className = '';
51+
document.body.replaceChildren();
52+
});
53+
54+
describe('mode detection cost', () => {
55+
test('does not wake subscribers for attribute changes that cannot affect a mode', async () => {
56+
render(<ManyDetectors count={5} depth={3} />);
57+
58+
const reads = spyOnClassListReads();
59+
await mutate(() => document.body.setAttribute('data-awsui-focus-visible', 'true'));
60+
expect(reads).not.toHaveBeenCalled();
61+
62+
await mutate(() => document.body.removeAttribute('data-awsui-focus-visible'));
63+
expect(reads).not.toHaveBeenCalled();
64+
});
65+
66+
test('shares ancestor lookups between subscribers within a single flush', async () => {
67+
const depth = 20;
68+
async function countReadsForOneFlush(detectorCount: number) {
69+
const { container, unmount } = render(<ManyDetectors count={detectorCount} depth={depth} />);
70+
const reads = spyOnClassListReads();
71+
await mutate(() => container.classList.add('unrelated-class'));
72+
const total = reads.mock.calls.length;
73+
jest.restoreAllMocks();
74+
unmount();
75+
return total;
76+
}
77+
78+
const withForty = await countReadsForOneFlush(40);
79+
const withEighty = await countReadsForOneFlush(80);
80+
81+
// Assert the marginal cost of one more subscriber, which is what memoization bounds.
82+
// Each additional subscriber should only read its own element's classList, once per
83+
// detected mode, because its ancestors were already resolved by an earlier subscriber.
84+
// Without memoization each one re-walks the shared chain instead, so the marginal cost
85+
// would scale with `depth`.
86+
const marginalReadsPerSubscriber = (withEighty - withForty) / 40;
87+
expect(marginalReadsPerSubscriber).toBeLessThan(depth / 2);
88+
});
89+
90+
test('does not wake subscribers for node insertions that move no subscriber', async () => {
91+
render(<ManyDetectors count={5} depth={3} />);
92+
const unrelated = document.createElement('div');
93+
unrelated.appendChild(document.createElement('span'));
94+
document.body.appendChild(unrelated);
95+
await mutate(() => undefined);
96+
97+
const reads = spyOnClassListReads();
98+
await mutate(() => unrelated.appendChild(document.createElement('span')));
99+
expect(reads).not.toHaveBeenCalled();
100+
101+
await mutate(() => unrelated.firstElementChild!.remove());
102+
expect(reads).not.toHaveBeenCalled();
103+
});
104+
105+
test('wakes subscribers when a childList change moves one of them', async () => {
106+
const host = document.createElement('div');
107+
document.body.appendChild(host);
108+
render(<ManyDetectors count={5} depth={3} />, { container: host });
109+
await mutate(() => undefined);
110+
111+
const reads = spyOnClassListReads();
112+
// The subscribers sit below `host`, so re-parenting it changes their ancestor chains.
113+
const newParent = document.createElement('div');
114+
document.body.appendChild(newParent);
115+
await mutate(() => newParent.appendChild(host));
116+
expect(reads).toHaveBeenCalled();
117+
});
118+
119+
test('wakes a subscriber inside a foreignObject when its svg is moved', async () => {
120+
const svgNamespace = 'http://www.w3.org/2000/svg';
121+
const svg = document.createElementNS(svgNamespace, 'svg');
122+
const foreignObject = document.createElementNS(svgNamespace, 'foreignObject');
123+
const host = document.createElement('div');
124+
foreignObject.appendChild(host);
125+
svg.appendChild(foreignObject);
126+
document.body.appendChild(svg);
127+
const darkSubtree = document.createElement('div');
128+
darkSubtree.className = 'awsui-dark-mode';
129+
document.body.appendChild(darkSubtree);
130+
131+
render(<ModeRender testId="detector" />, { container: host });
132+
expect(screen.getByTestId('detector')).toHaveTextContent('light-comfortable');
133+
134+
// The moved node is the <svg>, which is not an HTMLElement. The childList check has to
135+
// walk past it to find the subscriber below.
136+
await mutate(() => darkSubtree.appendChild(svg));
137+
expect(screen.getByTestId('detector')).toHaveTextContent('dark-comfortable');
138+
});
139+
140+
test('detects a move that spans two flushes, reported as a removal then an insertion', async () => {
141+
const host = document.createElement('div');
142+
document.body.appendChild(host);
143+
const darkSubtree = document.createElement('div');
144+
darkSubtree.className = 'awsui-dark-mode';
145+
document.body.appendChild(darkSubtree);
146+
147+
render(<ModeRender testId="detector" />, { container: host });
148+
expect(screen.getByTestId('detector')).toHaveTextContent('light-comfortable');
149+
150+
// Detaching and re-attaching in separate flushes splits the move across two records, so
151+
// the insertion arrives without the matching removal to identify it by.
152+
await mutate(() => host.remove());
153+
await mutate(() => darkSubtree.appendChild(host));
154+
expect(screen.getByTestId('detector')).toHaveTextContent('dark-comfortable');
155+
});
156+
157+
test('keeps detecting moves after a subscriber sharing the same ref unmounts', async () => {
158+
const host = document.createElement('div');
159+
document.body.appendChild(host);
160+
const darkSubtree = document.createElement('div');
161+
darkSubtree.className = 'awsui-dark-mode';
162+
document.body.appendChild(darkSubtree);
163+
164+
// The hooks take a ref rather than owning one, so two subscribers can share an element.
165+
function DensityOnSharedRef({ sharedRef }: { sharedRef: React.RefObject<HTMLElement> }) {
166+
return <span>{useDensityMode(sharedRef)}</span>;
167+
}
168+
169+
let hideInner: () => void = () => undefined;
170+
function SharedRefDetectors() {
171+
const ref = useRef<HTMLDivElement>(null);
172+
const colorMode = useCurrentMode(ref);
173+
const [showInner, setShowInner] = useState(true);
174+
hideInner = () => setShowInner(false);
175+
return (
176+
<div ref={ref} data-testid="detector">
177+
{colorMode}
178+
{showInner && <DensityOnSharedRef sharedRef={ref} />}
179+
</div>
180+
);
181+
}
182+
183+
render(<SharedRefDetectors />, { container: host });
184+
expect(screen.getByTestId('detector')).toHaveTextContent('light');
185+
186+
// Unmounting one of the two must not discard the bookkeeping the other still needs.
187+
await mutate(() => hideInner());
188+
await mutate(() => darkSubtree.appendChild(host));
189+
expect(screen.getByTestId('detector')).toHaveTextContent('dark');
190+
});
191+
192+
test('keeps detecting moves after a subscriber unmounts while detached', async () => {
193+
const host = document.createElement('div');
194+
document.body.appendChild(host);
195+
const darkSubtree = document.createElement('div');
196+
darkSubtree.className = 'awsui-dark-mode';
197+
document.body.appendChild(darkSubtree);
198+
const staying = document.createElement('div');
199+
document.body.appendChild(staying);
200+
201+
const leaving = render(<ModeRender testId="leaving" />, { container: host });
202+
render(<ModeRender testId="staying" />, { container: staying });
203+
await mutate(() => undefined);
204+
205+
// Detach first, then unmount: the ancestor chain at cleanup time is not the one that was
206+
// recorded on mount, so the bookkeeping cannot be unwound along it.
207+
await mutate(() => host.remove());
208+
leaving.unmount();
209+
210+
await mutate(() => darkSubtree.appendChild(staying));
211+
expect(screen.getByTestId('staying')).toHaveTextContent('dark-comfortable');
212+
});
213+
214+
test('does not reuse cached lookups across separate flushes', async () => {
215+
const { container } = render(<ModeRender testId="detector" />);
216+
expect(screen.getByTestId('detector')).toHaveTextContent('light-comfortable');
217+
218+
await mutate(() => container.classList.add('awsui-dark-mode'));
219+
expect(screen.getByTestId('detector')).toHaveTextContent('dark-comfortable');
220+
221+
await mutate(() => container.classList.add('awsui-compact-mode'));
222+
expect(screen.getByTestId('detector')).toHaveTextContent('dark-compact');
223+
224+
await mutate(() => container.classList.remove('awsui-dark-mode', 'awsui-compact-mode'));
225+
expect(screen.getByTestId('detector')).toHaveTextContent('light-comfortable');
226+
});
227+
228+
test('resolves each subscriber independently when modes differ by subtree', () => {
229+
render(
230+
<>
231+
<div className="awsui-dark-mode">
232+
<ModeRender testId="in-dark" />
233+
</div>
234+
<div className="awsui-compact-mode">
235+
<ModeRender testId="in-compact" />
236+
</div>
237+
<ModeRender testId="in-neither" />
238+
</>
239+
);
240+
241+
expect(screen.getByTestId('in-dark')).toHaveTextContent('dark-comfortable');
242+
expect(screen.getByTestId('in-compact')).toHaveTextContent('light-compact');
243+
expect(screen.getByTestId('in-neither')).toHaveTextContent('light-comfortable');
244+
});
245+
246+
test('detects a mode class applied above body', async () => {
247+
render(<ModeRender testId="detector" />);
248+
expect(screen.getByTestId('detector')).toHaveTextContent('light-comfortable');
249+
250+
await mutate(() => document.documentElement.classList.add('awsui-dark-mode'));
251+
expect(screen.getByTestId('detector')).toHaveTextContent('dark-comfortable');
252+
253+
await mutate(() => document.documentElement.classList.remove('awsui-dark-mode'));
254+
expect(screen.getByTestId('detector')).toHaveTextContent('light-comfortable');
255+
});
256+
257+
test('detects a move into a subtree with a different mode', async () => {
258+
const host = document.createElement('div');
259+
document.body.appendChild(host);
260+
const darkSubtree = document.createElement('div');
261+
darkSubtree.className = 'awsui-dark-mode';
262+
document.body.appendChild(darkSubtree);
263+
264+
render(<ModeRender testId="detector" />, { container: host });
265+
expect(screen.getByTestId('detector')).toHaveTextContent('light-comfortable');
266+
267+
// No class changes here: only the ancestor chain does.
268+
await mutate(() => darkSubtree.appendChild(host));
269+
expect(screen.getByTestId('detector')).toHaveTextContent('dark-comfortable');
270+
271+
await mutate(() => document.body.appendChild(host));
272+
expect(screen.getByTestId('detector')).toHaveTextContent('light-comfortable');
273+
});
274+
275+
test('isMotionDisabled reads the live DOM when called during a fan-out', async () => {
276+
const wrapper = document.createElement('div');
277+
document.body.appendChild(wrapper);
278+
let observedDuringFlush: boolean | undefined = undefined;
279+
280+
function MotionSubscriber() {
281+
const ref = useRef(null);
282+
useReducedMotion(ref);
283+
return <div ref={ref} />;
284+
}
285+
286+
// This effect runs inside the fan-out, after MotionSubscriber has already resolved (and
287+
// cached) the motion lookup for the shared ancestor chain.
288+
function MutatingDuringFlush() {
289+
const ref = useRef<HTMLDivElement>(null);
290+
const colorMode = useCurrentMode(ref);
291+
useLayoutEffect(() => {
292+
if (colorMode === 'dark' && ref.current) {
293+
wrapper.classList.add('awsui-motion-disabled');
294+
observedDuringFlush = isMotionDisabled(ref.current);
295+
}
296+
}, [colorMode]);
297+
return <div ref={ref} />;
298+
}
299+
300+
render(
301+
<>
302+
<MotionSubscriber />
303+
<MutatingDuringFlush />
304+
</>,
305+
{ container: wrapper }
306+
);
307+
await mutate(() => wrapper.classList.add('awsui-dark-mode'));
308+
309+
expect(observedDuringFlush).toBe(true);
310+
});
311+
312+
test('detects a mode applied to an intermediate ancestor rather than body', async () => {
313+
const { container } = render(
314+
<div className="outer">
315+
<div className="inner">
316+
<ModeRender testId="detector" />
317+
</div>
318+
</div>
319+
);
320+
const inner = container.querySelector('.inner')!;
321+
322+
await mutate(() => inner.classList.add('awsui-dark-mode'));
323+
expect(screen.getByTestId('detector')).toHaveTextContent('dark-comfortable');
324+
325+
await mutate(() => inner.classList.remove('awsui-dark-mode'));
326+
expect(screen.getByTestId('detector')).toHaveTextContent('light-comfortable');
327+
});
328+
});

0 commit comments

Comments
 (0)