Skip to content

Commit 97ff9f9

Browse files
authored
Fix useId hydration error in strict mode (#3980)
1 parent c5961ed commit 97ff9f9

File tree

5 files changed

+155
-7
lines changed

5 files changed

+155
-7
lines changed

packages/@react-aria/ssr/src/SSRProvider.tsx

+46-5
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
// We must avoid a circular dependency with @react-aria/utils, and this useLayoutEffect is
1414
// guarded by a check that it only runs on the client side.
1515
// eslint-disable-next-line rulesdir/useLayoutEffectRule
16-
import React, {ReactNode, useContext, useLayoutEffect, useMemo, useState} from 'react';
16+
import React, {ReactNode, useContext, useLayoutEffect, useMemo, useRef, useState} from 'react';
1717

1818
// To support SSR, the auto incrementing id counter is stored in a context. This allows
1919
// it to be reset on every request to ensure the client and server are consistent.
@@ -49,12 +49,13 @@ export interface SSRProviderProps {
4949
*/
5050
export function SSRProvider(props: SSRProviderProps): JSX.Element {
5151
let cur = useContext(SSRContext);
52+
let counter = useCounter(cur === defaultContext);
5253
let value: SSRContextValue = useMemo(() => ({
5354
// If this is the first SSRProvider, start with an empty string prefix, otherwise
5455
// append and increment the counter.
55-
prefix: cur === defaultContext ? '' : `${cur.prefix}-${++cur.current}`,
56+
prefix: cur === defaultContext ? '' : `${cur.prefix}-${counter}`,
5657
current: 0
57-
}), [cur]);
58+
}), [cur, counter]);
5859

