Skip to content

React strict mode support for the useSSRSafeId hook #3963

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
wants to merge 1 commit into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
99 changes: 82 additions & 17 deletions packages/@react-aria/ssr/src/SSRProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,50 @@ const defaultContext: SSRContextValue = {

const SSRContext = React.createContext<SSRContextValue>(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<SSRIdOptions>({mode: 'counter'});

const canUseDOM = Boolean(
typeof window !== 'undefined' &&
window.document &&
window.document.createElement
);

export type SSRProviderProps = SSRIdOptions & {
/** Your application here. */
children: ReactNode
}
Expand All @@ -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 (
<SSRContext.Provider value={value}>
{props.children}
<SSRIdOptionContext.Provider value={idOption}>
{children}
</SSRIdOptionContext.Provider>
</SSRContext.Provider>
);
}

let canUseDOM = Boolean(
typeof window !== 'undefined' &&
window.document &&
window.document.createElement
);

/** @private */
export function useSSRSafeId(defaultId?: string): string {
let ctx = useContext(SSRContext);
Expand All @@ -79,8 +127,25 @@ export function useSSRSafeId(defaultId?: string): string {
console.warn('When server rendering, you must wrap your application in an <SSRProvider> 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]);
}
}

/**
Expand Down
43 changes: 43 additions & 0 deletions packages/@react-aria/ssr/test/SSRProvider.ssr.test.js
Original file line number Diff line number Diff line change
@@ -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 = () => <div id={useSSRSafeId()} />;

<>
<SSRProvider>
<Test />
<Test />
</SSRProvider>
{React.useId !== undefined && (
<SSRProvider mode="useId" useId={React.useId}>
<Test />
<Test />
</SSRProvider>
)}
</>
`);
});

it('should render without errors in strict mode', async function () {
await testSSR(__filename, `
import {SSRProvider, useSSRSafeId} from '../';
const Test = () => <div id={useSSRSafeId()} />;

<React.StrictMode>
<SSRProvider strictMode>
<Test />
<Test />
</SSRProvider>
{React.useId !== undefined && (
<SSRProvider mode="useId" useId={React.useId}>
<Test />
<Test />
</SSRProvider>
)}
</React.StrictMode>
`);
});
});
53 changes: 53 additions & 0 deletions packages/@react-aria/ssr/test/SSRProvider.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<React.StrictMode>
<SSRProvider strictMode={process.env.NODE_ENV !== 'production'}>
<Test />
<Test />
</SSRProvider>
</React.StrictMode>
);

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(
<SSRProvider mode="useId" useId={mockFn}>
<Test />
<Test />
</SSRProvider>
);

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(
<React.StrictMode>
<SSRProvider mode="useId" useId={mockFn}>
<Test />
<Test />
</SSRProvider>
</React.StrictMode>
);

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(
<SSRProvider>
Expand Down
39 changes: 33 additions & 6 deletions packages/dev/docs/pages/react-aria/ssr.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -37,14 +37,41 @@ import {SSRProvider} from '@react-aria/ssr';
</SSRProvider>
```

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';

<SSRProvider mode="useId" useId={React.useId}>
<App />
</SSRProvider>
```

### 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';

<React.StrictMode>
<SSRProvider strictMode={process.env.NODE_ENV !== 'production'}>
<App />
</SSRProvider>
</React.StrictMode>
```

### 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

Expand Down
44 changes: 43 additions & 1 deletion packages/dev/docs/pages/react-spectrum/ssr.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -42,9 +42,51 @@ import {SSRProvider, Provider, defaultTheme} from '@adobe/react-spectrum';
</SSRProvider>
```

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';

<SSRProvider mode="useId" useId={React.useId}>
<Provider theme={defaultTheme} locale={yourLocaleHere}>
<App />
</Provider>
</SSRProvider>
```

### 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';

<React.StrictMode>
<SSRProvider strictMode={process.env.NODE_ENV !== 'production'}>
<Provider theme={defaultTheme} locale={yourLocaleHere}>
<App />
</Provider>
</SSRProvider>
</React.StrictMode>
```

### 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.

Expand Down