diff --git a/packages/@react-aria/ssr/src/SSRProvider.tsx b/packages/@react-aria/ssr/src/SSRProvider.tsx index ea466a37335..920c03a089f 100644 --- a/packages/@react-aria/ssr/src/SSRProvider.tsx +++ b/packages/@react-aria/ssr/src/SSRProvider.tsx @@ -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. @@ -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 ( @@ -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(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); @@ -79,8 +120,8 @@ 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]); + let counter = useCounter(!!defaultId); + return defaultId || `react-aria${ctx.prefix}-${counter}`; } /** 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..d4fafd2bd0d --- /dev/null +++ b/packages/@react-aria/ssr/test/SSRProvider.ssr.test.js @@ -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
; + } + + + + + + `); + }); + + it('should render without errors in StrictMode', async function () { + await testSSR(__filename, ` + import {SSRProvider, useSSRSafeId} from '../'; + + function Test() { + return
; + } + + + + + + + + `); + }); + + it('should render without errors in StrictMode with nested SSRProviders', async function () { + await testSSR(__filename, ` + import {SSRProvider, useSSRSafeId} from '../'; + + function Test() { + return
; + } + + + + + + + + + + + + `); + }); +}); diff --git a/packages/@react-aria/ssr/test/SSRProvider.test.js b/packages/@react-aria/ssr/test/SSRProvider.test.js index 85538d02853..5549d76e3f7 100644 --- a/packages/@react-aria/ssr/test/SSRProvider.test.js +++ b/packages/@react-aria/ssr/test/SSRProvider.test.js @@ -61,4 +61,42 @@ describe('SSRProvider', function () { ] `); }); + + 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-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( + + + + Loading}> + + + + + Loading}> + + + + + + ); + + let divs = tree.getAllByTestId('test'); + expect(divs[0].id).toBe('react-aria-1-1'); + expect(divs[1].id).toBe('react-aria-2-1'); + }); }); diff --git a/packages/@react-spectrum/table/src/TableView.tsx b/packages/@react-spectrum/table/src/TableView.tsx index 2e513e2ff3f..ccae6083522 100644 --- a/packages/@react-spectrum/table/src/TableView.tsx +++ b/packages/@react-spectrum/table/src/TableView.tsx @@ -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}> diff --git a/packages/dev/test-utils/src/testSSR.js b/packages/dev/test-utils/src/testSSR.js index 62e73806de8..72caebff3a6 100644 --- a/packages/dev/test-utils/src/testSSR.js +++ b/packages/dev/test-utils/src/testSSR.js @@ -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'; @@ -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, {element}); + act(() => ReactDOMClient.hydrateRoot(container, {element})); } else { ReactDOM.hydrate({element}, container); }