5960
return (
6061
<SSRContext.Provider value={value}>
@@ -69,6 +70,46 @@ let canUseDOM = Boolean(
6970
window.document.createElement
7071
);
7172

73+
let componentIds = new WeakMap();
74+
75+
function useCounter(isDisabled = false) {
76+
let ctx = useContext(SSRContext);
77+
let ref = useRef<number | null>(null);
78+
if (ref.current === null && !isDisabled) {
79+
// In strict mode, React renders components twice, and the ref will be reset to null on the second render.
80+
// This means our id counter will be incremented twice instead of once. This is a problem because on the
81+
// server, components are only rendered once and so ids generated on the server won't match the client.
82+
// In React 18, useId was introduced to solve this, but it is not available in older versions. So to solve this
83+
// we need to use some React internals to access the underlying Fiber instance, which is stable between renders.
84+
// This is exposed as ReactCurrentOwner in development, which is all we need since StrictMode only runs in development.
85+
// To ensure that we only increment the global counter once, we store the starting id for this component in
86+
// a weak map associated with the Fiber. On the second render, we reset the global counter to this value.
87+
// Since React runs the second render immediately after the first, this is safe.
88+
// @ts-ignore
89+
let currentOwner = React.__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED?.ReactCurrentOwner?.current;
90+
if (currentOwner) {
91+
let prevComponentValue = componentIds.get(currentOwner);
92+
if (prevComponentValue == null) {
93+
// On the first render, and first call to useId, store the id and state in our weak map.
94+
componentIds.set(currentOwner, {
95+
id: ctx.current,
96+
state: currentOwner.memoizedState
97+
});
98+
} else if (currentOwner.memoizedState !== prevComponentValue.state) {
99+
// On the second render, the memoizedState gets reset by React.
100+
// Reset the counter, and remove from the weak map so we don't
101+
// do this for subsequent useId calls.
102+
ctx.current = prevComponentValue.id;
103+
componentIds.delete(currentOwner);
104+
}
105+
}
106+
107+
ref.current = ++ctx.current;
108+
}
109+
110+
return ref.current;
111+
}
112+
72113
/** @private */
73114
export function useSSRSafeId(defaultId?: string): string {
74115
let ctx = useContext(SSRContext);
@@ -79,8 +120,8 @@ export function useSSRSafeId(defaultId?: string): string {
79120
console.warn('When server rendering, you must wrap your application in an <SSRProvider> to ensure consistent ids are generated between the client and server.');
80121
}
81122

82-
// eslint-disable-next-line react-hooks/exhaustive-deps
83-
return useMemo(() => defaultId || `react-aria${ctx.prefix}-${++ctx.current}`, [defaultId]);
123+
let counter = useCounter(!!defaultId);
124+
return defaultId || `react-aria${ctx.prefix}-${counter}`;
84125
}
85126

86127
/**
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
/*
2+
* Copyright 2020 Adobe. All rights reserved.
3+
* This file is licensed to you under the Apache License, Version 2.0 (the "License");
4+
* you may not use this file except in compliance with the License. You may obtain a copy
5+
* of the License at http://www.apache.org/licenses/LICENSE-2.0
6+
*
7+
* Unless required by applicable law or agreed to in writing, software distributed under
8+
* the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
9+
* OF ANY KIND, either express or implied. See the License for the specific language
10+
* governing permissions and limitations under the License.
11+
*/
12+
13+
import {testSSR} from '@react-spectrum/test-utils';
14+
15+
describe('SSRProvider SSR', function () {
16+
it('should render without errors', async function () {
17+
await testSSR(__filename, `
18+
import {SSRProvider, useSSRSafeId} from '../';
19+
20+
function Test() {
21+
return <div id={useSSRSafeId()} />;
22+
}
23+
24+
<SSRProvider>
25+
<Test />
26+
<Test />
27+
</SSRProvider>
28+
`);
29+
});
30+
31+
it('should render without errors in StrictMode', async function () {
32+
await testSSR(__filename, `
33+
import {SSRProvider, useSSRSafeId} from '../';
34+
35+
function Test() {
36+
return <div id={useSSRSafeId()} />;
37+
}
38+
39+
<React.StrictMode>
40+
<SSRProvider>
41+
<Test />
42+
<Test />
43+
</SSRProvider>
44+
</React.StrictMode>
45+
`);
46+
});
47+
48+
it('should render without errors in StrictMode with nested SSRProviders', async function () {
49+
await testSSR(__filename, `
50+
import {SSRProvider, useSSRSafeId} from '../';
51+
52+
function Test() {
53+
return <div id={useSSRSafeId()} />;
54+
}
55+
56+
<React.StrictMode>
57+
<SSRProvider>
58+
<SSRProvider>
59+
<Test />
60+
</SSRProvider>
61+
<SSRProvider>
62+
<Test />
63+
</SSRProvider>
64+
</SSRProvider>
65+
</React.StrictMode>
66+
`);
67+
});
68+
});

packages/@react-aria/ssr/test/SSRProvider.test.js

+38
Original file line numberDiff line numberDiff line change
@@ -61,4 +61,42 @@ describe('SSRProvider', function () {
6161
]
6262
`);
6363
});
64+
65+
it('it should generate consistent unique ids in React strict mode', function () {
66+
let tree = render(
67+
<React.StrictMode>
68+
<SSRProvider>
69+
<Test />
70+
<Test />
71+
</SSRProvider>
72+
</React.StrictMode>
73+
);
74+
75+
let divs = tree.getAllByTestId('test');
76+
expect(divs[0].id).toBe('react-aria-1');
77+
expect(divs[1].id).toBe('react-aria-2');
78+
});
79+
80+
it('it should generate consistent unique ids in React strict mode with Suspense', function () {
81+
let tree = render(
82+
<React.StrictMode>
83+
<SSRProvider>
84+
<SSRProvider>
85+
<React.Suspense fallback={<span>Loading</span>}>
86+
<Test />
87+
</React.Suspense>
88+
</SSRProvider>
89+
<SSRProvider>
90+
<React.Suspense fallback={<span>Loading</span>}>
91+
<Test />
92+
</React.Suspense>
93+
</SSRProvider>
94+
</SSRProvider>
95+
</React.StrictMode>
96+
);
97+
98+
let divs = tree.getAllByTestId('test');
99+
expect(divs[0].id).toBe('react-aria-1-1');
100+
expect(divs[1].id).toBe('react-aria-2-1');
101+
});
64102
});

packages/@react-spectrum/table/src/TableView.tsx

+1-1
Original file line numberDiff line numberDiff line change
@@ -540,7 +540,7 @@ function TableVirtualizer({layout, collection, focusedKey, renderView, renderWra
540540
height: headerHeight,
541541
overflow: 'hidden',
542542
position: 'relative',
543-
willChange: state.isScrolling ? 'scroll-position' : '',
543+
willChange: state.isScrolling ? 'scroll-position' : undefined,
544544
transition: state.isAnimating ? `none ${state.virtualizer.transitionDuration}ms` : undefined
545545
}}
546546
ref={headerRef}>

packages/dev/test-utils/src/testSSR.js

+2-1
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212

1313
// Can't `import` babel, have to require?
1414
const babel = require('@babel/core');
15+
import {act} from '@testing-library/react';
1516
import {evaluate} from './ssrUtils';
1617
import http from 'http';
1718
import React from 'react';
@@ -65,7 +66,7 @@ export async function testSSR(filename, source) {
6566
let container = document.querySelector('#root');
6667
let element = evaluate(source, filename);
6768
if (ReactDOMClient) {
68-
ReactDOMClient.hydrateRoot(container, <SSRProvider>{element}</SSRProvider>);
69+
act(() => ReactDOMClient.hydrateRoot(container, <SSRProvider>{element}</SSRProvider>));
6970
} else {
7071
ReactDOM.hydrate(<SSRProvider>{element}</SSRProvider>, container);
7172
}

0 commit comments

Comments
 (0)