Skip to content

Commit e9e6e07

Browse files
committed
fix: replace typeof window with typeof document for SSR detection
Node.js 25 exposes window as an alias for globalThis, which makes the previous typeof window SSR guard take the browser path during server rendering. Use typeof document instead and cover the Node.js 25 server case with a regression test that verifies server rendering does not read localStorage. Fixes #389
1 parent a7eeabc commit e9e6e07

2 files changed

Lines changed: 98 additions & 2 deletions

File tree

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
// @vitest-environment node
2+
3+
import * as React from 'react'
4+
import { renderToStaticMarkup } from 'react-dom/server'
5+
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'
6+
7+
// Regression test for https://github.com/pacocoursey/next-themes/issues/389
8+
//
9+
// Node.js 25 ships an experimental Web Storage API where `window` is aliased to
10+
// `globalThis`. That makes the historic `typeof window === 'undefined'` SSR guard
11+
// evaluate to `false` on the server, so next-themes wrongly takes the browser code
12+
// path during SSR and crashes (e.g. `localStorage.getItem is not a function` /
13+
// `window.matchMedia is not a function`).
14+
//
15+
// These tests recreate the Node 25 server environment and assert that server
16+
// rendering no longer touches browser-only globals.
17+
18+
describe('Node.js 25 SSR guard (window aliased to globalThis)', () => {
19+
const globals = globalThis as Record<string, unknown>
20+
const hadWindow = 'window' in globals
21+
const hadLocalStorage = 'localStorage' in globals
22+
const originalWindow = globals.window
23+
const originalLocalStorage = globals.localStorage
24+
25+
beforeEach(() => {
26+
vi.resetModules()
27+
28+
// Node 25 aliases `window` to `globalThis` on the server.
29+
globals.window = globalThis
30+
31+
// Node 25 exposes a *partial* localStorage. Simulate the broken state where
32+
// calling `getItem` throws, matching the original crash report.
33+
Object.defineProperty(globalThis, 'localStorage', {
34+
configurable: true,
35+
writable: true,
36+
value: {
37+
getItem() {
38+
throw new TypeError('localStorage.getItem is not a function')
39+
}
40+
}
41+
})
42+
43+
// `document` and `window.matchMedia` remain browser-only and are absent on the
44+
// server even on Node 25.
45+
expect(typeof (globalThis as { document?: unknown }).document).toBe('undefined')
46+
})
47+
48+
afterEach(() => {
49+
if (hadWindow) {
50+
globals.window = originalWindow
51+
} else {
52+
delete globals.window
53+
}
54+
55+
if (hadLocalStorage) {
56+
Object.defineProperty(globalThis, 'localStorage', {
57+
configurable: true,
58+
writable: true,
59+
value: originalLocalStorage
60+
})
61+
} else {
62+
delete globals.localStorage
63+
}
64+
65+
vi.resetModules()
66+
})
67+
68+
test('ThemeProvider renders to static markup without throwing', async () => {
69+
const { ThemeProvider } = await import('../src/index')
70+
71+
expect(() =>
72+
renderToStaticMarkup(
73+
React.createElement(ThemeProvider, null, React.createElement('span', null, 'child'))
74+
)
75+
).not.toThrow()
76+
})
77+
78+
test('server render does not read from the broken localStorage', async () => {
79+
const getItem = vi.fn(() => {
80+
throw new TypeError('localStorage.getItem is not a function')
81+
})
82+
Object.defineProperty(globalThis, 'localStorage', {
83+
configurable: true,
84+
writable: true,
85+
value: { getItem }
86+
})
87+
88+
const { ThemeProvider } = await import('../src/index')
89+
90+
renderToStaticMarkup(
91+
React.createElement(ThemeProvider, { defaultTheme: 'system', enableSystem: true }, 'child')
92+
)
93+
94+
expect(getItem).not.toHaveBeenCalled()
95+
})
96+
})

next-themes/src/index.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import type { Attribute, ThemeProviderProps, UseThemeProps } from './types'
66

77
const colorSchemes = ['light', 'dark']
88
const MEDIA = '(prefers-color-scheme: dark)'
9-
const isServer = typeof window === 'undefined'
9+
const isServer = typeof document === 'undefined'
1010
const ThemeContext = React.createContext<UseThemeProps | undefined>(undefined)
1111
const defaultContext: UseThemeProps = { setTheme: _ => { }, themes: [] }
1212

@@ -212,7 +212,7 @@ export const ThemeScript = React.memo(
212212
<script
213213
{...scriptProps}
214214
suppressHydrationWarning
215-
nonce={typeof window === 'undefined' ? nonce : ''}
215+
nonce={typeof document === 'undefined' ? nonce : ''}
216216
dangerouslySetInnerHTML={{ __html: `(${script.toString()})(${scriptArgs})` }}
217217
/>
218218
)

0 commit comments

Comments
 (0)