diff --git a/packages/react-dom/src/__tests__/ReactDOMServerIntegrationHooks-test.js b/packages/react-dom/src/__tests__/ReactDOMServerIntegrationHooks-test.js index 8c90d593a74a4..f3b30b1e86533 100644 --- a/packages/react-dom/src/__tests__/ReactDOMServerIntegrationHooks-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMServerIntegrationHooks-test.js @@ -1289,8 +1289,6 @@ describe('ReactDOMServerHooks', () => { .getAttribute('id'); expect(serverId).not.toBeNull(); - const childOneSpan = container.getElementsByTagName('span')[0]; - const root = ReactDOM.unstable_createRoot(container, {hydrate: true}); root.render(); expect(Scheduler).toHaveYielded([]); @@ -1306,25 +1304,15 @@ describe('ReactDOMServerHooks', () => { // State update should trigger the ID to update, which changes the props // of ChildWithID. This should cause ChildWithID to hydrate before Children - expect(Scheduler).toFlushAndYieldThrough( - __DEV__ - ? [ - 'Child with ID', - // Fallbacks are immediately committed in TestUtils version - // of act - // 'Child with ID', - // 'Child with ID', - 'Child One', - 'Child Two', - ] - : [ - 'Child with ID', - 'Child with ID', - 'Child with ID', - 'Child One', - 'Child Two', - ], - ); + expect(Scheduler).toFlushAndYieldThrough([ + 'Child with ID', + // Fallbacks are immediately committed in TestUtils version + // of act + // 'Child with ID', + // 'Child with ID', + 'Child One', + 'Child Two', + ]); expect(child1Ref.current).toBe(null); expect(childWithIDRef.current).toEqual( @@ -1344,7 +1332,9 @@ describe('ReactDOMServerHooks', () => { }); // Children hydrates after ChildWithID - expect(child1Ref.current).toBe(childOneSpan); + expect(child1Ref.current).toBe( + container.getElementsByTagName('span')[0], + ); Scheduler.unstable_flushAll(); @@ -1450,9 +1440,7 @@ describe('ReactDOMServerHooks', () => { ReactDOM.unstable_createRoot(container, {hydrate: true}).render( , ); - expect(() => - expect(() => Scheduler.unstable_flushAll()).toThrow(), - ).toErrorDev([ + expect(() => Scheduler.unstable_flushAll()).toErrorDev([ 'Warning: Expected server HTML to contain a matching
in
.', ]); }); @@ -1538,14 +1526,12 @@ describe('ReactDOMServerHooks', () => { ReactDOM.unstable_createRoot(container, {hydrate: true}).render( , ); - expect(() => - expect(() => Scheduler.unstable_flushAll()).toThrow(), - ).toErrorDev([ + expect(() => Scheduler.unstable_flushAll()).toErrorDev([ 'Warning: Expected server HTML to contain a matching
in
.', ]); }); - it('useOpaqueIdentifier throws when there is a hydration error and we are using ID as a string', async () => { + it('useOpaqueIdentifier warns when there is a hydration error and we are using ID as a string', async () => { function Child({appId}) { return
; } @@ -1562,12 +1548,7 @@ describe('ReactDOMServerHooks', () => { ReactDOM.unstable_createRoot(container, {hydrate: true}).render( , ); - expect(() => - expect(() => Scheduler.unstable_flushAll()).toThrow( - 'The object passed back from useOpaqueIdentifier is meant to be passed through to attributes only. ' + - 'Do not read the value directly.', - ), - ).toErrorDev( + expect(() => Scheduler.unstable_flushAll()).toErrorDev( [ 'Warning: The object passed back from useOpaqueIdentifier is meant to be passed through to attributes only. Do not read the value directly.', 'Warning: Did not expect server HTML to contain a in
.', @@ -1576,7 +1557,7 @@ describe('ReactDOMServerHooks', () => { ); }); - it('useOpaqueIdentifier throws when there is a hydration error and we are using ID as a string', async () => { + it('useOpaqueIdentifier warns when there is a hydration error and we are using ID as a string', async () => { function Child({appId}) { return
; } @@ -1593,12 +1574,7 @@ describe('ReactDOMServerHooks', () => { ReactDOM.unstable_createRoot(container, {hydrate: true}).render( , ); - expect(() => - expect(() => Scheduler.unstable_flushAll()).toThrow( - 'The object passed back from useOpaqueIdentifier is meant to be passed through to attributes only. ' + - 'Do not read the value directly.', - ), - ).toErrorDev( + expect(() => Scheduler.unstable_flushAll()).toErrorDev( [ 'Warning: The object passed back from useOpaqueIdentifier is meant to be passed through to attributes only. Do not read the value directly.', 'Warning: Did not expect server HTML to contain a in
.', @@ -1607,7 +1583,7 @@ describe('ReactDOMServerHooks', () => { ); }); - it('useOpaqueIdentifier throws if you try to use the result as a string in a child component', async () => { + it('useOpaqueIdentifier warns if you try to use the result as a string in a child component', async () => { function Child({appId}) { return
; } @@ -1623,12 +1599,7 @@ describe('ReactDOMServerHooks', () => { ReactDOM.unstable_createRoot(container, {hydrate: true}).render( , ); - expect(() => - expect(() => Scheduler.unstable_flushAll()).toThrow( - 'The object passed back from useOpaqueIdentifier is meant to be passed through to attributes only. ' + - 'Do not read the value directly.', - ), - ).toErrorDev( + expect(() => Scheduler.unstable_flushAll()).toErrorDev( [ 'Warning: The object passed back from useOpaqueIdentifier is meant to be passed through to attributes only. Do not read the value directly.', 'Warning: Did not expect server HTML to contain a
in
.', @@ -1637,7 +1608,7 @@ describe('ReactDOMServerHooks', () => { ); }); - it('useOpaqueIdentifier throws if you try to use the result as a string', async () => { + it('useOpaqueIdentifier warns if you try to use the result as a string', async () => { function App() { const id = useOpaqueIdentifier(); return
; @@ -1650,12 +1621,7 @@ describe('ReactDOMServerHooks', () => { ReactDOM.unstable_createRoot(container, {hydrate: true}).render( , ); - expect(() => - expect(() => Scheduler.unstable_flushAll()).toThrow( - 'The object passed back from useOpaqueIdentifier is meant to be passed through to attributes only. ' + - 'Do not read the value directly.', - ), - ).toErrorDev( + expect(() => Scheduler.unstable_flushAll()).toErrorDev( [ 'Warning: The object passed back from useOpaqueIdentifier is meant to be passed through to attributes only. Do not read the value directly.', 'Warning: Did not expect server HTML to contain a
in
.', @@ -1664,7 +1630,7 @@ describe('ReactDOMServerHooks', () => { ); }); - it('useOpaqueIdentifier throws if you try to use the result as a string in a child component wrapped in a Suspense', async () => { + it('useOpaqueIdentifier warns if you try to use the result as a string in a child component wrapped in a Suspense', async () => { function Child({appId}) { return
; } @@ -1686,27 +1652,13 @@ describe('ReactDOMServerHooks', () => { , ); - if (gate(flags => flags.new)) { - expect(() => Scheduler.unstable_flushAll()).toErrorDev([ - 'The object passed back from useOpaqueIdentifier is meant to be passed through to attributes only. ' + - 'Do not read the value directly.', - ]); - } else { - // In the old reconciler, the error isn't surfaced to the user. That - // part isn't important, as long as It warns. - expect(() => - expect(() => Scheduler.unstable_flushAll()).toThrow( - 'The object passed back from useOpaqueIdentifier is meant to be passed through to attributes only. ' + - 'Do not read the value directly.', - ), - ).toErrorDev([ - 'The object passed back from useOpaqueIdentifier is meant to be passed through to attributes only. ' + - 'Do not read the value directly.', - ]); - } + expect(() => Scheduler.unstable_flushAll()).toErrorDev([ + 'The object passed back from useOpaqueIdentifier is meant to be passed through to attributes only. ' + + 'Do not read the value directly.', + ]); }); - it('useOpaqueIdentifier throws if you try to add the result as a number in a child component wrapped in a Suspense', async () => { + it('useOpaqueIdentifier warns if you try to add the result as a number in a child component wrapped in a Suspense', async () => { function Child({appId}) { return
; } @@ -1730,24 +1682,10 @@ describe('ReactDOMServerHooks', () => { , ); - if (gate(flags => flags.new)) { - expect(() => Scheduler.unstable_flushAll()).toErrorDev([ - 'The object passed back from useOpaqueIdentifier is meant to be passed through to attributes only. ' + - 'Do not read the value directly.', - ]); - } else { - // In the old reconciler, the error isn't surfaced to the user. That - // part isn't important, as long as It warns. - expect(() => - expect(() => Scheduler.unstable_flushAll()).toThrow( - 'The object passed back from useOpaqueIdentifier is meant to be passed through to attributes only. ' + - 'Do not read the value directly.', - ), - ).toErrorDev([ - 'The object passed back from useOpaqueIdentifier is meant to be passed through to attributes only. ' + - 'Do not read the value directly.', - ]); - } + expect(() => Scheduler.unstable_flushAll()).toErrorDev([ + 'The object passed back from useOpaqueIdentifier is meant to be passed through to attributes only. ' + + 'Do not read the value directly.', + ]); }); it('useOpaqueIdentifier with two opaque identifiers on the same page', () => { diff --git a/packages/react-dom/src/client/ReactDOMRoot.js b/packages/react-dom/src/client/ReactDOMRoot.js index 3c29c2e86a66e..34a3223a0cd6e 100644 --- a/packages/react-dom/src/client/ReactDOMRoot.js +++ b/packages/react-dom/src/client/ReactDOMRoot.js @@ -9,9 +9,8 @@ import type {Container} from './ReactDOMHostConfig'; import type {RootTag} from 'react-reconciler/src/ReactRootTags'; -import type {ReactNodeList} from 'shared/ReactTypes'; +import type {MutableSource, ReactNodeList} from 'shared/ReactTypes'; import type {FiberRoot} from 'react-reconciler/src/ReactInternalTypes'; -import {findHostInstanceWithNoPortals} from 'react-reconciler/src/ReactFiberReconciler'; export type RootType = { render(children: ReactNodeList): void, @@ -30,6 +29,8 @@ export type RootOptions = { ... }; +import {findHostInstanceWithNoPortals} from 'react-reconciler/src/ReactFiberReconciler'; +import {registerMutableSourceForHydration} from 'react-reconciler/src/ReactMutableSource'; import { isContainerMarkedAsRoot, markContainerAsRoot, @@ -115,6 +116,13 @@ ReactDOMRoot.prototype.unmount = ReactDOMBlockingRoot.prototype.unmount = functi }); }; +ReactDOMRoot.prototype.registerMutableSourceForHydration = ReactDOMBlockingRoot.prototype.registerMutableSourceForHydration = function( + mutableSource: MutableSource, +): void { + const root = this._internalRoot; + registerMutableSourceForHydration(root, mutableSource); +}; + function createRootImpl( container: Container, tag: RootTag, diff --git a/packages/react-reconciler/src/ReactFiberBeginWork.new.js b/packages/react-reconciler/src/ReactFiberBeginWork.new.js index c7b9e24ceac6d..5459e297b5901 100644 --- a/packages/react-reconciler/src/ReactFiberBeginWork.new.js +++ b/packages/react-reconciler/src/ReactFiberBeginWork.new.js @@ -13,6 +13,7 @@ import type {LazyComponent as LazyComponentType} from 'react/src/ReactLazy'; import type {Fiber} from './ReactInternalTypes'; import type {FiberRoot} from './ReactInternalTypes'; import type {Lanes, Lane} from './ReactFiberLane'; +import type {MutableSource} from 'shared/ReactTypes'; import type { SuspenseState, SuspenseListRenderState, @@ -197,8 +198,12 @@ import { markSkippedUpdateLanes, getWorkInProgressRoot, pushRenderLanes, + getExecutionContext, + RetryAfterError, + NoContext, } from './ReactFiberWorkLoop.new'; import {unstable_wrap as Schedule_tracing_wrap} from 'scheduler/tracing'; +import {setWorkInProgressVersion} from './ReactMutableSource.new'; import {disableLogs, reenableLogs} from 'shared/ConsolePatchingDev'; @@ -1061,6 +1066,16 @@ function updateHostRoot(current, workInProgress, renderLanes) { // be any children to hydrate which is effectively the same thing as // not hydrating. + const mutableSourceEagerHydrationData = + root.mutableSourceEagerHydrationData; + for (let i = 0; i < mutableSourceEagerHydrationData.length; i += 2) { + const mutableSource = ((mutableSourceEagerHydrationData[ + i + ]: any): MutableSource); + const version = mutableSourceEagerHydrationData[i + 1]; + setWorkInProgressVersion(mutableSource, version); + } + const child = mountChildFibers( workInProgress, null, @@ -2263,6 +2278,14 @@ function updateDehydratedSuspenseComponent( // but after we've already committed once. warnIfHydrating(); + if ((getExecutionContext() & RetryAfterError) !== NoContext) { + return retrySuspenseComponentWithoutHydrating( + current, + workInProgress, + renderLanes, + ); + } + if ((workInProgress.mode & BlockingMode) === NoMode) { return retrySuspenseComponentWithoutHydrating( current, diff --git a/packages/react-reconciler/src/ReactFiberBeginWork.old.js b/packages/react-reconciler/src/ReactFiberBeginWork.old.js index 0a3a4e5a47317..f7179fe353d0a 100644 --- a/packages/react-reconciler/src/ReactFiberBeginWork.old.js +++ b/packages/react-reconciler/src/ReactFiberBeginWork.old.js @@ -13,6 +13,7 @@ import type {LazyComponent as LazyComponentType} from 'react/src/ReactLazy'; import type {Fiber} from './ReactInternalTypes'; import type {FiberRoot} from './ReactInternalTypes'; import type {ExpirationTime} from './ReactFiberExpirationTime.old'; +import type {MutableSource} from 'shared/ReactTypes'; import type { SuspenseState, SuspenseListRenderState, @@ -179,8 +180,12 @@ import { renderDidSuspendDelayIfPossible, markUnprocessedUpdateTime, getWorkInProgressRoot, + getExecutionContext, + RetryAfterError, + NoContext, } from './ReactFiberWorkLoop.old'; import {unstable_wrap as Schedule_tracing_wrap} from 'scheduler/tracing'; +import {setWorkInProgressVersion} from './ReactMutableSource.old'; import {disableLogs, reenableLogs} from 'shared/ConsolePatchingDev'; @@ -1038,6 +1043,16 @@ function updateHostRoot(current, workInProgress, renderExpirationTime) { // be any children to hydrate which is effectively the same thing as // not hydrating. + const mutableSourceEagerHydrationData = + root.mutableSourceEagerHydrationData; + for (let i = 0; i < mutableSourceEagerHydrationData.length; i += 2) { + const mutableSource = ((mutableSourceEagerHydrationData[ + i + ]: any): MutableSource); + const version = mutableSourceEagerHydrationData[i + 1]; + setWorkInProgressVersion(mutableSource, version); + } + const child = mountChildFibers( workInProgress, null, @@ -2239,6 +2254,14 @@ function updateDehydratedSuspenseComponent( // but after we've already committed once. warnIfHydrating(); + if ((getExecutionContext() & RetryAfterError) !== NoContext) { + return retrySuspenseComponentWithoutHydrating( + current, + workInProgress, + renderExpirationTime, + ); + } + if ((workInProgress.mode & BlockingMode) === NoMode) { return retrySuspenseComponentWithoutHydrating( current, diff --git a/packages/react-reconciler/src/ReactFiberRoot.new.js b/packages/react-reconciler/src/ReactFiberRoot.new.js index b3fc36d51724a..6ee88ea9241ee 100644 --- a/packages/react-reconciler/src/ReactFiberRoot.new.js +++ b/packages/react-reconciler/src/ReactFiberRoot.new.js @@ -41,9 +41,10 @@ function FiberRootNode(containerInfo, tag, hydrate) { this.pingedLanes = NoLanes; this.expiredLanes = NoLanes; this.mutableReadLanes = NoLanes; - this.finishedLanes = NoLanes; + this.mutableSourceEagerHydrationData = []; + if (enableSchedulerTracing) { this.interactionThreadID = unstable_getThreadID(); this.memoizedInteractions = new Set(); diff --git a/packages/react-reconciler/src/ReactFiberRoot.old.js b/packages/react-reconciler/src/ReactFiberRoot.old.js index 60339c86e8626..e972e076b6211 100644 --- a/packages/react-reconciler/src/ReactFiberRoot.old.js +++ b/packages/react-reconciler/src/ReactFiberRoot.old.js @@ -45,6 +45,7 @@ function FiberRootNode(containerInfo, tag, hydrate) { this.lastPingedTime = NoWork; this.lastExpiredTime = NoWork; this.mutableSourceLastPendingUpdateTime = NoWork; + this.mutableSourceEagerHydrationData = []; if (enableSchedulerTracing) { this.interactionThreadID = unstable_getThreadID(); diff --git a/packages/react-reconciler/src/ReactFiberWorkLoop.new.js b/packages/react-reconciler/src/ReactFiberWorkLoop.new.js index e2ff1032b8287..5c6c5bf719de0 100644 --- a/packages/react-reconciler/src/ReactFiberWorkLoop.new.js +++ b/packages/react-reconciler/src/ReactFiberWorkLoop.new.js @@ -61,6 +61,7 @@ import { warnsIfNotActing, beforeActiveInstanceBlur, afterActiveInstanceBlur, + clearContainer, } from './ReactFiberHostConfig'; import { @@ -210,13 +211,14 @@ const { type ExecutionContext = number; -const NoContext = /* */ 0b000000; -const BatchedContext = /* */ 0b000001; -const EventContext = /* */ 0b000010; -const DiscreteEventContext = /* */ 0b000100; -const LegacyUnbatchedContext = /* */ 0b001000; -const RenderContext = /* */ 0b010000; -const CommitContext = /* */ 0b100000; +export const NoContext = /* */ 0b0000000; +const BatchedContext = /* */ 0b0000001; +const EventContext = /* */ 0b0000010; +const DiscreteEventContext = /* */ 0b0000100; +const LegacyUnbatchedContext = /* */ 0b0001000; +const RenderContext = /* */ 0b0010000; +const CommitContext = /* */ 0b0100000; +export const RetryAfterError = /* */ 0b1000000; type RootExitStatus = 0 | 1 | 2 | 3 | 4 | 5; const RootIncomplete = 0; @@ -703,6 +705,15 @@ function performConcurrentWorkOnRoot(root, didTimeout) { prepareFreshStack(root, NoLanes); } else if (exitStatus !== RootIncomplete) { if (exitStatus === RootErrored) { + executionContext |= RetryAfterError; + + // If an error occurred during hydration, + // discard server response and fall back to client side render. + if (root.hydrate) { + root.hydrate = false; + clearContainer(root.containerInfo); + } + // If something threw an error, try rendering one more time. We'll render // synchronously to block concurrent data mutations, and we'll includes // all pending updates are included. If it still fails after the second @@ -953,6 +964,15 @@ function performSyncWorkOnRoot(root) { } if (root.tag !== LegacyRoot && exitStatus === RootErrored) { + executionContext |= RetryAfterError; + + // If an error occurred during hydration, + // discard server response and fall back to client side render. + if (root.hydrate) { + root.hydrate = false; + clearContainer(root.containerInfo); + } + // If something threw an error, try rendering one more time. We'll render // synchronously to block concurrent data mutations, and we'll includes // all pending updates are included. If it still fails after the second @@ -993,6 +1013,10 @@ export function flushRoot(root: FiberRoot, lanes: Lanes) { } } +export function getExecutionContext(): ExecutionContext { + return executionContext; +} + export function flushDiscreteUpdates() { // TODO: Should be able to flush inside batchedUpdates, but not inside `act`. // However, `act` uses `batchedUpdates`, so there's no way to distinguish diff --git a/packages/react-reconciler/src/ReactFiberWorkLoop.old.js b/packages/react-reconciler/src/ReactFiberWorkLoop.old.js index e76d0bf38b4f3..a6d20d79fad28 100644 --- a/packages/react-reconciler/src/ReactFiberWorkLoop.old.js +++ b/packages/react-reconciler/src/ReactFiberWorkLoop.old.js @@ -74,6 +74,7 @@ import { warnsIfNotActing, beforeActiveInstanceBlur, afterActiveInstanceBlur, + clearContainer, } from './ReactFiberHostConfig'; import { @@ -207,13 +208,14 @@ const { type ExecutionContext = number; -const NoContext = /* */ 0b000000; -const BatchedContext = /* */ 0b000001; -const EventContext = /* */ 0b000010; -const DiscreteEventContext = /* */ 0b000100; -const LegacyUnbatchedContext = /* */ 0b001000; -const RenderContext = /* */ 0b010000; -const CommitContext = /* */ 0b100000; +export const NoContext = /* */ 0b0000000; +const BatchedContext = /* */ 0b0000001; +const EventContext = /* */ 0b0000010; +const DiscreteEventContext = /* */ 0b0000100; +const LegacyUnbatchedContext = /* */ 0b0001000; +const RenderContext = /* */ 0b0010000; +const CommitContext = /* */ 0b0100000; +export const RetryAfterError = /* */ 0b1000000; type RootExitStatus = 0 | 1 | 2 | 3 | 4 | 5; const RootIncomplete = 0; @@ -728,6 +730,15 @@ function performConcurrentWorkOnRoot(root, didTimeout) { if (exitStatus !== RootIncomplete) { if (exitStatus === RootErrored) { + executionContext |= RetryAfterError; + + // If an error occurred during hydration, + // discard server response and fall back to client side render. + if (root.hydrate) { + root.hydrate = false; + clearContainer(root.containerInfo); + } + // If something threw an error, try rendering one more time. We'll // render synchronously to block concurrent data mutations, and we'll // render at Idle (or lower) so that all pending updates are included. @@ -1011,6 +1022,15 @@ function performSyncWorkOnRoot(root) { let exitStatus = renderRootSync(root, expirationTime); if (root.tag !== LegacyRoot && exitStatus === RootErrored) { + executionContext |= RetryAfterError; + + // If an error occurred during hydration, + // discard server response and fall back to client side render. + if (root.hydrate) { + root.hydrate = false; + clearContainer(root.containerInfo); + } + // If something threw an error, try rendering one more time. We'll // render synchronously to block concurrent data mutations, and we'll // render at Idle (or lower) so that all pending updates are included. @@ -1051,6 +1071,10 @@ export function flushRoot(root: FiberRoot, expirationTime: ExpirationTime) { } } +export function getExecutionContext(): ExecutionContext { + return executionContext; +} + export function flushDiscreteUpdates() { // TODO: Should be able to flush inside batchedUpdates, but not inside `act`. // However, `act` uses `batchedUpdates`, so there's no way to distinguish diff --git a/packages/react-reconciler/src/ReactInternalTypes.js b/packages/react-reconciler/src/ReactInternalTypes.js index 2b12735c8c58f..bd929f8f0d8d9 100644 --- a/packages/react-reconciler/src/ReactInternalTypes.js +++ b/packages/react-reconciler/src/ReactInternalTypes.js @@ -16,6 +16,7 @@ import type { ReactContext, MutableSourceSubscribeFn, MutableSourceGetSnapshotFn, + MutableSourceVersion, MutableSource, } from 'shared/ReactTypes'; import type {SuspenseInstance} from './ReactFiberHostConfig'; @@ -248,6 +249,11 @@ type BaseFiberRootProperties = {| // when external, mutable sources are read from during render. mutableSourceLastPendingUpdateTime: ExpirationTime, + // Used by useMutableSource hook to avoid tearing during hydrtaion. + mutableSourceEagerHydrationData: Array< + MutableSource | MutableSourceVersion, + >, + // Only used by new reconciler // Represents the next task that the root should work on, or the current one diff --git a/packages/react-reconciler/src/ReactMutableSource.js b/packages/react-reconciler/src/ReactMutableSource.js new file mode 100644 index 0000000000000..0b48c6183c3fb --- /dev/null +++ b/packages/react-reconciler/src/ReactMutableSource.js @@ -0,0 +1,24 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + */ + +import {enableNewReconciler} from 'shared/ReactFeatureFlags'; + +// The entry file imports either the old or new version of mutable source. +// This is necessary since ReactDOMRoot imports this module directly. +// Note that it's not possible to export all of the API methods, +// as the new and old implementations fork slightly (due to the lanes refactor). +// It's only necessary to export the subset of the API required by ReactDOMRoot. + +import {registerMutableSourceForHydration as registerMutableSourceForHydration_old} from './ReactMutableSource.old'; + +import {registerMutableSourceForHydration as registerMutableSourceForHydration_new} from './ReactMutableSource.new'; + +export const registerMutableSourceForHydration = enableNewReconciler + ? registerMutableSourceForHydration_new + : registerMutableSourceForHydration_old; diff --git a/packages/react-reconciler/src/ReactMutableSource.new.js b/packages/react-reconciler/src/ReactMutableSource.new.js index be7bb4dfdf594..313d3d18fa436 100644 --- a/packages/react-reconciler/src/ReactMutableSource.new.js +++ b/packages/react-reconciler/src/ReactMutableSource.new.js @@ -8,6 +8,7 @@ */ import type {MutableSource, MutableSourceVersion} from 'shared/ReactTypes'; +import type {FiberRoot} from './ReactInternalTypes'; import {isPrimaryRenderer} from './ReactFiberHostConfig'; @@ -95,3 +96,19 @@ export function warnAboutMultipleRenderersDEV( } } } + +// Eager reads the version of a mutable source and stores it on the root. +// This ensures that the version used for server rendering matches the one +// that is eventually read during hydration. +// If they don't match there's a potential tear and a full deopt render is required. +export function registerMutableSourceForHydration( + root: FiberRoot, + mutableSource: MutableSource, +): void { + const getVersion = mutableSource._getVersion; + const version = getVersion(mutableSource._source); + + // TODO Clear this data once all pending hydration work is finished. + // Retaining it forever may interfere with GC. + root.mutableSourceEagerHydrationData.push(mutableSource, version); +} diff --git a/packages/react-reconciler/src/ReactMutableSource.old.js b/packages/react-reconciler/src/ReactMutableSource.old.js index f0658bea5592a..300b6dc322a00 100644 --- a/packages/react-reconciler/src/ReactMutableSource.old.js +++ b/packages/react-reconciler/src/ReactMutableSource.old.js @@ -126,3 +126,19 @@ export function warnAboutMultipleRenderersDEV( } } } + +// Eager reads the version of a mutable source and stores it on the root. +// This ensures that the version used for server rendering matches the one +// that is eventually read during hydration. +// If they don't match there's a potential tear and a full deopt render is required. +export function registerMutableSourceForHydration( + root: FiberRoot, + mutableSource: MutableSource, +): void { + const getVersion = mutableSource._getVersion; + const version = getVersion(mutableSource._source); + + // TODO Clear this data once all pending hydration work is finished. + // Retaining it forever may interfere with GC. + root.mutableSourceEagerHydrationData.push(mutableSource, version); +} diff --git a/packages/react-reconciler/src/__tests__/useMutableSourceHydration-test.js b/packages/react-reconciler/src/__tests__/useMutableSourceHydration-test.js new file mode 100644 index 0000000000000..229282ad4434c --- /dev/null +++ b/packages/react-reconciler/src/__tests__/useMutableSourceHydration-test.js @@ -0,0 +1,376 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @emails react-core + */ + +'use strict'; + +let React; +let ReactDOM; +let ReactDOMServer; +let Scheduler; +let act; +let useMutableSource; + +describe('useMutableSourceHydration', () => { + beforeEach(() => { + jest.resetModules(); + + React = require('react'); + ReactDOM = require('react-dom'); + ReactDOMServer = require('react-dom/server'); + Scheduler = require('scheduler'); + + useMutableSource = React.useMutableSource; + act = require('react-dom/test-utils').act; + }); + + const defaultGetSnapshot = source => source.value; + const defaultSubscribe = (source, callback) => source.subscribe(callback); + + function createComplexSource(initialValueA, initialValueB) { + const callbacksA = []; + const callbacksB = []; + let revision = 0; + let valueA = initialValueA; + let valueB = initialValueB; + + const subscribeHelper = (callbacks, callback) => { + if (callbacks.indexOf(callback) < 0) { + callbacks.push(callback); + } + return () => { + const index = callbacks.indexOf(callback); + if (index >= 0) { + callbacks.splice(index, 1); + } + }; + }; + + return { + subscribeA(callback) { + return subscribeHelper(callbacksA, callback); + }, + subscribeB(callback) { + return subscribeHelper(callbacksB, callback); + }, + + get listenerCountA() { + return callbacksA.length; + }, + get listenerCountB() { + return callbacksB.length; + }, + + set valueA(newValue) { + revision++; + valueA = newValue; + callbacksA.forEach(callback => callback()); + }, + get valueA() { + return valueA; + }, + + set valueB(newValue) { + revision++; + valueB = newValue; + callbacksB.forEach(callback => callback()); + }, + get valueB() { + return valueB; + }, + + get version() { + return revision; + }, + }; + } + + function createSource(initialValue) { + const callbacks = []; + let revision = 0; + let value = initialValue; + return { + subscribe(callback) { + if (callbacks.indexOf(callback) < 0) { + callbacks.push(callback); + } + return () => { + const index = callbacks.indexOf(callback); + if (index >= 0) { + callbacks.splice(index, 1); + } + }; + }, + get listenerCount() { + return callbacks.length; + }, + set value(newValue) { + revision++; + value = newValue; + callbacks.forEach(callback => callback()); + }, + get value() { + return value; + }, + get version() { + return revision; + }, + }; + } + + function createMutableSource(source) { + return React.createMutableSource(source, param => param.version); + } + + function Component({getSnapshot, label, mutableSource, subscribe}) { + const snapshot = useMutableSource(mutableSource, getSnapshot, subscribe); + Scheduler.unstable_yieldValue(`${label}:${snapshot}`); + return
{`${label}:${snapshot}`}
; + } + + // @gate experimental + it('should render and hydrate', () => { + const source = createSource('one'); + const mutableSource = createMutableSource(source); + + function TestComponent() { + return ( + + ); + } + + const container = document.createElement('div'); + document.body.appendChild(container); + + const htmlString = ReactDOMServer.renderToString(); + container.innerHTML = htmlString; + expect(Scheduler).toHaveYielded(['only:one']); + expect(source.listenerCount).toBe(0); + + const root = ReactDOM.unstable_createRoot(container, {hydrate: true}); + act(() => { + root.registerMutableSourceForHydration(mutableSource); + root.render(); + }); + expect(Scheduler).toHaveYielded(['only:one']); + expect(source.listenerCount).toBe(1); + }); + + // @gate experimental + it('should detect a tear before hydrating a component', () => { + const source = createSource('one'); + const mutableSource = createMutableSource(source); + + function TestComponent() { + return ( + + ); + } + + const container = document.createElement('div'); + document.body.appendChild(container); + + const htmlString = ReactDOMServer.renderToString(); + container.innerHTML = htmlString; + expect(Scheduler).toHaveYielded(['only:one']); + expect(source.listenerCount).toBe(0); + + const root = ReactDOM.unstable_createRoot(container, {hydrate: true}); + expect(() => { + act(() => { + root.registerMutableSourceForHydration(mutableSource); + root.render(); + + source.value = 'two'; + }); + }).toErrorDev( + 'Warning: Did not expect server HTML to contain a
in
.', + {withoutStack: true}, + ); + expect(Scheduler).toHaveYielded(['only:two']); + expect(source.listenerCount).toBe(1); + }); + + // @gate experimental + it('should detect a tear between hydrating components', () => { + const source = createSource('one'); + const mutableSource = createMutableSource(source); + + function TestComponent() { + return ( + <> + + + + ); + } + + const container = document.createElement('div'); + document.body.appendChild(container); + + const htmlString = ReactDOMServer.renderToString(); + container.innerHTML = htmlString; + expect(Scheduler).toHaveYielded(['a:one', 'b:one']); + expect(source.listenerCount).toBe(0); + + const root = ReactDOM.unstable_createRoot(container, {hydrate: true}); + expect(() => { + act(() => { + root.registerMutableSourceForHydration(mutableSource); + root.render(); + expect(Scheduler).toFlushAndYieldThrough(['a:one']); + source.value = 'two'; + }); + }).toErrorDev( + 'Warning: Did not expect server HTML to contain a
in
.', + {withoutStack: true}, + ); + expect(Scheduler).toHaveYielded(['a:two', 'b:two']); + expect(source.listenerCount).toBe(2); + }); + + // @gate experimental + it('should detect a tear between hydrating components reading from different parts of a source', () => { + const source = createComplexSource('a:one', 'b:one'); + const mutableSource = createMutableSource(source); + + // Subscribe to part of the store. + const getSnapshotA = s => s.valueA; + const subscribeA = (s, callback) => s.subscribeA(callback); + const getSnapshotB = s => s.valueB; + const subscribeB = (s, callback) => s.subscribeB(callback); + + const container = document.createElement('div'); + document.body.appendChild(container); + + const htmlString = ReactDOMServer.renderToString( + <> + + + , + ); + container.innerHTML = htmlString; + expect(Scheduler).toHaveYielded(['0:a:one', '1:b:one']); + + const root = ReactDOM.unstable_createRoot(container, {hydrate: true}); + expect(() => { + act(() => { + root.registerMutableSourceForHydration(mutableSource); + root.render( + <> + + + , + ); + expect(Scheduler).toFlushAndYieldThrough(['0:a:one']); + source.valueB = 'b:two'; + }); + }).toErrorDev( + 'Warning: Did not expect server HTML to contain a
in
.', + {withoutStack: true}, + ); + expect(Scheduler).toHaveYielded(['0:a:one', '1:b:two']); + }); + + // @gate experimental + it('should detect a tear during a higher priority interruption', () => { + const source = createSource('one'); + const mutableSource = createMutableSource(source); + + function Unrelated({flag}) { + Scheduler.unstable_yieldValue(flag); + return flag; + } + + function TestComponent({flag}) { + return ( + <> + + + + ); + } + + const container = document.createElement('div'); + document.body.appendChild(container); + + const htmlString = ReactDOMServer.renderToString( + , + ); + container.innerHTML = htmlString; + expect(Scheduler).toHaveYielded([1, 'a:one']); + expect(source.listenerCount).toBe(0); + + const root = ReactDOM.unstable_createRoot(container, {hydrate: true}); + expect(() => { + act(() => { + root.registerMutableSourceForHydration(mutableSource); + root.render(); + expect(Scheduler).toFlushAndYieldThrough([1]); + + // Render an update which will be higher priority than the hydration. + Scheduler.unstable_runWithPriority( + Scheduler.unstable_UserBlockingPriority, + () => root.render(), + ); + expect(Scheduler).toFlushAndYieldThrough([2]); + + source.value = 'two'; + }); + }).toErrorDev( + 'Warning: Text content did not match. Server: "1" Client: "2"', + ); + expect(Scheduler).toHaveYielded([2, 'a:two']); + expect(source.listenerCount).toBe(1); + }); +}); diff --git a/scripts/jest/setupHostConfigs.js b/scripts/jest/setupHostConfigs.js index 9a4af65cf0c59..7f525dee4891e 100644 --- a/scripts/jest/setupHostConfigs.js +++ b/scripts/jest/setupHostConfigs.js @@ -10,6 +10,14 @@ jest.mock('react-reconciler/src/ReactFiberReconciler', () => { ); }); +jest.mock('react-reconciler/src/ReactMutableSource', () => { + return require.requireActual( + __VARIANT__ + ? 'react-reconciler/src/ReactMutableSource.new' + : 'react-reconciler/src/ReactMutableSource.old' + ); +}); + // When testing the custom renderer code path through `react-reconciler`, // turn the export into a function, and use the argument as host config. const shimHostConfigPath = 'react-reconciler/src/ReactFiberHostConfig'; diff --git a/scripts/rollup/forks.js b/scripts/rollup/forks.js index b0319333ea80d..93e12fe04dbd8 100644 --- a/scripts/rollup/forks.js +++ b/scripts/rollup/forks.js @@ -280,6 +280,26 @@ const forks = Object.freeze({ return 'react-reconciler/src/ReactFiberReconciler.old.js'; }, + 'react-reconciler/src/ReactMutableSource': ( + bundleType, + entry, + dependencies, + moduleType, + bundle + ) => { + if (bundle.enableNewReconciler) { + switch (bundleType) { + case FB_WWW_DEV: + case FB_WWW_PROD: + case FB_WWW_PROFILING: + // Use the forked version of the reconciler + return 'react-reconciler/src/ReactMutableSource.new.js'; + } + } + // Otherwise, use the non-forked version. + return 'react-reconciler/src/ReactMutableSource.old.js'; + }, + 'react-reconciler/src/ReactFiberHotReloading': ( bundleType, entry,