Skip to content

Commit

Permalink
Prevent window access in useViewportSize when server rendered (#2468
Browse files Browse the repository at this point in the history
)

* 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
  • Loading branch information
tsck authored Sep 19, 2024
1 parent 30f1114 commit 9776f5f
Show file tree
Hide file tree
Showing 11 changed files with 224 additions and 8 deletions.
15 changes: 15 additions & 0 deletions .changeset/mighty-clouds-impress.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
'@leafygreen-ui/testing-lib': minor
---

Adds `renderHookServer` method

@testing-library/react-hooks/server exposed a `renderHook` method
that allowed for one to render hooks as if SSR, and control
hydration. This is no longer supported in versions >=18.

This code was extracted from @testing-library/react-hooks/server and
updated to be compatible with React version >= 18 using `hydrateRoot`.

More context found here:
https://github.com/testing-library/react-testing-library/issues/1120
7 changes: 7 additions & 0 deletions .changeset/short-eels-deliver.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@leafygreen-ui/hooks': minor
---

Adds `useSsrCheck` and adds it to viewport check in `useViewportSize`.

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.
16 changes: 13 additions & 3 deletions packages/hooks/src/hooks.spec.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
import { waitFor } from '@testing-library/react';

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

import {
useEventListener,
useIdAllocator,
useObjectDependency,
usePoller,
usePrevious,
useSsrCheck,
useViewportSize,
} from './index';
import useValidation from './useValidation';
Expand Down Expand Up @@ -353,7 +354,7 @@ describe('packages/hooks', () => {
});

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

it('Returns validation functions when callback is undefined', () => {
test('Returns validation functions when callback is undefined', () => {
const { result } = renderHook(() => useValidation());
expect(result.current.onBlur).toBeDefined();
expect(result.current.onChange).toBeDefined();
});
});

describe('useSsrCheck', () => {
test('should return true when server-side rendered and false after hydration', () => {
const { result, hydrate } = renderHookServer(useSsrCheck);
expect(result.current).toBe(true);
hydrate();
expect(result.current).toBe(false);
});
});
});
1 change: 1 addition & 0 deletions packages/hooks/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ export { default as useMutationObserver } from './useMutationObserver';
export { default as useObjectDependency } from './useObjectDependency';
export { default as usePoller } from './usePoller';
export { default as usePrevious } from './usePrevious';
export { default as useSsrCheck } from './useSsrCheck';
export { useStateRef } from './useStateRef';
export { default as useValidation } from './useValidation';
export { default as useViewportSize } from './useViewportSize';
15 changes: 15 additions & 0 deletions packages/hooks/src/useSsrCheck.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { useEffect, useState } from 'react';

export default function useSsrCheck() {
const [isSsr, setIsSsr] = useState(typeof window === 'undefined');

useEffect(() => {
// When rendered on server, this won't run until we're on the client. Therefore,
// isSsr should be true when server rendered, and only be set to false on subsequent client render.
// When rendered directly on the client, isSsr should already be false, so
// this update shouldn't trigger a re-render.
setIsSsr(false);
}, []);

return isSsr;
}
12 changes: 8 additions & 4 deletions packages/hooks/src/useViewportSize.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { useEffect, useState } from 'react';
import debounce from 'lodash/debounce';

import useSsrCheck from './useSsrCheck';

interface ViewportSize {
width: number;
height: number;
Expand All @@ -13,9 +15,11 @@ function getViewportSize(): ViewportSize {
};
}

