diff --git a/packages/@react-aria/ssr/src/SSRProvider.tsx b/packages/@react-aria/ssr/src/SSRProvider.tsx index ea466a37335..1d74526c4ac 100644 --- a/packages/@react-aria/ssr/src/SSRProvider.tsx +++ b/packages/@react-aria/ssr/src/SSRProvider.tsx @@ -38,7 +38,50 @@ const defaultContext: SSRContextValue = { const SSRContext = React.createContext(defaultContext); -export interface SSRProviderProps { +/** + * A set of options for generating IDs. + * You cannot change the mode at runtime. + */ +export type SSRIdOptions = + | { + /** + * In 'counter' mode (the default), the auto incrementing counter stored in a context is used to generate IDs. + * If you are using React 18 or later, use the 'useId' mode instead. + */ + mode?: 'counter', + /** + * Whether or not React strict mode is enabled in this context. This value should be invariant at runtime. + * When set to `true`, the `current` counter is incremented by two on the server side to avoid hydration errors. + * Normally you should pass `process.env.NODE_ENV !== 'production'`. + */ + strictMode?: boolean, + + useId?: never + } + | { + /** + * In 'useId' mode, the provided `useId` function is used to generate IDs. + * If you are using React 16 or 17, use the 'counter' mode instead. + */ + mode: 'useId', + /** + * A React hook to generate IDs. This value must be invariant at runtime. + * Normally you should pass `React.useId`. + */ + useId: () => string, + + strictMode?: never + }; + +const SSRIdOptionContext = React.createContext({mode: 'counter'}); + +const canUseDOM = Boolean( + typeof window !== 'undefined' && + window.document && + window.document.createElement +); + +export type SSRProviderProps = SSRIdOptions & { /** Your application here. */ children: ReactNode } @@ -47,28 +90,33 @@ export interface SSRProviderProps { * When using SSR with React Aria, applications must be wrapped in an SSRProvider. * This ensures that auto generated ids are consistent between the client and server. */ -export function SSRProvider(props: SSRProviderProps): JSX.Element { +export function SSRProvider({children, ...idOption}: SSRProviderProps): JSX.Element { let cur = useContext(SSRContext); - let value: SSRContextValue = useMemo(() => ({ - // If this is the first SSRProvider, start with an empty string prefix, otherwise - // append and increment the counter. - prefix: cur === defaultContext ? '' : `${cur.prefix}-${++cur.current}`, - current: 0 - }), [cur]); + let value: SSRContextValue = useMemo(() => { + // If React strict mode is enabled, the function passed to `useMemo` will be called twice on the client. + // As a result, `cur.current` will increase by two on the client side, causing hydration errors. + // To avoid the error, we increase the counter to mimic this behavior on the server side. + if (idOption.strictMode && !canUseDOM) { + cur.current++; + } + + return { + // If this is the first SSRProvider, start with an empty string prefix, otherwise + // append and increment the counter. + prefix: cur === defaultContext ? '' : `${cur.prefix}-${++cur.current}`, + current: 0 + }; + }, [idOption.strictMode, cur]); return ( - {props.children} + + {children} + ); } -let canUseDOM = Boolean( - typeof window !== 'undefined' && - window.document && - window.document.createElement -); - /** @private */ export function useSSRSafeId(defaultId?: string): string { let ctx = useContext(SSRContext); @@ -79,8 +127,25 @@ export function useSSRSafeId(defaultId?: string): string { console.warn('When server rendering, you must wrap your application in an to ensure consistent ids are generated between the client and server.'); } - // eslint-disable-next-line react-hooks/exhaustive-deps - return useMemo(() => defaultId || `react-aria${ctx.prefix}-${++ctx.current}`, [defaultId]); + const idOption = useContext(SSRIdOptionContext); + + // We assume that `idOption.mode` is invariant at runtime. + if (idOption.mode === 'useId') { + return idOption.useId(); + } else { + // eslint-disable-next-line react-hooks/rules-of-hooks + return useMemo(() => { + // If React strict mode is enabled, the function passed to `useMemo` will be called twice on the client. + // As a result, `ctx.current` will increase by two on the client side, causing hydration errors. + // To avoid the error, we increase the counter to mimic this behavior on the server side. + if (idOption.strictMode && !canUseDOM) { + ctx.current++; + } + + return defaultId || `react-aria${ctx.prefix}-${++ctx.current}`; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [idOption.strictMode, defaultId]); + } } /** diff --git a/packages/@react-aria/ssr/test/SSRProvider.ssr.test.js b/packages/@react-aria/ssr/test/SSRProvider.ssr.test.js new file mode 100644 index 00000000000..16cf8eaccd0 --- /dev/null +++ b/packages/@react-aria/ssr/test/SSRProvider.ssr.test.js @@ -0,0 +1,43 @@ +import {testSSR} from '@react-spectrum/test-utils'; + +describe('SSRProvider', function () { + it('should render without errors', async function () { + await testSSR(__filename, ` + import {SSRProvider, useSSRSafeId} from '../'; + const Test = () =>
; + + <> + + + + + {React.useId !== undefined && ( + + + + + )} + + `); + }); + + it('should render without errors in strict mode', async function () { + await testSSR(__filename, ` + import {SSRProvider, useSSRSafeId} from '../'; + const Test = () =>
; + + + + + + + {React.useId !== undefined && ( + + + + + )} + + `); + }); +}); diff --git a/packages/@react-aria/ssr/test/SSRProvider.test.js b/packages/@react-aria/ssr/test/SSRProvider.test.js index 85538d02853..afddbd02532 100644 --- a/packages/@react-aria/ssr/test/SSRProvider.test.js +++ b/packages/@react-aria/ssr/test/SSRProvider.test.js @@ -33,6 +33,59 @@ describe('SSRProvider', function () { expect(divs[1].id).toBe('react-aria-2'); }); + it('it should generate consistent unique ids in React strict mode', function () { + let tree = render( + + + + + + + ); + + let divs = tree.getAllByTestId('test'); + expect(divs[0].id).toBe('react-aria-2'); + expect(divs[1].id).toBe('react-aria-4'); + }); + + it('it should generate consistent unique ids in "useId" mode', function () { + const mockFn = jest.fn() + .mockReturnValueOnce('a') + .mockReturnValueOnce('b'); + + let tree = render( + + + + + ); + + let divs = tree.getAllByTestId('test'); + expect(divs[0].id).toBe('a'); + expect(divs[1].id).toBe('b'); + }); + + it('it should generate consistent unique ids in "useId" mode in React strict mode', function () { + const mockFn = jest.fn() + .mockReturnValueOnce('a') + .mockReturnValueOnce('b') + .mockReturnValueOnce('c') + .mockReturnValueOnce('d'); + + let tree = render( + + + + + + + ); + + let divs = tree.getAllByTestId('test'); + expect(divs[0].id).toBe('b'); + expect(divs[1].id).toBe('d'); + }); + it('it should generate consistent unique ids with nested SSR providers', function () { let tree = render( diff --git a/packages/dev/docs/pages/react-aria/ssr.mdx b/packages/dev/docs/pages/react-aria/ssr.mdx index 2c464f50279..3d84cb88f6a 100644 --- a/packages/dev/docs/pages/react-aria/ssr.mdx +++ b/packages/dev/docs/pages/react-aria/ssr.mdx @@ -27,7 +27,7 @@ This page describes how to use React Aria with server side rendering, including In React, SSR works by rendering the component to HTML on the server, and then **hydrating** the DOM tree with events and state on the client. This enables applications to both render complete HTML in advance for performance and SEO, but also support rich interactions on the client. -In order to make components using React Aria work with SSR, you will need to wrap your application in an [SSRProvider](SSRProvider.html). This signals to all nested React Aria hooks that they are being rendered in an SSR context. +In order to make components using React Aria work with SSR, you will need to wrap your application in an [SSRProvider](SSRProvider.html). This signals to all nested React Aria hooks that they are being rendered in an SSR context. Specifically, it affects React Aria’s automatic id generation. ```tsx import {SSRProvider} from '@react-aria/ssr'; @@ -37,14 +37,41 @@ import {SSRProvider} from '@react-aria/ssr'; ``` -Wrapping your application in an `SSRProvider` helps ensure that the HTML generated on the server matches the DOM structure hydrated on the client. Specifically, it affects React Aria’s automatic id generation, and you can also use this information to influence rendering in your own components. +You may need to pass additional options to the `SSRProvider`, depending on your environment. -## Automatic ID Generation +### React 18 or later -When using SSR, only a single copy of React Aria can be on the page at a time. This is in contrast to client-side rendering, where multiple copies from different parts of an app can coexist. Internally, many components rely on auto-generated ids to link related elements via ARIA attributes. These ids typically use a randomly generated seed plus an incrementing counter to ensure uniqueness even when multiple instances of React Aria are on the page. With SSR, we need to ensure that these ids are consistent between the server and client. This means the counter resets on every request, and we use a consistent seed. Due to this, multiple copies of React Aria cannot be supported because the auto-generated ids would conflict. +If you are using React 18 or later, you will need to specify `useId` mode and pass [React.useId](https://reactjs.org/docs/hooks-reference.html#useid) to the `SSRProvider`. -If you use React Aria’s [useId](useId.html) hook in your own components, `SSRProvider` will ensure the ids are consistent when server rendered. No additional changes in each component are required to enable -SSR support. +```tsx +import React from 'react' +import {SSRProvider} from '@react-aria/ssr'; + + + + +``` + +### React 16 or 17 with strict mode enabled + +If you are using React 16 or 17 with [strict mode](https://reactjs.org/docs/strict-mode.html) enabled, you will need to specify `strictMode` prop. + +```tsx +import React from 'react' +import {SSRProvider} from '@react-aria/ssr'; + + + + + + +``` + +### Multiple copies of react-aria on the same page + +If you are using React 18 or later and have multiple roots on the same page, you will need to specify the `identifierPrefix` option on [ReactDOMClient.createRoot](https://reactjs.org/docs/react-dom-client.html#createroot). + +If you are using React 16 or 17, only a single copy of React Aria can be on the page at a time. ## SSR specific rendering diff --git a/packages/dev/docs/pages/react-spectrum/ssr.mdx b/packages/dev/docs/pages/react-spectrum/ssr.mdx index 2bfb754872e..08e89535d46 100644 --- a/packages/dev/docs/pages/react-spectrum/ssr.mdx +++ b/packages/dev/docs/pages/react-spectrum/ssr.mdx @@ -42,9 +42,51 @@ import {SSRProvider, Provider, defaultTheme} from '@adobe/react-spectrum'; ``` +You may need to pass additional options to the `SSRProvider`, depending on your environment. + +### React 18 or later + +If you are using React 18 or later, you will need to specify `useId` mode and pass [React.useId](https://reactjs.org/docs/hooks-reference.html#useid) to the `SSRProvider`. + +```tsx +import React from 'react' +import {SSRProvider, Provider, defaultTheme} from '@adobe/react-spectrum'; + + + + + + +``` + +### React 16 or 17 with strict mode enabled + +If you are using React 16 or 17 with [strict mode](https://reactjs.org/docs/strict-mode.html) enabled, you will need to specify `strictMode` prop. + +```tsx +import React from 'react' +import {SSRProvider, Provider, defaultTheme} from '@adobe/react-spectrum'; + + + + + + + + +``` + +### Multiple copies of react-aria on the same page + +If you are using React 18 or later and have multiple roots on the same page, you will need to specify the `identifierPrefix` option on [ReactDOMClient.createRoot](https://reactjs.org/docs/react-dom-client.html#createroot). + +If you are using React 16 or 17, only a single copy of React Spectrum can be on the page at a time. + +### Background + Wrapping your application in an `SSRProvider` ensures that the HTML generated on the server matches the DOM structure hydrated on the client. Specifically, it affects four things: id generation for accessibility, media queries, feature detection, and automatic locale selection. -When using SSR, only a single copy of React Spectrum can be on the page at a time. This is in contrast to client-side rendering, where multiple copies from different parts of an app can coexist. Internally, many components rely on auto-generated ids to link related elements via ARIA attributes. When server side rendering, these ids need to be consistent so they match between the server and client, and this would not be possible with multiple copies of React Spectrum. +Internally, many components rely on auto-generated ids to link related elements via ARIA attributes. When server side rendering, these ids need to be consistent so they match between the server and client. Media queries and DOM feature detection cannot be performed on the server because they depend on specific browser parameters that aren’t sent as part of the request. In cases where these affect the rendering of a particular component, this check is delayed until just after hydration is completed. This ensures that the rendering is consistent between the server and hydrated DOM, but updated immediately after the page becomes interactive.