Skip to content

Commit 999163f

Browse files
EduardF1Copilot
andcommitted
test: add Node.js 25 SSR regression test for the window->globalThis alias
Adds a node-environment test that recreates the Node.js 25 environment where `window` is aliased to `globalThis` and `localStorage.getItem` throws. It asserts `ThemeProvider` renders to static markup without throwing and that server rendering never reads from `localStorage`. The test fails on the old `typeof window` guard and passes with the `typeof document` guard. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 841f74d commit 999163f

1 file changed

Lines changed: 96 additions & 0 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+
})

0 commit comments

Comments
 (0)