From a5b22b73a11cfd5101668718ffbfa6dd89c1b731 Mon Sep 17 00:00:00 2001 From: miacycle <184569369+miacycle@users.noreply.github.com> Date: Thu, 21 May 2026 14:25:18 -0500 Subject: [PATCH 1/2] fix(useWindowDimensions): guard window access for SSR / static prerender getWindowDimensions() read window.innerWidth/innerHeight unguarded and is invoked eagerly via useState(getWindowDimensions()) on the initial render. Under server-side rendering or Next.js static-export prerender there is no window, so any component calling useWindowDimensions() (e.g. a navbar) threw ReferenceError: window is not defined during prerender. Return zeroed dimensions when window is undefined; the real values are read on the client during hydration and updated by the existing resize listener. Adds a node-environment regression test asserting renderToString does not throw and falls back to 0x0 when window is absent. Signed-off-by: miacycle <184569369+miacycle@users.noreply.github.com> --- src/__testing__/useWindowDimensions.test.tsx | 38 ++++++++++++++++++++ src/custom/Helpers/Dimension/windowSize.tsx | 8 +++++ 2 files changed, 46 insertions(+) create mode 100644 src/__testing__/useWindowDimensions.test.tsx diff --git a/src/__testing__/useWindowDimensions.test.tsx b/src/__testing__/useWindowDimensions.test.tsx new file mode 100644 index 000000000..580385ba5 --- /dev/null +++ b/src/__testing__/useWindowDimensions.test.tsx @@ -0,0 +1,38 @@ +/** + * @jest-environment node + */ +import React from 'react'; +import { renderToString } from 'react-dom/server'; +import { useWindowDimensions } from '../custom/Helpers/Dimension/windowSize'; + +function Probe() { + const { width, height } = useWindowDimensions(); + return React.createElement('div', null, `${width}x${height}`); +} + +describe('useWindowDimensions SSR safety', () => { + it('does not throw when window is undefined (server / static prerender)', () => { + // The `node` environment has no `window`, mirroring Node SSR and + // Next.js static-export prerender. + expect(typeof window).toBe('undefined'); + let html = ''; + expect(() => { + html = renderToString(React.createElement(Probe)); + }).not.toThrow(); + // Falls back to zeroed dimensions instead of reading `window`. + expect(html).toContain('0x0'); + }); + + it('reads real dimensions when window is present', () => { + (global as unknown as { window: { innerWidth: number; innerHeight: number } }).window = { + innerWidth: 1280, + innerHeight: 800 + }; + try { + const html = renderToString(React.createElement(Probe)); + expect(html).toContain('1280x800'); + } finally { + delete (global as unknown as { window?: unknown }).window; + } + }); +}); diff --git a/src/custom/Helpers/Dimension/windowSize.tsx b/src/custom/Helpers/Dimension/windowSize.tsx index e396e9896..ea0673e33 100644 --- a/src/custom/Helpers/Dimension/windowSize.tsx +++ b/src/custom/Helpers/Dimension/windowSize.tsx @@ -3,9 +3,17 @@ import React from 'react'; /** * Returns the width and height of the window. * + * During server-side rendering / static prerender there is no `window`, + * so this returns zeroed dimensions instead of throwing a + * `ReferenceError`. The real values are picked up on the client after the + * `resize` listener (and the first effect-driven read) run. + * * @returns {WindowDimensions} { width, height } */ function getWindowDimensions(): WindowDimensions { + if (typeof window === 'undefined') { + return { width: 0, height: 0 }; + } const { innerWidth: width, innerHeight: height } = window; return { width, From 89dbc88e58f3ea39562acce7260fac5dd0a30a4a Mon Sep 17 00:00:00 2001 From: miacycle <184569369+miacycle@users.noreply.github.com> Date: Thu, 21 May 2026 14:40:57 -0500 Subject: [PATCH 2/2] fix(useWindowDimensions): initialize 0x0 and read window in effect Addresses review feedback: initializing state via useState(getWindowDimensions()) read window during render, which (a) diverged from the server's 0x0 render and caused a hydration mismatch for SSR'd consumers, and (b) made the docstring's 'first effect-driven read' claim inaccurate (the effect only added a resize listener, never an initial read). Initialize state to 0x0 so the server render and first client (hydration) render are identical, then sync to the real dimensions in the mount effect and keep them current via the debounced resize listener. getWindowDimensions is now only called on the client; its window guard remains as a defensive net. Splits tests: SSR safety (node env, renderToString does not read window, falls back to 0x0) and client behavior (jsdom renderHook: reads real dimensions on mount, updates on debounced resize). Signed-off-by: miacycle <184569369+miacycle@users.noreply.github.com> --- .../useWindowDimensions.client.test.tsx | 32 +++++++++++++++++++ src/__testing__/useWindowDimensions.test.tsx | 20 +++--------- src/custom/Helpers/Dimension/windowSize.tsx | 25 +++++++++++---- 3 files changed, 55 insertions(+), 22 deletions(-) create mode 100644 src/__testing__/useWindowDimensions.client.test.tsx diff --git a/src/__testing__/useWindowDimensions.client.test.tsx b/src/__testing__/useWindowDimensions.client.test.tsx new file mode 100644 index 000000000..da579e2a1 --- /dev/null +++ b/src/__testing__/useWindowDimensions.client.test.tsx @@ -0,0 +1,32 @@ +import { act, renderHook } from '@testing-library/react'; +import { useWindowDimensions } from '../custom/Helpers/Dimension/windowSize'; + +describe('useWindowDimensions (client)', () => { + it('reads the real window dimensions after mount', () => { + const { result } = renderHook(() => useWindowDimensions()); + // The mount effect syncs state to the live window size (jsdom default). + expect(result.current).toEqual({ + width: window.innerWidth, + height: window.innerHeight + }); + }); + + it('updates (debounced) on window resize', () => { + jest.useFakeTimers(); + try { + const { result } = renderHook(() => useWindowDimensions()); + + act(() => { + (window as unknown as { innerWidth: number }).innerWidth = 480; + (window as unknown as { innerHeight: number }).innerHeight = 640; + window.dispatchEvent(new Event('resize')); + // Resize handling is debounced by 500ms. + jest.advanceTimersByTime(500); + }); + + expect(result.current).toEqual({ width: 480, height: 640 }); + } finally { + jest.useRealTimers(); + } + }); +}); diff --git a/src/__testing__/useWindowDimensions.test.tsx b/src/__testing__/useWindowDimensions.test.tsx index 580385ba5..518f1bc8e 100644 --- a/src/__testing__/useWindowDimensions.test.tsx +++ b/src/__testing__/useWindowDimensions.test.tsx @@ -11,28 +11,16 @@ function Probe() { } describe('useWindowDimensions SSR safety', () => { - it('does not throw when window is undefined (server / static prerender)', () => { + it('does not read window during render and falls back to 0x0', () => { // The `node` environment has no `window`, mirroring Node SSR and - // Next.js static-export prerender. + // Next.js static-export prerender. Initial state is zeroed and the + // mount effect does not run during renderToString, so `window` is + // never touched during render. expect(typeof window).toBe('undefined'); let html = ''; expect(() => { html = renderToString(React.createElement(Probe)); }).not.toThrow(); - // Falls back to zeroed dimensions instead of reading `window`. expect(html).toContain('0x0'); }); - - it('reads real dimensions when window is present', () => { - (global as unknown as { window: { innerWidth: number; innerHeight: number } }).window = { - innerWidth: 1280, - innerHeight: 800 - }; - try { - const html = renderToString(React.createElement(Probe)); - expect(html).toContain('1280x800'); - } finally { - delete (global as unknown as { window?: unknown }).window; - } - }); }); diff --git a/src/custom/Helpers/Dimension/windowSize.tsx b/src/custom/Helpers/Dimension/windowSize.tsx index ea0673e33..49ab957b9 100644 --- a/src/custom/Helpers/Dimension/windowSize.tsx +++ b/src/custom/Helpers/Dimension/windowSize.tsx @@ -1,12 +1,12 @@ import React from 'react'; /** - * Returns the width and height of the window. + * Reads the current window dimensions. * - * During server-side rendering / static prerender there is no `window`, - * so this returns zeroed dimensions instead of throwing a - * `ReferenceError`. The real values are picked up on the client after the - * `resize` listener (and the first effect-driven read) run. + * Only invoked on the client - from the mount effect and the resize handler + * in `useWindowDimensions`, never during render. The `typeof window` guard is + * a defensive net so a stray render-time call can never throw a + * `ReferenceError` under SSR / static prerender. * * @returns {WindowDimensions} { width, height } */ @@ -24,12 +24,25 @@ function getWindowDimensions(): WindowDimensions { /** * Custom hook for getting window dimensions. * + * State is initialised to zeroed dimensions rather than by reading `window` + * in the `useState` initialiser. This keeps the server render and the first + * client (hydration) render identical - reading `window` during render would + * make them diverge (`0x0` on the server vs the real size on the client) and + * trigger a hydration mismatch. The real dimensions are read once on mount + * via the effect below and then kept current by the debounced resize listener. + * * @returns {WindowDimensions} { width, height } */ export function useWindowDimensions(): WindowDimensions { - const [windowDimensions, setWindowDimensions] = React.useState(getWindowDimensions()); + const [windowDimensions, setWindowDimensions] = React.useState({ + width: 0, + height: 0 + }); React.useEffect(() => { + // Sync to the real dimensions on mount (client only). + setWindowDimensions(getWindowDimensions()); + let resizeTimeout: number; function handleResize() {