From e9e6e074ed61e6c035bb1a99f1852151dbd91ac7 Mon Sep 17 00:00:00 2001 From: EduardF1 <50618110+EduardF1@users.noreply.github.com> Date: Tue, 30 Jun 2026 15:08:39 +0200 Subject: [PATCH] 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 --- next-themes/__tests__/ssr-node25.test.tsx | 96 +++++++++++++++++++++++ next-themes/src/index.tsx | 4 +- 2 files changed, 98 insertions(+), 2 deletions(-) create mode 100644 next-themes/__tests__/ssr-node25.test.tsx diff --git a/next-themes/__tests__/ssr-node25.test.tsx b/next-themes/__tests__/ssr-node25.test.tsx new file mode 100644 index 0000000..1b73b84 --- /dev/null +++ b/next-themes/__tests__/ssr-node25.test.tsx @@ -0,0 +1,96 @@ +// @vitest-environment node + +import * as React from 'react' +import { renderToStaticMarkup } from 'react-dom/server' +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest' + +// Regression test for https://github.com/pacocoursey/next-themes/issues/389 +// +// Node.js 25 ships an experimental Web Storage API where `window` is aliased to +// `globalThis`. That makes the historic `typeof window === 'undefined'` SSR guard +// evaluate to `false` on the server, so next-themes wrongly takes the browser code +// path during SSR and crashes (e.g. `localStorage.getItem is not a function` / +// `window.matchMedia is not a function`). +// +// These tests recreate the Node 25 server environment and assert that server +// rendering no longer touches browser-only globals. + +describe('Node.js 25 SSR guard (window aliased to globalThis)', () => { + const globals = globalThis as Record + const hadWindow = 'window' in globals + const hadLocalStorage = 'localStorage' in globals + const originalWindow = globals.window + const originalLocalStorage = globals.localStorage + + beforeEach(() => { + vi.resetModules() + + // Node 25 aliases `window` to `globalThis` on the server. + globals.window = globalThis + + // Node 25 exposes a *partial* localStorage. Simulate the broken state where + // calling `getItem` throws, matching the original crash report. + Object.defineProperty(globalThis, 'localStorage', { + configurable: true, + writable: true, + value: { + getItem() { + throw new TypeError('localStorage.getItem is not a function') + } + } + }) + + // `document` and `window.matchMedia` remain browser-only and are absent on the + // server even on Node 25. + expect(typeof (globalThis as { document?: unknown }).document).toBe('undefined') + }) + + afterEach(() => { + if (hadWindow) { + globals.window = originalWindow + } else { + delete globals.window + } + + if (hadLocalStorage) { + Object.defineProperty(globalThis, 'localStorage', { + configurable: true, + writable: true, + value: originalLocalStorage + }) + } else { + delete globals.localStorage + } + + vi.resetModules() + }) + + test('ThemeProvider renders to static markup without throwing', async () => { + const { ThemeProvider } = await import('../src/index') + + expect(() => + renderToStaticMarkup( + React.createElement(ThemeProvider, null, React.createElement('span', null, 'child')) + ) + ).not.toThrow() + }) + + test('server render does not read from the broken localStorage', async () => { + const getItem = vi.fn(() => { + throw new TypeError('localStorage.getItem is not a function') + }) + Object.defineProperty(globalThis, 'localStorage', { + configurable: true, + writable: true, + value: { getItem } + }) + + const { ThemeProvider } = await import('../src/index') + + renderToStaticMarkup( + React.createElement(ThemeProvider, { defaultTheme: 'system', enableSystem: true }, 'child') + ) + + expect(getItem).not.toHaveBeenCalled() + }) +}) diff --git a/next-themes/src/index.tsx b/next-themes/src/index.tsx index 0813c49..852e6a9 100644 --- a/next-themes/src/index.tsx +++ b/next-themes/src/index.tsx @@ -6,7 +6,7 @@ import type { Attribute, ThemeProviderProps, UseThemeProps } from './types' const colorSchemes = ['light', 'dark'] const MEDIA = '(prefers-color-scheme: dark)' -const isServer = typeof window === 'undefined' +const isServer = typeof document === 'undefined' const ThemeContext = React.createContext(undefined) const defaultContext: UseThemeProps = { setTheme: _ => { }, themes: [] } @@ -212,7 +212,7 @@ export const ThemeScript = React.memo(