diff --git a/src/internal/visual-mode/__tests__/use-visual-mode-performance.test.tsx b/src/internal/visual-mode/__tests__/use-visual-mode-performance.test.tsx new file mode 100644 index 0000000..635c425 --- /dev/null +++ b/src/internal/visual-mode/__tests__/use-visual-mode-performance.test.tsx @@ -0,0 +1,328 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import React, { useLayoutEffect, useRef, useState } from 'react'; +import { isMotionDisabled, useCurrentMode, useDensityMode, useReducedMotion } from '../index'; +import { render, screen } from '@testing-library/react'; +import { mutate } from './utils'; + +function ModeRender({ testId }: { testId: string }) { + const ref = useRef(null); + const colorMode = useCurrentMode(ref); + const densityMode = useDensityMode(ref); + return ( +
+ {colorMode}-{densityMode} +
+ ); +} + +/** Renders `count` detectors under a shared ancestor chain of the given depth. */ +function ManyDetectors({ count, depth }: { count: number; depth: number }) { + let tree = ( + <> + {Array.from({ length: count }, (_, i) => ( + + ))} + + ); + for (let i = 0; i < depth; i++) { + tree =
{tree}
; + } + return tree; +} + +function spyOnClassListReads() { + const spy = jest.fn(); + const descriptor = Object.getOwnPropertyDescriptor(Element.prototype, 'classList')!; + jest.spyOn(Element.prototype, 'classList', 'get').mockImplementation(function (this: Element) { + spy(); + return descriptor.get!.call(this); + }); + return spy; +} + +afterEach(() => { + jest.restoreAllMocks(); + // Reset shared state explicitly rather than in each test, so that one failing assertion + // cannot leave a mode class behind and cascade into unrelated failures. + document.documentElement.className = ''; + document.body.className = ''; + document.body.replaceChildren(); +}); + +describe('mode detection cost', () => { + test('does not wake subscribers for attribute changes that cannot affect a mode', async () => { + render(); + + const reads = spyOnClassListReads(); + await mutate(() => document.body.setAttribute('data-awsui-focus-visible', 'true')); + expect(reads).not.toHaveBeenCalled(); + + await mutate(() => document.body.removeAttribute('data-awsui-focus-visible')); + expect(reads).not.toHaveBeenCalled(); + }); + + test('shares ancestor lookups between subscribers within a single flush', async () => { + const depth = 20; + async function countReadsForOneFlush(detectorCount: number) { + const { container, unmount } = render(); + const reads = spyOnClassListReads(); + await mutate(() => container.classList.add('unrelated-class')); + const total = reads.mock.calls.length; + jest.restoreAllMocks(); + unmount(); + return total; + } + + const withForty = await countReadsForOneFlush(40); + const withEighty = await countReadsForOneFlush(80); + + // Assert the marginal cost of one more subscriber, which is what memoization bounds. + // Each additional subscriber should only read its own element's classList, once per + // detected mode, because its ancestors were already resolved by an earlier subscriber. + // Without memoization each one re-walks the shared chain instead, so the marginal cost + // would scale with `depth`. + const marginalReadsPerSubscriber = (withEighty - withForty) / 40; + expect(marginalReadsPerSubscriber).toBeLessThan(depth / 2); + }); + + test('does not wake subscribers for node insertions that move no subscriber', async () => { + render(); + const unrelated = document.createElement('div'); + unrelated.appendChild(document.createElement('span')); + document.body.appendChild(unrelated); + await mutate(() => undefined); + + const reads = spyOnClassListReads(); + await mutate(() => unrelated.appendChild(document.createElement('span'))); + expect(reads).not.toHaveBeenCalled(); + + await mutate(() => unrelated.firstElementChild!.remove()); + expect(reads).not.toHaveBeenCalled(); + }); + + test('wakes subscribers when a childList change moves one of them', async () => { + const host = document.createElement('div'); + document.body.appendChild(host); + render(, { container: host }); + await mutate(() => undefined); + + const reads = spyOnClassListReads(); + // The subscribers sit below `host`, so re-parenting it changes their ancestor chains. + const newParent = document.createElement('div'); + document.body.appendChild(newParent); + await mutate(() => newParent.appendChild(host)); + expect(reads).toHaveBeenCalled(); + }); + + test('wakes a subscriber inside a foreignObject when its svg is moved', async () => { + const svgNamespace = 'http://www.w3.org/2000/svg'; + const svg = document.createElementNS(svgNamespace, 'svg'); + const foreignObject = document.createElementNS(svgNamespace, 'foreignObject'); + const host = document.createElement('div'); + foreignObject.appendChild(host); + svg.appendChild(foreignObject); + document.body.appendChild(svg); + const darkSubtree = document.createElement('div'); + darkSubtree.className = 'awsui-dark-mode'; + document.body.appendChild(darkSubtree); + + render(, { container: host }); + expect(screen.getByTestId('detector')).toHaveTextContent('light-comfortable'); + + // The moved node is the , which is not an HTMLElement. The childList check has to + // walk past it to find the subscriber below. + await mutate(() => darkSubtree.appendChild(svg)); + expect(screen.getByTestId('detector')).toHaveTextContent('dark-comfortable'); + }); + + test('detects a move that spans two flushes, reported as a removal then an insertion', async () => { + const host = document.createElement('div'); + document.body.appendChild(host); + const darkSubtree = document.createElement('div'); + darkSubtree.className = 'awsui-dark-mode'; + document.body.appendChild(darkSubtree); + + render(, { container: host }); + expect(screen.getByTestId('detector')).toHaveTextContent('light-comfortable'); + + // Detaching and re-attaching in separate flushes splits the move across two records, so + // the insertion arrives without the matching removal to identify it by. + await mutate(() => host.remove()); + await mutate(() => darkSubtree.appendChild(host)); + expect(screen.getByTestId('detector')).toHaveTextContent('dark-comfortable'); + }); + + test('keeps detecting moves after a subscriber sharing the same ref unmounts', async () => { + const host = document.createElement('div'); + document.body.appendChild(host); + const darkSubtree = document.createElement('div'); + darkSubtree.className = 'awsui-dark-mode'; + document.body.appendChild(darkSubtree); + + // The hooks take a ref rather than owning one, so two subscribers can share an element. + function DensityOnSharedRef({ sharedRef }: { sharedRef: React.RefObject }) { + return {useDensityMode(sharedRef)}; + } + + let hideInner: () => void = () => undefined; + function SharedRefDetectors() { + const ref = useRef(null); + const colorMode = useCurrentMode(ref); + const [showInner, setShowInner] = useState(true); + hideInner = () => setShowInner(false); + return ( +
+ {colorMode} + {showInner && } +
+ ); + } + + render(, { container: host }); + expect(screen.getByTestId('detector')).toHaveTextContent('light'); + + // Unmounting one of the two must not discard the bookkeeping the other still needs. + await mutate(() => hideInner()); + await mutate(() => darkSubtree.appendChild(host)); + expect(screen.getByTestId('detector')).toHaveTextContent('dark'); + }); + + test('keeps detecting moves after a subscriber unmounts while detached', async () => { + const host = document.createElement('div'); + document.body.appendChild(host); + const darkSubtree = document.createElement('div'); + darkSubtree.className = 'awsui-dark-mode'; + document.body.appendChild(darkSubtree); + const staying = document.createElement('div'); + document.body.appendChild(staying); + + const leaving = render(, { container: host }); + render(, { container: staying }); + await mutate(() => undefined); + + // Detach first, then unmount: the ancestor chain at cleanup time is not the one that was + // recorded on mount, so the bookkeeping cannot be unwound along it. + await mutate(() => host.remove()); + leaving.unmount(); + + await mutate(() => darkSubtree.appendChild(staying)); + expect(screen.getByTestId('staying')).toHaveTextContent('dark-comfortable'); + }); + + test('does not reuse cached lookups across separate flushes', async () => { + const { container } = render(); + expect(screen.getByTestId('detector')).toHaveTextContent('light-comfortable'); + + await mutate(() => container.classList.add('awsui-dark-mode')); + expect(screen.getByTestId('detector')).toHaveTextContent('dark-comfortable'); + + await mutate(() => container.classList.add('awsui-compact-mode')); + expect(screen.getByTestId('detector')).toHaveTextContent('dark-compact'); + + await mutate(() => container.classList.remove('awsui-dark-mode', 'awsui-compact-mode')); + expect(screen.getByTestId('detector')).toHaveTextContent('light-comfortable'); + }); + + test('resolves each subscriber independently when modes differ by subtree', () => { + render( + <> +
+ +
+
+ +
+ + + ); + + expect(screen.getByTestId('in-dark')).toHaveTextContent('dark-comfortable'); + expect(screen.getByTestId('in-compact')).toHaveTextContent('light-compact'); + expect(screen.getByTestId('in-neither')).toHaveTextContent('light-comfortable'); + }); + + test('detects a mode class applied above body', async () => { + render(); + expect(screen.getByTestId('detector')).toHaveTextContent('light-comfortable'); + + await mutate(() => document.documentElement.classList.add('awsui-dark-mode')); + expect(screen.getByTestId('detector')).toHaveTextContent('dark-comfortable'); + + await mutate(() => document.documentElement.classList.remove('awsui-dark-mode')); + expect(screen.getByTestId('detector')).toHaveTextContent('light-comfortable'); + }); + + test('detects a move into a subtree with a different mode', async () => { + const host = document.createElement('div'); + document.body.appendChild(host); + const darkSubtree = document.createElement('div'); + darkSubtree.className = 'awsui-dark-mode'; + document.body.appendChild(darkSubtree); + + render(, { container: host }); + expect(screen.getByTestId('detector')).toHaveTextContent('light-comfortable'); + + // No class changes here: only the ancestor chain does. + await mutate(() => darkSubtree.appendChild(host)); + expect(screen.getByTestId('detector')).toHaveTextContent('dark-comfortable'); + + await mutate(() => document.body.appendChild(host)); + expect(screen.getByTestId('detector')).toHaveTextContent('light-comfortable'); + }); + + test('isMotionDisabled reads the live DOM when called during a fan-out', async () => { + const wrapper = document.createElement('div'); + document.body.appendChild(wrapper); + let observedDuringFlush: boolean | undefined = undefined; + + function MotionSubscriber() { + const ref = useRef(null); + useReducedMotion(ref); + return
; + } + + // This effect runs inside the fan-out, after MotionSubscriber has already resolved (and + // cached) the motion lookup for the shared ancestor chain. + function MutatingDuringFlush() { + const ref = useRef(null); + const colorMode = useCurrentMode(ref); + useLayoutEffect(() => { + if (colorMode === 'dark' && ref.current) { + wrapper.classList.add('awsui-motion-disabled'); + observedDuringFlush = isMotionDisabled(ref.current); + } + }, [colorMode]); + return
; + } + + render( + <> + + + , + { container: wrapper } + ); + await mutate(() => wrapper.classList.add('awsui-dark-mode')); + + expect(observedDuringFlush).toBe(true); + }); + + test('detects a mode applied to an intermediate ancestor rather than body', async () => { + const { container } = render( +
+
+ +
+
+ ); + const inner = container.querySelector('.inner')!; + + await mutate(() => inner.classList.add('awsui-dark-mode')); + expect(screen.getByTestId('detector')).toHaveTextContent('dark-comfortable'); + + await mutate(() => inner.classList.remove('awsui-dark-mode')); + expect(screen.getByTestId('detector')).toHaveTextContent('light-comfortable'); + }); +}); diff --git a/src/internal/visual-mode/index.ts b/src/internal/visual-mode/index.ts index 10cfa6f..fcff94d 100644 --- a/src/internal/visual-mode/index.ts +++ b/src/internal/visual-mode/index.ts @@ -3,6 +3,7 @@ import React, { useEffect, useState } from 'react'; import { findUpUntil } from '../../dom/index.js'; +import { isHTMLElement } from '../../dom/element-types.js'; import { createSingletonHandler } from '../singleton-handler/index.js'; import { useStableCallback } from '../stable-callback/index.js'; import { isDevelopment } from '../is-development.js'; @@ -10,9 +11,87 @@ import { warnOnce } from '../logging.js'; import { awsuiVisualRefreshFlag, getGlobal, getGlobalFlag } from '../global-flags/index.js'; import { safeMatchMedia } from '../utils/safe-match-media.js'; +/** + * Ancestor-chain lookups resolved during the current mutation flush, keyed by mode and then + * by element. Only populated while the singleton observer is fanning out to its subscribers + * (see `useMutationSingleton`), and `null` at all other times. + * + * Only the detectors below read it. Callers outside the fan-out must not, because React can + * run effects while it is populated: an effect that mutates a mode class and then queries + * synchronously has to see the DOM as it is, not as the flush found it. The detectors + * themselves are unaffected, since they all run before any effect in the batch and any + * mutation an effect makes schedules a fresh flush. + */ +let flushCache: null | Map> = null; + +function getParentHTMLElement(element: HTMLElement): HTMLElement | null { + let parent: HTMLElement | null = element.parentElement; + // If a component is used within an svg (i.e. as foreignObject), then it will have some + // ancestor nodes that are SVGElement. We want to skip those, as they have very different + // properties to HTMLElements. + while (parent && !isHTMLElement(parent)) { + parent = (parent as Element).parentElement; + } + return parent; +} + +/** + * Whether `element` or any of its ancestors satisfies `test`. + * + * Within a mutation flush every element on the traversed path is memoized, so subscribers + * that share ancestors resolve in constant time instead of each re-walking to the document + * root. Pages with many mode detectors mounted (for example a table with a popover in every + * cell) are dominated by those redundant walks: cost per flush goes from + * O(subscribers x depth) to O(distinct paths). + */ +function hasMatchingAncestor(mode: string, element: HTMLElement, test: (node: HTMLElement) => boolean): boolean { + if (!flushCache) { + return !!findUpUntil(element, test); + } + let cache = flushCache.get(mode); + if (!cache) { + cache = new Map(); + flushCache.set(mode, cache); + } + + const path: Array = []; + let current: HTMLElement | null = element; + let result: boolean | undefined = undefined; + + while (current) { + const cached = cache.get(current); + if (cached !== undefined) { + result = cached; + break; + } + path.push(current); + if (test(current)) { + result = true; + break; + } + current = getParentHTMLElement(current); + } + + const resolved = result ?? false; + for (const visited of path) { + cache.set(visited, resolved); + } + return resolved; +} + +function hasMotionDisabledAncestor(element: HTMLElement): boolean { + return !!findUpUntil(element, node => node.classList.contains('awsui-motion-disabled')); +} + +// Public API, callable at any time, so it always reads the live DOM rather than the flush cache. export function isMotionDisabled(element: HTMLElement): boolean { + return hasMotionDisabledAncestor(element) || safeMatchMedia(element, '(prefers-reduced-motion: reduce)'); +} + +// Equivalent to `isMotionDisabled`, but shares ancestor lookups across subscribers in a flush. +function detectReducedMotion(element: HTMLElement): boolean { return ( - !!findUpUntil(element, node => node.classList.contains('awsui-motion-disabled')) || + hasMatchingAncestor('motion', element, node => node.classList.contains('awsui-motion-disabled')) || safeMatchMedia(element, '(prefers-reduced-motion: reduce)') ); } @@ -43,19 +122,21 @@ function useModeDetector( } function detectCurrentMode(node: HTMLElement): 'light' | 'dark' { - const darkModeParent = findUpUntil( + const isDark = hasMatchingAncestor( + 'dark', node, node => node.classList.contains('awsui-polaris-dark-mode') || node.classList.contains('awsui-dark-mode') ); - return darkModeParent ? 'dark' : 'light'; + return isDark ? 'dark' : 'light'; } function detectDensityMode(node: HTMLElement): 'comfortable' | 'compact' { - const compactModeParent = findUpUntil( + const isCompact = hasMatchingAncestor( + 'compact', node, node => node.classList.contains('awsui-polaris-compact-mode') || node.classList.contains('awsui-compact-mode') ); - return compactModeParent ? 'compact' : 'comfortable'; + return isCompact ? 'compact' : 'comfortable'; } // Note that this hook doesn't take into consideration @media print (unlike the dark mode CSS), @@ -70,13 +151,160 @@ export function useDensityMode(elementRef: React.RefObject) { } export function useReducedMotion(elementRef: React.RefObject) { - return useModeDetector(elementRef, isMotionDisabled, false); + return useModeDetector(elementRef, detectReducedMotion, false); +} + +/** + * How many subscribers each ref backs. Counted rather than a set, because the hooks take a + * ref instead of owning one, so a component may detect one mode on an element and pass the + * same ref to a child that detects another. Unmounting one of those must not discard the + * bookkeeping the other still relies on. + */ +const subscriberCountsByRef = new Map, number>(); + +/** + * Every node on some subscriber's ancestor chain, including the subscribed elements + * themselves, with a count of how many chains pass through each. + * + * This is what makes the `childList` filter cheap. A `childList` change can only alter a mode + * if a subscriber sits at or below one of the moved nodes, which is true exactly when a moved + * node is on some subscriber's chain. Testing that costs one map lookup per moved node, where + * walking every subscriber's chain per mutation would cost O(subscribers x depth) — and + * unrelated churn, which is most of what `childList` reports, is all worst case for such a + * walk, since it can only conclude "no subscriber moved" after walking all of them. + * + * Counting, rather than a plain set, is what lets a subscriber be added to an existing map: + * detectors on one page share most of their chain, so the shared nodes must survive until the + * last chain through them is gone. + */ +const chainNodeCounts = new Map(); + +/** + * Whether `chainNodeCounts` still describes the live DOM. It is derived from ancestor chains, + * so anything that reshapes one invalidates it. Rebuilding is deferred to the next read, so a + * burst of mutations costs one rebuild rather than one per mutation. + */ +let chainsNeedRebuild = false; + +function addChain(elementRef: React.RefObject) { + for (let node: Node | null = elementRef.current; node; node = node.parentNode) { + chainNodeCounts.set(node, (chainNodeCounts.get(node) ?? 0) + 1); + } +} + +function rebuildChains() { + chainNodeCounts.clear(); + for (const [ref, subscriberCount] of subscriberCountsByRef) { + for (let i = 0; i < subscriberCount; i++) { + addChain(ref); + } + } + chainsNeedRebuild = false; +} + +function subscribeChain(elementRef: React.RefObject) { + subscriberCountsByRef.set(elementRef, (subscriberCountsByRef.get(elementRef) ?? 0) + 1); + if (!chainsNeedRebuild) { + addChain(elementRef); + } +} + +function unsubscribeChain(elementRef: React.RefObject) { + const remaining = (subscriberCountsByRef.get(elementRef) ?? 1) - 1; + if (remaining > 0) { + subscriberCountsByRef.set(elementRef, remaining); + } else { + subscriberCountsByRef.delete(elementRef); + } + // The chain is not unwound incrementally: by cleanup time the element may already have been + // detached or moved, so the chain walked here need not be the one that was counted. + chainsNeedRebuild = true; +} + +/** + * Whether these records moved a subscriber, and so changed which ancestors it inherits a mode + * from. + * + * Both added and removed nodes count, because a move reports the two halves separately and + * they can land in different records when it spans flushes: re-attaching an element that was + * detached in an earlier flush is reported only as an addition. + * + * Chain membership is by node identity, so an `` between a `foreignObject` and its + * subscriber is on the chain like any other node and needs no special handling. + */ +function movesSubscriber(records: Array): boolean { + if (chainsNeedRebuild) { + rebuildChains(); + } + for (const record of records) { + if (record.type !== 'childList') { + continue; + } + for (let i = 0; i < record.removedNodes.length; i++) { + if (chainNodeCounts.has(record.removedNodes[i])) { + return true; + } + } + for (let i = 0; i < record.addedNodes.length; i++) { + if (chainNodeCounts.has(record.addedNodes[i])) { + return true; + } + } + } + return false; +} + +function hasClassChange(records: Array): boolean { + for (const record of records) { + if (record.type !== 'childList') { + return true; + } + } + return false; } const useMutationSingleton = createSingletonHandler(handler => { - const observer = new MutationObserver(() => handler()); - observer.observe(document.body, { attributes: true, subtree: true }); - return () => observer.disconnect(); + const fanOut = () => { + // Memoize ancestor lookups for the duration of this fan-out only. Every subscriber runs + // synchronously inside handler(), before React processes any effect in the batch, so all + // of them see one consistent view of the DOM. + flushCache = new Map(); + try { + handler(); + } finally { + flushCache = null; + } + }; + const observer = new MutationObserver(records => { + // A moved subscriber has a new ancestor chain, so the counts must be rebuilt. A class + // change moves nothing and leaves them valid, so it only wakes the subscribers. + const moved = movesSubscriber(records); + if (moved) { + chainsNeedRebuild = true; + } + if (moved || hasClassChange(records)) { + fanOut(); + } + }); + const htmlObserver = new MutationObserver(fanOut); + // A mode is only ever expressed as a class name, so watching `class` is what detects a mode + // change. Filtering to it avoids waking every subscriber for unrelated attribute changes + // anywhere in the document, such as the `data-awsui-focus-visible` toggle that + // focus-visible writes to `` on every keydown and mousedown. + // + // `childList` is watched as well because moving an element between subtrees changes its + // ancestor chain, and therefore its mode, without any class changing. Previously such + // moves were only picked up incidentally, by whatever unrelated attribute mutation + // happened to follow. Because that covers every node insertion on the page, and most of + // them are ordinary rendering that moves no subscriber, `movesSubscriber` discards them + // before waking anyone. + observer.observe(document.body, { attributes: true, subtree: true, childList: true, attributeFilter: ['class'] }); + // Modes are also honoured above ``, which the observer above does not cover. + htmlObserver.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] }); + return () => { + observer.disconnect(); + htmlObserver.disconnect(); + }; }); function useMutationObserver(elementRef: React.RefObject, onChange: (element: HTMLElement) => void) { @@ -87,6 +315,13 @@ function useMutationObserver(elementRef: React.RefObject, onChange: }); useMutationSingleton(handler); + useEffect(() => { + subscribeChain(elementRef); + return () => { + unsubscribeChain(elementRef); + }; + }, [elementRef]); + useEffect(() => { handler(); }, [handler]);