@@ -3,6 +3,8 @@ import { darkModeInitScript } from '../../darkMode';
33import { useDarkMode } from '../useDarkMode' ;
44
55describe ( 'useDarkMode' , ( ) => {
6+ const changeHandlers : Array < ( event : { matches : boolean } ) => void > = [ ] ;
7+
68 const mockMatchMedia = ( matches : boolean ) => {
79 Object . defineProperty ( window , 'matchMedia' , {
810 writable : true ,
@@ -12,20 +14,26 @@ describe('useDarkMode', () => {
1214 onchange : null ,
1315 addListener : jest . fn ( ) ,
1416 removeListener : jest . fn ( ) ,
15- addEventListener : jest . fn ( ) ,
17+ addEventListener : jest . fn (
18+ ( _type : string , handler : ( event : { matches : boolean } ) => void ) => {
19+ changeHandlers . push ( handler ) ;
20+ } ,
21+ ) ,
1622 removeEventListener : jest . fn ( ) ,
1723 dispatchEvent : jest . fn ( ) ,
1824 } ) ) ,
1925 } ) ;
2026 } ;
2127
2228 beforeEach ( ( ) => {
29+ changeHandlers . length = 0 ;
2330 localStorage . clear ( ) ;
24- document . documentElement . classList . remove ( 'dark-mode' ) ;
25- document . documentElement . classList . remove ( 'light-mode' ) ;
31+ document . documentElement . removeAttribute ( 'data-theme' ) ;
2632 mockMatchMedia ( false ) ;
2733 } ) ;
2834
35+ const getTheme = ( ) => document . documentElement . getAttribute ( 'data-theme' ) ;
36+
2937 it ( 'should initialize with light mode by default' , async ( ) => {
3038 const { result } = renderHook ( ( ) => useDarkMode ( ) ) ;
3139
@@ -35,53 +43,52 @@ describe('useDarkMode', () => {
3543 expect ( localStorage . getItem ( 'darkMode' ) ) . toBeNull ( ) ;
3644 } ) ;
3745
38- it ( 'should use system dark mode when no preference is stored' , async ( ) => {
46+ it ( 'should follow system dark mode when no preference is stored, without pinning the theme ' , async ( ) => {
3947 mockMatchMedia ( true ) ;
4048
4149 const { result } = renderHook ( ( ) => useDarkMode ( ) ) ;
4250
4351 await waitFor ( ( ) => expect ( result . current . isLoaded ) . toBe ( true ) ) ;
4452
4553 expect ( result . current . isDarkMode ) . toBe ( true ) ;
46- expect ( document . documentElement . classList . contains ( 'dark-mode' ) ) . toBe ( true ) ;
47- expect ( document . documentElement . classList . contains ( 'light-mode' ) ) . toBe ( false ) ;
54+ // Following the system must NOT look like an explicit choice.
55+ expect ( getTheme ( ) ) . toBeNull ( ) ;
4856 expect ( localStorage . getItem ( 'darkMode' ) ) . toBeNull ( ) ;
4957 } ) ;
5058
5159 it ( 'should toggle dark mode' , async ( ) => {
5260 const { result } = renderHook ( ( ) => useDarkMode ( ) ) ;
5361
5462 await waitFor ( ( ) => expect ( result . current . isLoaded ) . toBe ( true ) ) ;
55-
63+
5664 act ( ( ) => {
5765 result . current . toggleDarkMode ( ) ;
5866 } ) ;
59-
67+
6068 expect ( result . current . isDarkMode ) . toBe ( true ) ;
6169 } ) ;
6270
6371 it ( 'should persist dark mode preference to localStorage' , async ( ) => {
6472 const { result } = renderHook ( ( ) => useDarkMode ( ) ) ;
6573
6674 await waitFor ( ( ) => expect ( result . current . isLoaded ) . toBe ( true ) ) ;
67-
75+
6876 act ( ( ) => {
6977 result . current . toggleDarkMode ( ) ;
7078 } ) ;
71-
79+
7280 expect ( localStorage . getItem ( 'darkMode' ) ) . toBe ( 'true' ) ;
7381 } ) ;
7482
7583 it ( 'should load dark mode preference from localStorage' , async ( ) => {
7684 localStorage . setItem ( 'darkMode' , 'true' ) ;
77-
85+
7886 const { result } = renderHook ( ( ) => useDarkMode ( ) ) ;
79-
87+
8088 await waitFor ( ( ) => expect ( result . current . isLoaded ) . toBe ( true ) ) ;
8189
8290 expect ( result . current . isDarkMode ) . toBe ( true ) ;
83- expect ( document . documentElement . classList . contains ( 'dark-mode' ) ) . toBe ( true ) ;
84- expect ( document . documentElement . classList . contains ( 'light-mode' ) ) . toBe ( false ) ;
91+ expect ( getTheme ( ) ) . toBe ( 'dark' ) ;
8592 } ) ;
8693
8794 it ( 'should restore stored light preference over system dark mode' , async ( ) => {
@@ -93,17 +100,15 @@ describe('useDarkMode', () => {
93100 await waitFor ( ( ) => expect ( result . current . isLoaded ) . toBe ( true ) ) ;
94101
95102 expect ( result . current . isDarkMode ) . toBe ( false ) ;
96- expect ( document . documentElement . classList . contains ( 'dark-mode' ) ) . toBe ( false ) ;
97- expect ( document . documentElement . classList . contains ( 'light-mode' ) ) . toBe ( true ) ;
103+ expect ( getTheme ( ) ) . toBe ( 'light' ) ;
98104 } ) ;
99105
100106 it ( 'should apply stored dark preference before React loads' , ( ) => {
101107 localStorage . setItem ( 'darkMode' , 'true' ) ;
102108
103109 Function ( darkModeInitScript ) ( ) ;
104110
105- expect ( document . documentElement . classList . contains ( 'dark-mode' ) ) . toBe ( true ) ;
106- expect ( document . documentElement . classList . contains ( 'light-mode' ) ) . toBe ( false ) ;
111+ expect ( getTheme ( ) ) . toBe ( 'dark' ) ;
107112 } ) ;
108113
109114 it ( 'should apply stored light preference before React loads' , ( ) => {
@@ -112,35 +117,44 @@ describe('useDarkMode', () => {
112117
113118 Function ( darkModeInitScript ) ( ) ;
114119
115- expect ( document . documentElement . classList . contains ( 'dark-mode' ) ) . toBe ( false ) ;
116- expect ( document . documentElement . classList . contains ( 'light-mode' ) ) . toBe ( true ) ;
120+ expect ( getTheme ( ) ) . toBe ( 'light' ) ;
121+ } ) ;
122+
123+ it ( 'should not pin the theme before React loads when no preference is stored' , ( ) => {
124+ mockMatchMedia ( true ) ;
125+
126+ Function ( darkModeInitScript ) ( ) ;
127+
128+ // Following the system: the CSS prefers-color-scheme media query drives
129+ // the theme, so the init script must leave the attribute untouched.
130+ expect ( getTheme ( ) ) . toBeNull ( ) ;
117131 } ) ;
118132
119133 // ------------------------------------------------------------------
120- // Bug #1159 — lazy initializer reads DOM class on first render
134+ // Bug #1159 — lazy initializer reads DOM state on first render
121135 // ------------------------------------------------------------------
122136
123- it ( 'reads dark-mode class from DOM and returns isDarkMode:true on the very first render (before isLoaded)' , ( ) => {
137+ it ( 'reads data-theme="dark" from DOM and returns isDarkMode:true on the very first render (before isLoaded)' , ( ) => {
124138 // Simulate what the inline init script does before React hydrates.
125- document . documentElement . classList . add ( 'dark-mode ') ;
139+ document . documentElement . setAttribute ( 'data-theme' , 'dark ') ;
126140
127141 const { result } = renderHook ( ( ) => useDarkMode ( ) ) ;
128142
129143 // isDarkMode must be true on the FIRST render, not only after useEffect.
130144 expect ( result . current . isDarkMode ) . toBe ( true ) ;
131145 } ) ;
132146
133- it ( 'reads light-mode class from DOM and returns isDarkMode:false on the very first render' , ( ) => {
134- document . documentElement . classList . add ( 'light-mode ') ;
147+ it ( 'reads data-theme="light" from DOM and returns isDarkMode:false on the very first render' , ( ) => {
148+ document . documentElement . setAttribute ( 'data-theme' , 'light ') ;
135149
136150 const { result } = renderHook ( ( ) => useDarkMode ( ) ) ;
137151
138152 expect ( result . current . isDarkMode ) . toBe ( false ) ;
139153 } ) ;
140154
141155 it ( 'no stale icon flash: isDarkMode is already correct before isLoaded becomes true' , async ( ) => {
142- // Init script applied dark-mode before React renders.
143- document . documentElement . classList . add ( 'dark-mode ') ;
156+ // Init script applied data-theme="dark" before React renders.
157+ document . documentElement . setAttribute ( 'data-theme' , 'dark ') ;
144158
145159 const { result } = renderHook ( ( ) => useDarkMode ( ) ) ;
146160
@@ -152,8 +166,8 @@ describe('useDarkMode', () => {
152166 expect ( result . current . isDarkMode ) . toBe ( true ) ;
153167 } ) ;
154168
155- it ( 'falls back to getDarkModePreference when no DOM class is set' , async ( ) => {
156- // Neither class present — should fall back to system/storage preference.
169+ it ( 'falls back to getDarkModePreference when no data-theme attribute is set' , async ( ) => {
170+ // No attribute present — should fall back to system/storage preference.
157171 mockMatchMedia ( false ) ;
158172
159173 const { result } = renderHook ( ( ) => useDarkMode ( ) ) ;
@@ -162,8 +176,8 @@ describe('useDarkMode', () => {
162176 expect ( result . current . isDarkMode ) . toBe ( false ) ;
163177 } ) ;
164178
165- it ( 'toggleDarkMode still works correctly after lazy-init from DOM class ' , async ( ) => {
166- document . documentElement . classList . add ( 'dark-mode ') ;
179+ it ( 'toggleDarkMode still works correctly after lazy-init from DOM attribute ' , async ( ) => {
180+ document . documentElement . setAttribute ( 'data-theme' , 'dark ') ;
167181
168182 const { result } = renderHook ( ( ) => useDarkMode ( ) ) ;
169183
@@ -174,35 +188,73 @@ describe('useDarkMode', () => {
174188 } ) ;
175189
176190 expect ( result . current . isDarkMode ) . toBe ( false ) ;
177- expect ( document . documentElement . classList . contains ( 'dark-mode' ) ) . toBe ( false ) ;
191+ expect ( getTheme ( ) ) . toBe ( 'light' ) ;
178192 expect ( localStorage . getItem ( 'darkMode' ) ) . toBe ( 'false' ) ;
179193 } ) ;
180194
181- it ( 'should add dark-mode class to document element ' , async ( ) => {
195+ it ( 'should set data-theme="dark" when toggling on ' , async ( ) => {
182196 const { result } = renderHook ( ( ) => useDarkMode ( ) ) ;
183197
184198 await waitFor ( ( ) => expect ( result . current . isLoaded ) . toBe ( true ) ) ;
185-
199+
186200 act ( ( ) => {
187201 result . current . toggleDarkMode ( ) ;
188202 } ) ;
189-
190- expect ( document . documentElement . classList . contains ( 'dark-mode' ) ) . toBe ( true ) ;
203+
204+ expect ( getTheme ( ) ) . toBe ( 'dark' ) ;
191205 } ) ;
192206
193- it ( 'should remove dark-mode class when toggling off' , async ( ) => {
207+ it ( 'should set data-theme="light" when toggling off' , async ( ) => {
194208 localStorage . setItem ( 'darkMode' , 'true' ) ;
195-
209+
196210 const { result } = renderHook ( ( ) => useDarkMode ( ) ) ;
197211
198212 await waitFor ( ( ) => expect ( result . current . isLoaded ) . toBe ( true ) ) ;
199-
213+
200214 act ( ( ) => {
201215 result . current . toggleDarkMode ( ) ;
202216 } ) ;
203-
204- expect ( document . documentElement . classList . contains ( 'dark-mode' ) ) . toBe ( false ) ;
205- expect ( document . documentElement . classList . contains ( 'light-mode' ) ) . toBe ( true ) ;
217+
218+ expect ( result . current . isDarkMode ) . toBe ( false ) ;
219+ expect ( getTheme ( ) ) . toBe ( 'light' ) ;
206220 expect ( localStorage . getItem ( 'darkMode' ) ) . toBe ( 'false' ) ;
207221 } ) ;
222+
223+ // ------------------------------------------------------------------
224+ // System-following vs explicit choice
225+ // ------------------------------------------------------------------
226+
227+ it ( 'follows OS theme changes live when no explicit choice is stored' , async ( ) => {
228+ mockMatchMedia ( false ) ;
229+
230+ const { result } = renderHook ( ( ) => useDarkMode ( ) ) ;
231+
232+ await waitFor ( ( ) => expect ( result . current . isLoaded ) . toBe ( true ) ) ;
233+ expect ( result . current . isDarkMode ) . toBe ( false ) ;
234+
235+ // OS flips to dark — the hook reacts without a page reload.
236+ act ( ( ) => {
237+ changeHandlers . forEach ( ( handler ) => handler ( { matches : true } ) ) ;
238+ } ) ;
239+
240+ expect ( result . current . isDarkMode ) . toBe ( true ) ;
241+ // Still following the system: no explicit theme is pinned.
242+ expect ( getTheme ( ) ) . toBeNull ( ) ;
243+ expect ( localStorage . getItem ( 'darkMode' ) ) . toBeNull ( ) ;
244+ } ) ;
245+
246+ it ( 'does not register an OS-change listener when an explicit choice is stored' , async ( ) => {
247+ mockMatchMedia ( true ) ; // OS prefers dark
248+ localStorage . setItem ( 'darkMode' , 'false' ) ; // but the user chose light
249+
250+ const { result } = renderHook ( ( ) => useDarkMode ( ) ) ;
251+
252+ await waitFor ( ( ) => expect ( result . current . isLoaded ) . toBe ( true ) ) ;
253+
254+ expect ( result . current . isDarkMode ) . toBe ( false ) ;
255+ expect ( getTheme ( ) ) . toBe ( 'light' ) ;
256+
257+ // The stored choice must never be overridden by an OS-level change.
258+ expect ( changeHandlers ) . toHaveLength ( 0 ) ;
259+ } ) ;
208260} ) ;
0 commit comments