export default function useViewportSize(): ViewportSize {
const [viewportSize, setViewportUpdateVal] = useState<ViewportSize>(
getViewportSize(),
export default function useViewportSize(): ViewportSize | null {
const isSsr = useSsrCheck();

const [viewportSize, setViewportUpdateVal] = useState<ViewportSize | null>(
isSsr ? null : getViewportSize(), // window undefined on server
);

useEffect(() => {
Expand All @@ -24,8 +28,8 @@ export default function useViewportSize(): ViewportSize {
100,
);

// useEffect callback only runs on client, so safe to assume window is defined here
window.addEventListener('resize', calcResize);

return () => window.removeEventListener('resize', calcResize);
}, []);

Expand Down
18 changes: 18 additions & 0 deletions packages/testing-lib/rollup.config.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { esmConfig, umdConfig } from '@lg-tools/build/config/rollup.config.mjs';

export default [
esmConfig,
umdConfig,
{
...esmConfig,
input: ['./src/renderHookServer.tsx', './src/renderHookServerV17.tsx'],
output: {
// cjs is fully supported in node.js
format: 'cjs', // overrides esm format from esmConfig.output
entryFileNames: '[name].js',
dir: 'dist',
preserveModules: true,
exports: 'auto',
},
},
];
20 changes: 20 additions & 0 deletions packages/testing-lib/src/RTLOverrides.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,11 @@
import * as React from 'react';
import * as RTL from '@testing-library/react';
import path from 'path';

import {
RenderHookServerOptions,
RenderHookServerResult,
} from './renderHookServer';

/**
* Utility type that returns `X.Y` if it exists, otherwise defaults to fallback type `Z`, or `any`
Expand Down Expand Up @@ -34,3 +41,16 @@ export const act: Exists<typeof RTL, 'act'> =
const RHTL = require('@testing-library/react-hooks');
return RHTL.act;
})();

/**
* Correct `renderHookServer` method based on React version.
*/
export const renderHookServer: <Hook extends () => any>(
useHook: Hook,
options?: RenderHookServerOptions,
) => RenderHookServerResult<Hook> = (() => {
const isReact18 = parseInt(React.version.split('.')[0], 10) >= 18;
const filename = isReact18 ? 'renderHookServer' : 'renderHookServerV17';
const RHS = require(path.resolve(__dirname, filename));
return RHS.renderHookServer;
})();
2 changes: 1 addition & 1 deletion packages/testing-lib/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import * as Context from './context';
import * as jest from './jest';
import * as JestDOM from './jest-dom';
export { act, renderHook } from './RTLOverrides';
export { act, renderHook, renderHookServer } from './RTLOverrides';
export { useTraceUpdate } from './useTraceUpdate';
export { waitForState } from './waitForState';
export { waitForTransition } from './waitForTransition';
Expand Down
94 changes: 94 additions & 0 deletions packages/testing-lib/src/renderHookServer.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import type { ReactNode } from 'react';
import React from 'react';
//@ts-ignore Cannot find module 'react-dom/client' or its corresponding type declarations
import { hydrateRoot } from 'react-dom/client';
import { renderToString } from 'react-dom/server';
import { act } from 'react-dom/test-utils';

export interface RenderHookServerOptions {
wrapper?: ({ children }: { children: ReactNode }) => JSX.Element;
}

export interface RenderHookServerResult<Hook extends () => any> {
result: { current: ReturnType<Hook> };
hydrate: () => void;
}

/**
* Allows you to mock the server side rendering of a hook.
*
* @testing-library/react-hooks/server exposed a `renderHook` method
* that allowed for one to render hooks as if SSR, and control
* hydration. This is no longer supported in versions >=18.
*
* This code was extracted from @testing-library/react-hooks/server and
* updated to be compatible with React version >= 18 using `hydrateRoot`.
*
* More context found here:
* https://github.com/testing-library/react-testing-library/issues/1120
*
* e.g.
* ```typescript
* it('should return true when server-side rendered and false after hydration', () => {
* const { result, hydrate } = renderHookServer(useMyHook);
* expect(result.current).toBe(true);
* hydrate();
* expect(result.current).toBe(false);
* });
* ```
}
*/
export function renderHookServer<Hook extends () => any>(
useHook: Hook,
{ wrapper: Wrapper }: RenderHookServerOptions = {},
): RenderHookServerResult<Hook> {
// Store hook return value
const results: Array<ReturnType<Hook>> = [];
const result = {
get current() {
return results.slice(-1)[0];
},
};

// Test component to render hook in
const Component = ({ useHook }: { useHook: Hook }) => {
results.push(useHook());
return null;
};

// Add wrapper if necessary
const component = Wrapper ? (
<Wrapper>
<Component useHook={useHook} />
</Wrapper>
) : (
<Component useHook={useHook} />
);

// Running tests in an environment that simulates a browser (like Jest with jsdom),
// the window object will still be available even when server rendered. To ensure
// that window is not available during SSR we need to explicitly mock or remove the
// window object.
// @ts-ignore Type 'undefined' is not assignable to type 'Window'.
jest.spyOn(global, 'window', 'get').mockImplementation(() => undefined);

// Render hook on server
const serverOutput = renderToString(component);

// Restore window
jest.spyOn(global, 'window', 'get').mockRestore();

// Render hook on client
const hydrate = () => {
const root = document.createElement('div');
root.innerHTML = serverOutput;
act(() => {
hydrateRoot(root, component);
});
};

return {
result,
hydrate,
};
}
32 changes: 32 additions & 0 deletions packages/testing-lib/src/renderHookServerV17.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { renderHook } from '@testing-library/react-hooks/server';

import {
RenderHookServerOptions,
RenderHookServerResult,
} from './renderHookServer';

/**
* Allows you to mock the server side rendering of a hook in pre React 18 versions.
* For versions >=18, use `@testing-lib/renderHookServer`.
*
* e.g.
* ```typescript
* it('should return true when server-side rendered and false after hydration', () => {
* const { result, hydrate } = renderHookServer(useMyHook);
* expect(result.current).toBe(true);
* hydrate();
* expect(result.current).toBe(false);
* });
* ```
}
*/
export function renderHookServer<Hook extends () => any>(
useHook: Hook,
{ wrapper }: RenderHookServerOptions = {},
): RenderHookServerResult<Hook> {
// @ts-ignore Type 'undefined' is not assignable to type 'Window'.
jest.spyOn(global, 'window', 'get').mockImplementation(() => undefined);
const response = renderHook(useHook, { wrapper });
jest.spyOn(global, 'window', 'get').mockRestore();
return response;
}

0 comments on commit 9776f5f

Please sign in to comment.