Skip to content
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

Fix useId hydration error in strict mode #3980

Merged
merged 3 commits into from
Feb 2, 2023
Merged
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
51 changes: 46 additions & 5 deletions packages/@react-aria/ssr/src/SSRProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
// We must avoid a circular dependency with @react-aria/utils, and this useLayoutEffect is
// guarded by a check that it only runs on the client side.
// eslint-disable-next-line rulesdir/useLayoutEffectRule
import React, {ReactNode, useContext, useLayoutEffect, useMemo, useState} from 'react';
import React, {ReactNode, useContext, useLayoutEffect, useMemo, useRef, useState} from 'react';

// To support SSR, the auto incrementing id counter is stored in a context. This allows
// it to be reset on every request to ensure the client and server are consistent.
Expand Down Expand Up @@ -49,12 +49,13 @@ export interface SSRProviderProps {
*/
export function SSRProvider(props: SSRProviderProps): JSX.Element {
let cur = useContext(SSRContext);
let counter = useCounter(cur === defaultContext);
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}`,
prefix: cur === defaultContext ? '' : `${cur.prefix}-${counter}`,
current: 0
}), [cur]);
}), [cur, counter]);

return (
<SSRContext.Provider value={value}>
Expand All @@ -69,6 +70,46 @@ let canUseDOM = Boolean(
window.document.createElement
);

let componentIds = new WeakMap();

function useCounter(isDisabled = false) {
let ctx = useContext(SSRContext);
let ref = useRef<number | null>(null);
if (ref.current === null && !isDisabled) {
// In strict mode, React renders components twice, and the ref will be reset to null on the second render.
// This means our id counter will be incremented twice instead of once. This is a problem because on the
// server, components are only rendered once and so ids generated on the server won't match the client.
// In React 18, useId was introduced to solve this, but it is not available in older versions. So to solve this
// we need to use some React internals to access the underlying Fiber instance, which is stable between renders.
// This is exposed as ReactCurrentOwner in development, which is all we need since StrictMode only runs in development.
// To ensure that we only increment the global counter once, we store the starting id for this component in
// a weak map associated with the Fiber. On the second render, we reset the global counter to this value.
// Since React runs the second render immediately after the first, this is safe.
// @ts-ignore
let currentOwner = React.__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED?.ReactCurrentOwner?.current;
if (currentOwner) {
let prevComponentValue = componentIds.get(currentOwner);
if (prevComponentValue == null) {
// On the first render, and first call to useId, store the id and state in our weak map.
componentIds.set(currentOwner, {
id: ctx.current,
state: currentOwner.memoizedState
});
} else if (currentOwner.memoizedState !== prevComponentValue.state) {
// On the second render, the memoizedState gets reset by React.
// Reset the counter, and remove from the weak map so we don't
// do this for subsequent useId calls.
ctx.current = prevComponentValue.id;
componentIds.delete(currentOwner);
}
}

ref.current = ++ctx.current;
}

return ref.current;
}

/** @private */
export function useSSRSafeId(defaultId?: string): string {
let ctx = useContext(SSRContext);
Expand All @@ -79,8 +120,8 @@ 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]);
let counter = useCounter(!!defaultId);
return defaultId || `react-aria${ctx.prefix}-${counter}`;
}

/**
Expand Down
68 changes: 68 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,68 @@
/*
* Copyright 2020 Adobe. All rights reserved.
* This file is licensed to you under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. You may obtain a copy
* of the License at http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software distributed under
* the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
* OF ANY KIND, either express or implied. See the License for the specific language
* governing permissions and limitations under the License.
*/

import {testSSR} from '@react-spectrum/test-utils';

describe('SSRProvider SSR', function () {
it('should render without errors', async function () {
await testSSR(__filename, `
import {SSRProvider, useSSRSafeId} from '../';

function Test() {
return <div id={useSSRSafeId()} />;
}

<SSRProvider>
<Test />
<Test />
</SSRProvider>
`);
});

it('should render without errors in StrictMode', async function () {
await testSSR(__filename, `
import {SSRProvider, useSSRSafeId} from '../';

function Test() {
return <div id={useSSRSafeId()} />;
}

<React.StrictMode>
<SSRProvider>
<Test />
<Test />
</SSRProvider>
</React.StrictMode>
`);
});

it('should render without errors in StrictMode with nested SSRProviders', async function () {
await testSSR(__filename, `
import {SSRProvider, useSSRSafeId} from '../';

function Test() {
return <div id={useSSRSafeId()} />;
}

<React.StrictMode>
<SSRProvider>
<SSRProvider>
<Test />
</SSRProvider>
<SSRProvider>
<Test />
</SSRProvider>
</SSRProvider>
</React.StrictMode>
`);
});
});
38 changes: 38 additions & 0 deletions packages/@react-aria/ssr/test/SSRProvider.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -61,4 +61,42 @@ describe('SSRProvider', function () {
]
`);
});

it('it should generate consistent unique ids in React strict mode', function () {
let tree = render(
<React.StrictMode>
<SSRProvider>
<Test />
<Test />
</SSRProvider>
</React.StrictMode>
);

let divs = tree.getAllByTestId('test');
expect(divs[0].id).toBe('react-aria-1');
expect(divs[1].id).toBe('react-aria-2');
});

it('it should generate consistent unique ids in React strict mode with Suspense', function () {
let tree = render(
<React.StrictMode>
<SSRProvider>
<SSRProvider>
<React.Suspense fallback={<span>Loading</span>}>
<Test />
</React.Suspense>
</SSRProvider>
<SSRProvider>
<React.Suspense fallback={<span>Loading</span>}>
<Test />
</React.Suspense>
</SSRProvider>
</SSRProvider>
</React.StrictMode>
);

let divs = tree.getAllByTestId('test');
expect(divs[0].id).toBe('react-aria-1-1');
expect(divs[1].id).toBe('react-aria-2-1');
});
});
2 changes: 1 addition & 1 deletion packages/@react-spectrum/table/src/TableView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -540,7 +540,7 @@ function TableVirtualizer({layout, collection, focusedKey, renderView, renderWra
height: headerHeight,
overflow: 'hidden',
position: 'relative',
willChange: state.isScrolling ? 'scroll-position' : '',
willChange: state.isScrolling ? 'scroll-position' : undefined,
transition: state.isAnimating ? `none ${state.virtualizer.transitionDuration}ms` : undefined
}}
ref={headerRef}>
Expand Down
3 changes: 2 additions & 1 deletion packages/dev/test-utils/src/testSSR.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@

// Can't `import` babel, have to require?
const babel = require('@babel/core');
import {act} from '@testing-library/react';
import {evaluate} from './ssrUtils';
import http from 'http';
import React from 'react';
Expand Down Expand Up @@ -65,7 +66,7 @@ export async function testSSR(filename, source) {
let container = document.querySelector('#root');
let element = evaluate(source, filename);
if (ReactDOMClient) {
ReactDOMClient.hydrateRoot(container, <SSRProvider>{element}</SSRProvider>);
act(() => ReactDOMClient.hydrateRoot(container, <SSRProvider>{element}</SSRProvider>));
} else {
ReactDOM.hydrate(<SSRProvider>{element}</SSRProvider>, container);
}
Expand Down