11// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
22// SPDX-License-Identifier: Apache-2.0
33
4- import React , { useRef } from 'react' ;
5- import { useCurrentMode , useDensityMode } from '../index' ;
4+ import React , { useLayoutEffect , useRef } from 'react' ;
5+ import { isMotionDisabled , useCurrentMode , useDensityMode , useReducedMotion } from '../index' ;
66import { render , screen } from '@testing-library/react' ;
77import { mutate } from './utils' ;
88
@@ -44,6 +44,11 @@ function spyOnClassListReads() {
4444
4545afterEach ( ( ) => {
4646 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 ( ) ;
4752} ) ;
4853
4954describe ( 'mode detection cost' , ( ) => {
@@ -59,8 +64,9 @@ describe('mode detection cost', () => {
5964 } ) ;
6065
6166 test ( 'shares ancestor lookups between subscribers within a single flush' , async ( ) => {
67+ const depth = 20 ;
6268 async function countReadsForOneFlush ( detectorCount : number ) {
63- const { container, unmount } = render ( < ManyDetectors count = { detectorCount } depth = { 20 } /> ) ;
69+ const { container, unmount } = render ( < ManyDetectors count = { detectorCount } depth = { depth } /> ) ;
6470 const reads = spyOnClassListReads ( ) ;
6571 await mutate ( ( ) => container . classList . add ( 'unrelated-class' ) ) ;
6672 const total = reads . mock . calls . length ;
@@ -69,14 +75,16 @@ describe('mode detection cost', () => {
6975 return total ;
7076 }
7177
72- const withTen = await countReadsForOneFlush ( 10 ) ;
7378 const withForty = await countReadsForOneFlush ( 40 ) ;
74- expect ( withTen ) . toBeGreaterThan ( 0 ) ;
75-
76- // Each subscriber walks to the document root, so without memoization 4x the subscribers
77- // costs ~4x the class reads. Sharing the resolved path within a flush means only each
78- // subscriber's own element is uncached, making the growth far sublinear in chain depth.
79- expect ( withForty ) . toBeLessThan ( withTen * 2 ) ;
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 ) ;
8088 } ) ;
8189
8290 test ( 'does not reuse cached lookups across separate flushes' , async ( ) => {
@@ -111,6 +119,72 @@ describe('mode detection cost', () => {
111119 expect ( screen . getByTestId ( 'in-neither' ) ) . toHaveTextContent ( 'light-comfortable' ) ;
112120 } ) ;
113121
122+ test ( 'detects a mode class applied above body' , async ( ) => {
123+ render ( < ModeRender testId = "detector" /> ) ;
124+ expect ( screen . getByTestId ( 'detector' ) ) . toHaveTextContent ( 'light-comfortable' ) ;
125+
126+ await mutate ( ( ) => document . documentElement . classList . add ( 'awsui-dark-mode' ) ) ;
127+ expect ( screen . getByTestId ( 'detector' ) ) . toHaveTextContent ( 'dark-comfortable' ) ;
128+
129+ await mutate ( ( ) => document . documentElement . classList . remove ( 'awsui-dark-mode' ) ) ;
130+ expect ( screen . getByTestId ( 'detector' ) ) . toHaveTextContent ( 'light-comfortable' ) ;
131+ } ) ;
132+
133+ test ( 'detects a move into a subtree with a different mode' , async ( ) => {
134+ const host = document . createElement ( 'div' ) ;
135+ document . body . appendChild ( host ) ;
136+ const darkSubtree = document . createElement ( 'div' ) ;
137+ darkSubtree . className = 'awsui-dark-mode' ;
138+ document . body . appendChild ( darkSubtree ) ;
139+
140+ render ( < ModeRender testId = "detector" /> , { container : host } ) ;
141+ expect ( screen . getByTestId ( 'detector' ) ) . toHaveTextContent ( 'light-comfortable' ) ;
142+
143+ // No class changes here: only the ancestor chain does.
144+ await mutate ( ( ) => darkSubtree . appendChild ( host ) ) ;
145+ expect ( screen . getByTestId ( 'detector' ) ) . toHaveTextContent ( 'dark-comfortable' ) ;
146+
147+ await mutate ( ( ) => document . body . appendChild ( host ) ) ;
148+ expect ( screen . getByTestId ( 'detector' ) ) . toHaveTextContent ( 'light-comfortable' ) ;
149+ } ) ;
150+
151+ test ( 'isMotionDisabled reads the live DOM when called during a fan-out' , async ( ) => {
152+ const wrapper = document . createElement ( 'div' ) ;
153+ document . body . appendChild ( wrapper ) ;
154+ let observedDuringFlush : boolean | undefined = undefined ;
155+
156+ function MotionSubscriber ( ) {
157+ const ref = useRef ( null ) ;
158+ useReducedMotion ( ref ) ;
159+ return < div ref = { ref } /> ;
160+ }
161+
162+ // This effect runs inside the fan-out, after MotionSubscriber has already resolved (and
163+ // cached) the motion lookup for the shared ancestor chain.
164+ function MutatingDuringFlush ( ) {
165+ const ref = useRef < HTMLDivElement > ( null ) ;
166+ const colorMode = useCurrentMode ( ref ) ;
167+ useLayoutEffect ( ( ) => {
168+ if ( colorMode === 'dark' && ref . current ) {
169+ wrapper . classList . add ( 'awsui-motion-disabled' ) ;
170+ observedDuringFlush = isMotionDisabled ( ref . current ) ;
171+ }
172+ } , [ colorMode ] ) ;
173+ return < div ref = { ref } /> ;
174+ }
175+
176+ render (
177+ < >
178+ < MotionSubscriber />
179+ < MutatingDuringFlush />
180+ </ > ,
181+ { container : wrapper }
182+ ) ;
183+ await mutate ( ( ) => wrapper . classList . add ( 'awsui-dark-mode' ) ) ;
184+
185+ expect ( observedDuringFlush ) . toBe ( true ) ;
186+ } ) ;
187+
114188 test ( 'detects a mode applied to an intermediate ancestor rather than body' , async ( ) => {
115189 const { container } = render (
116190 < div className = "outer" >
0 commit comments