Skip to content

Commit 9776f5f

Browse files
authored
Prevent window access in useViewportSize when server rendered (#2468)
* Add SSR check on window call * Handle client render after server using state * Add changeset * Lint * Add testing renderHookServer * Remove unused * Update changeset * Lint * useIsSsr to useSsrCheck * Lint * Update viewport hook * Add R17 version * Revert R17 package changes * CR changes * Lint * Is it that simple? * Semi shot in the dark * Revert "Semi shot in the dark" This reverts commit d8b5fed. * Bump changesets to minor
1 parent 30f1114 commit 9776f5f

File tree

11 files changed

+224
-8
lines changed

11 files changed

+224
-8
lines changed

.changeset/mighty-clouds-impress.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
'@leafygreen-ui/testing-lib': minor
3+
---
4+
5+
Adds `renderHookServer` method
6+
7+
@testing-library/react-hooks/server exposed a `renderHook` method
8+
that allowed for one to render hooks as if SSR, and control
9+
hydration. This is no longer supported in versions >=18.
10+
11+
This code was extracted from @testing-library/react-hooks/server and
12+
updated to be compatible with React version >= 18 using `hydrateRoot`.
13+
14+
More context found here:
15+
https://github.com/testing-library/react-testing-library/issues/1120

.changeset/short-eels-deliver.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
'@leafygreen-ui/hooks': minor
3+
---
4+
5+
Adds `useSsrCheck` and adds it to viewport check in `useViewportSize`.
6+
7+
When server side rendering is used, `window` is not defined. This is causing build issues on the server where we access `window` in `useViewportSize`. To fix this, this change adds a hook, `useSsrCheck`, that checks the rendering environment and can be used before attempting to access `window`. It adds a check of this to `useViewportSize` to fix the current build issue.

packages/hooks/src/hooks.spec.tsx

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,14 @@
11
import { waitFor } from '@testing-library/react';
22

3-
import { act, renderHook } from '@leafygreen-ui/testing-lib';
3+
import { act, renderHook, renderHookServer } from '@leafygreen-ui/testing-lib';
44

55
import {
66
useEventListener,
77
useIdAllocator,
88
useObjectDependency,
99
usePoller,
1010
usePrevious,
11+
useSsrCheck,
1112
useViewportSize,
1213
} from './index';
1314
import useValidation from './useValidation';
@@ -353,7 +354,7 @@ describe('packages/hooks', () => {
353354
});
354355

355356
describe('useValidation', () => {
356-
it('Returns validation functions when callback is defined', () => {
357+
test('Returns validation functions when callback is defined', () => {
357358
const { result } = renderHook(() =>
358359
// eslint-disable-next-line no-console
359360
useValidation(value => console.log(value)),
@@ -362,10 +363,19 @@ describe('packages/hooks', () => {
362363
expect(result.current.onChange).toBeDefined();
363364
});
364365

365-
it('Returns validation functions when callback is undefined', () => {
366+
test('Returns validation functions when callback is undefined', () => {
366367
const { result } = renderHook(() => useValidation());
367368
expect(result.current.onBlur).toBeDefined();
368369
expect(result.current.onChange).toBeDefined();
369370
});
370371
});
372+
373+
describe('useSsrCheck', () => {
374+
test('should return true when server-side rendered and false after hydration', () => {
375+
const { result, hydrate } = renderHookServer(useSsrCheck);
376+
expect(result.current).toBe(true);
377+
hydrate();
378+
expect(result.current).toBe(false);
379+
});
380+
});
371381
});

packages/hooks/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ export { default as useMutationObserver } from './useMutationObserver';
1313
export { default as useObjectDependency } from './useObjectDependency';
1414
export { default as usePoller } from './usePoller';
1515
export { default as usePrevious } from './usePrevious';
16+
export { default as useSsrCheck } from './useSsrCheck';
1617
export { useStateRef } from './useStateRef';
1718
export { default as useValidation } from './useValidation';
1819
export { default as useViewportSize } from './useViewportSize';

packages/hooks/src/useSsrCheck.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import { useEffect, useState } from 'react';
2+
3+
export default function useSsrCheck() {
4+
const [isSsr, setIsSsr] = useState(typeof window === 'undefined');
5+
6+
useEffect(() => {
7+
// When rendered on server, this won't run until we're on the client. Therefore,
8+
// isSsr should be true when server rendered, and only be set to false on subsequent client render.
9+
// When rendered directly on the client, isSsr should already be false, so
10+
// this update shouldn't trigger a re-render.
11+
setIsSsr(false);
12+
}, []);
13+
14+
return isSsr;
15+
}

packages/hooks/src/useViewportSize.ts

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import { useEffect, useState } from 'react';
22
import debounce from 'lodash/debounce';
33

4+
import useSsrCheck from './useSsrCheck';
5+
46
interface ViewportSize {
57
width: number;
68
height: number;
@@ -13,9 +15,11 @@ function getViewportSize(): ViewportSize {
1315
};
1416
}
1517

16-
export default function useViewportSize(): ViewportSize {
17-
const [viewportSize, setViewportUpdateVal] = useState<ViewportSize>(
18-
getViewportSize(),
18+
export default function useViewportSize(): ViewportSize | null {
19+
const isSsr = useSsrCheck();
20+
21+
const [viewportSize, setViewportUpdateVal] = useState<ViewportSize | null>(
22+
isSsr ? null : getViewportSize(), // window undefined on server
1923
);
2024

2125
useEffect(() => {
@@ -24,8 +28,8 @@ export default function useViewportSize(): ViewportSize {
2428
100,
2529
);
2630

31+
// useEffect callback only runs on client, so safe to assume window is defined here
2732
window.addEventListener('resize', calcResize);
28-
2933
return () => window.removeEventListener('resize', calcResize);
3034
}, []);
3135

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import { esmConfig, umdConfig } from '@lg-tools/build/config/rollup.config.mjs';
2+
3+
export default [
4+
esmConfig,
5+
umdConfig,
6+
{
7+
...esmConfig,
8+
input: ['./src/renderHookServer.tsx', './src/renderHookServerV17.tsx'],
9+
output: {
10+
// cjs is fully supported in node.js
11+
format: 'cjs', // overrides esm format from esmConfig.output
12+
entryFileNames: '[name].js',
13+
dir: 'dist',
14+
preserveModules: true,
15+
exports: 'auto',
16+
},
17+
},
18+
];

packages/testing-lib/src/RTLOverrides.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,11 @@
1+
import * as React from 'react';
12
import * as RTL from '@testing-library/react';
3+
import path from 'path';
4+
5+
import {
6+
RenderHookServerOptions,
7+
RenderHookServerResult,
8+
} from './renderHookServer';
29

310
/**
411
* Utility type that returns `X.Y` if it exists, otherwise defaults to fallback type `Z`, or `any`
@@ -34,3 +41,16 @@ export const act: Exists<typeof RTL, 'act'> =
3441
const RHTL = require('@testing-library/react-hooks');
3542
return RHTL.act;
3643
})();
44+
45+
/**
46+
* Correct `renderHookServer` method based on React version.
47+
*/
48+
export const renderHookServer: <Hook extends () => any>(
49+
useHook: Hook,
50+
options?: RenderHookServerOptions,
51+
) => RenderHookServerResult<Hook> = (() => {
52+
const isReact18 = parseInt(React.version.split('.')[0], 10) >= 18;
53+
const filename = isReact18 ? 'renderHookServer' : 'renderHookServerV17';
54+
const RHS = require(path.resolve(__dirname, filename));
55+
return RHS.renderHookServer;
56+
})();

packages/testing-lib/src/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import * as Context from './context';
22
import * as jest from './jest';
33
import * as JestDOM from './jest-dom';
4-
export { act, renderHook } from './RTLOverrides';
4+
export { act, renderHook, renderHookServer } from './RTLOverrides';
55
export { useTraceUpdate } from './useTraceUpdate';
66
export { waitForState } from './waitForState';
77
export { waitForTransition } from './waitForTransition';
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
import type { ReactNode } from 'react';
2+
import React from 'react';
3+
//@ts-ignore Cannot find module 'react-dom/client' or its corresponding type declarations
4+
import { hydrateRoot } from 'react-dom/client';
5+
import { renderToString } from 'react-dom/server';
6+
import { act } from 'react-dom/test-utils';
7+
8+
export interface RenderHookServerOptions {
9+
wrapper?: ({ children }: { children: ReactNode }) => JSX.Element;
10+
}
11+
12+
export interface RenderHookServerResult<Hook extends () => any> {
13+
result: { current: ReturnType<Hook> };
14+
hydrate: () => void;
15+
}
16+
17+
/**
18+
* Allows you to mock the server side rendering of a hook.
19+
*
20+
* @testing-library/react-hooks/server exposed a `renderHook` method
21+
* that allowed for one to render hooks as if SSR, and control
22+
* hydration. This is no longer supported in versions >=18.
23+
*
24+
* This code was extracted from @testing-library/react-hooks/server and
25+
* updated to be compatible with React version >= 18 using `hydrateRoot`.
26+
*
27+
* More context found here:
28+
* https://github.com/testing-library/react-testing-library/issues/1120
29+
*
30+
* e.g.
31+
* ```typescript
32+
* it('should return true when server-side rendered and false after hydration', () => {
33+
* const { result, hydrate } = renderHookServer(useMyHook);
34+
* expect(result.current).toBe(true);
35+
* hydrate();
36+
* expect(result.current).toBe(false);
37+
* });
38+
* ```
39+
}
40+
*/
41+
export function renderHookServer<Hook extends () => any>(
42+
useHook: Hook,
43+
{ wrapper: Wrapper }: RenderHookServerOptions = {},
44+
): RenderHookServerResult<Hook> {
45+
// Store hook return value
46+
const results: Array<ReturnType<Hook>> = [];
47+
const result = {
48+
get current() {
49+
return results.slice(-1)[0];
50+
},
51+
};
52+
53+
// Test component to render hook in
54+
const Component = ({ useHook }: { useHook: Hook }) => {
55+
results.push(useHook());
56+
return null;
57+
};
58+
59+
// Add wrapper if necessary
60+
const component = Wrapper ? (
61+
<Wrapper>
62+
<Component useHook={useHook} />
63+
</Wrapper>
64+
) : (
65+
<Component useHook={useHook} />
66+
);
67+
68+
// Running tests in an environment that simulates a browser (like Jest with jsdom),
69+
// the window object will still be available even when server rendered. To ensure
70+
// that window is not available during SSR we need to explicitly mock or remove the
71+
// window object.
72+
// @ts-ignore Type 'undefined' is not assignable to type 'Window'.
73+
jest.spyOn(global, 'window', 'get').mockImplementation(() => undefined);
74+
75+
// Render hook on server
76+
const serverOutput = renderToString(component);
77+
78+
// Restore window
79+
jest.spyOn(global, 'window', 'get').mockRestore();
80+
81+
// Render hook on client
82+
const hydrate = () => {
83+
const root = document.createElement('div');
84+
root.innerHTML = serverOutput;
85+
act(() => {
86+
hydrateRoot(root, component);
87+
});
88+
};
89+
90+
return {
91+
result,
92+
hydrate,
93+
};
94+
}

0 commit comments

Comments
 (0)