diff --git a/packages/react-dom/src/__tests__/ReactDOMServerIntegrationHooks-test.js b/packages/react-dom/src/__tests__/ReactDOMServerIntegrationHooks-test.js index c69ea907210..ea5dfc1be76 100644 --- a/packages/react-dom/src/__tests__/ReactDOMServerIntegrationHooks-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMServerIntegrationHooks-test.js @@ -1445,8 +1445,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([]); @@ -1462,25 +1460,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( @@ -1500,7 +1488,9 @@ describe('ReactDOMServerHooks', () => { }); // Children hydrates after ChildWithID - expect(child1Ref.current).toBe(childOneSpan); + expect(child1Ref.current).toBe( + container.getElementsByTagName('span')[0], + ); Scheduler.unstable_flushAll(); @@ -1606,9 +1596,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
.', ]); }); @@ -1694,14 +1682,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
; } @@ -1718,12 +1704,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
.', @@ -1732,7 +1713,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
; } @@ -1749,12 +1730,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
.', @@ -1763,7 +1739,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
; } @@ -1779,12 +1755,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
.', @@ -1793,7 +1764,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
; @@ -1806,12 +1777,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
.', @@ -1820,7 +1786,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
; } @@ -1842,16 +1808,14 @@ describe('ReactDOMServerHooks', () => { , ); - if ( - gate(flags => flags.new && flags.deferRenderPhaseUpdateToNextBatch) - ) { + 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. + // This error isn't surfaced to the user; only the warning is. + // The error is just the mechanism that restarts the render. expect(() => expect(() => Scheduler.unstable_flushAll()).toThrow( 'The object passed back from useOpaqueIdentifier is meant to be passed through to attributes only. ' + @@ -1864,7 +1828,7 @@ describe('ReactDOMServerHooks', () => { } }); - 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
; } @@ -1888,16 +1852,14 @@ describe('ReactDOMServerHooks', () => { , ); - if ( - gate(flags => flags.new && flags.deferRenderPhaseUpdateToNextBatch) - ) { + 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. + // This error isn't surfaced to the user; only the warning is. + // The error is just the mechanism that restarts the render. expect(() => expect(() => Scheduler.unstable_flushAll()).toThrow( 'The object passed back from useOpaqueIdentifier is meant to be passed through to attributes only. ' + diff --git a/packages/react-dom/src/client/ReactDOMRoot.js b/packages/react-dom/src/client/ReactDOMRoot.js index 3c29c2e86a6..549f7c3e600 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, @@ -25,6 +24,7 @@ export type RootOptions = { hydrationOptions?: { onHydrated?: (suspenseNode: Comment) => void, onDeleted?: (suspenseNode: Comment) => void, + mutableSources?: Array>, ... }, ... @@ -47,6 +47,8 @@ import {ensureListeningTo} from './ReactDOMComponent'; import { createContainer, updateContainer, + findHostInstanceWithNoPortals, + registerMutableSourceForHydration, } from 'react-reconciler/src/ReactFiberReconciler'; import invariant from 'shared/invariant'; import { @@ -124,6 +126,11 @@ function createRootImpl( const hydrate = options != null && options.hydrate === true; const hydrationCallbacks = (options != null && options.hydrationOptions) || null; + const mutableSources = + (options != null && + options.hydrationOptions != null && + options.hydrationOptions.mutableSources) || + null; const root = createContainer(container, tag, hydrate, hydrationCallbacks); markContainerAsRoot(root.current, container); const containerNodeType = container.nodeType; @@ -143,6 +150,14 @@ function createRootImpl( ) { ensureListeningTo(container, 'onMouseEnter'); } + + if (mutableSources) { + for (let i = 0; i < mutableSources.length; i++) { + const mutableSource = mutableSources[i]; + registerMutableSourceForHydration(root, mutableSource); + } + } + return root; } diff --git a/packages/react-reconciler/src/ReactFiberBeginWork.new.js b/packages/react-reconciler/src/ReactFiberBeginWork.new.js index 76838eda06e..38cbec836fe 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, @@ -126,6 +127,7 @@ import { isSuspenseInstancePending, isSuspenseInstanceFallback, registerSuspenseInstanceRetry, + supportsHydration, } from './ReactFiberHostConfig'; import type {SuspenseInstance} from './ReactFiberHostConfig'; import {shouldSuspend} from './ReactFiberReconciler'; @@ -193,8 +195,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'; @@ -1071,6 +1077,20 @@ function updateHostRoot(current, workInProgress, renderLanes) { // be any children to hydrate which is effectively the same thing as // not hydrating. + if (supportsHydration) { + const mutableSourceEagerHydrationData = + root.mutableSourceEagerHydrationData; + if (mutableSourceEagerHydrationData != null) { + 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, @@ -2253,6 +2273,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 0a3a4e5a473..2d2916ac777 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, @@ -114,6 +115,7 @@ import { isSuspenseInstancePending, isSuspenseInstanceFallback, registerSuspenseInstanceRetry, + supportsHydration, } from './ReactFiberHostConfig'; import type {SuspenseInstance} from './ReactFiberHostConfig'; import {shouldSuspend} from './ReactFiberReconciler'; @@ -179,8 +181,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 +1044,20 @@ function updateHostRoot(current, workInProgress, renderExpirationTime) { // be any children to hydrate which is effectively the same thing as // not hydrating. + if (supportsHydration) { + const mutableSourceEagerHydrationData = + root.mutableSourceEagerHydrationData; + if (mutableSourceEagerHydrationData != null) { + 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 +2259,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/ReactFiberReconciler.js b/packages/react-reconciler/src/ReactFiberReconciler.js index 78992d9d783..6cc795b2ed6 100644 --- a/packages/react-reconciler/src/ReactFiberReconciler.js +++ b/packages/react-reconciler/src/ReactFiberReconciler.js @@ -49,6 +49,7 @@ import { findBoundingRects as findBoundingRects_old, focusWithin as focusWithin_old, observeVisibleRects as observeVisibleRects_old, + registerMutableSourceForHydration as registerMutableSourceForHydration_old, } from './ReactFiberReconciler.old'; import { @@ -86,6 +87,7 @@ import { findBoundingRects as findBoundingRects_new, focusWithin as focusWithin_new, observeVisibleRects as observeVisibleRects_new, + registerMutableSourceForHydration as registerMutableSourceForHydration_new, } from './ReactFiberReconciler.new'; export const createContainer = enableNewReconciler @@ -186,3 +188,7 @@ export const focusWithin = enableNewReconciler export const observeVisibleRects = enableNewReconciler ? observeVisibleRects_new : observeVisibleRects_old; + +export const registerMutableSourceForHydration = enableNewReconciler + ? registerMutableSourceForHydration_new + : registerMutableSourceForHydration_old; diff --git a/packages/react-reconciler/src/ReactFiberReconciler.new.js b/packages/react-reconciler/src/ReactFiberReconciler.new.js index eb3eb6a7602..3d508dec78a 100644 --- a/packages/react-reconciler/src/ReactFiberReconciler.new.js +++ b/packages/react-reconciler/src/ReactFiberReconciler.new.js @@ -88,6 +88,7 @@ import { findHostInstancesForRefresh, } from './ReactFiberHotReloading.new'; +export {registerMutableSourceForHydration} from './ReactMutableSource.new'; export {createPortal} from './ReactPortal'; export { createComponentSelector, diff --git a/packages/react-reconciler/src/ReactFiberReconciler.old.js b/packages/react-reconciler/src/ReactFiberReconciler.old.js index 8646598564c..68d3fbd206c 100644 --- a/packages/react-reconciler/src/ReactFiberReconciler.old.js +++ b/packages/react-reconciler/src/ReactFiberReconciler.old.js @@ -85,6 +85,7 @@ import { findHostInstancesForRefresh, } from './ReactFiberHotReloading.old'; +export {registerMutableSourceForHydration} from './ReactMutableSource.old'; export {createPortal} from './ReactPortal'; export { createComponentSelector, diff --git a/packages/react-reconciler/src/ReactFiberRoot.new.js b/packages/react-reconciler/src/ReactFiberRoot.new.js index 96f795cbdf2..d1e481414b3 100644 --- a/packages/react-reconciler/src/ReactFiberRoot.new.js +++ b/packages/react-reconciler/src/ReactFiberRoot.new.js @@ -10,7 +10,7 @@ import type {FiberRoot, SuspenseHydrationCallbacks} from './ReactInternalTypes'; import type {RootTag} from './ReactRootTags'; -import {noTimeout} from './ReactFiberHostConfig'; +import {noTimeout, supportsHydration} from './ReactFiberHostConfig'; import {createHostRootFiber} from './ReactFiber.new'; import { NoLanes, @@ -46,12 +46,15 @@ function FiberRootNode(containerInfo, tag, hydrate) { this.pingedLanes = NoLanes; this.expiredLanes = NoLanes; this.mutableReadLanes = NoLanes; - this.finishedLanes = NoLanes; this.entangledLanes = NoLanes; this.entanglements = createLaneMap(NoLanes); + if (supportsHydration) { + this.mutableSourceEagerHydrationData = null; + } + 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 72d03a59692..fab9d3def65 100644 --- a/packages/react-reconciler/src/ReactFiberRoot.old.js +++ b/packages/react-reconciler/src/ReactFiberRoot.old.js @@ -11,7 +11,7 @@ import type {FiberRoot, SuspenseHydrationCallbacks} from './ReactInternalTypes'; import type {ExpirationTime} from './ReactFiberExpirationTime.old'; import type {RootTag} from './ReactRootTags'; -import {noTimeout} from './ReactFiberHostConfig'; +import {noTimeout, supportsHydration} from './ReactFiberHostConfig'; import {createHostRootFiber} from './ReactFiber.old'; import {NoWork} from './ReactFiberExpirationTime.old'; import { @@ -46,6 +46,10 @@ function FiberRootNode(containerInfo, tag, hydrate) { this.lastExpiredTime = NoWork; this.mutableSourceLastPendingUpdateTime = NoWork; + if (supportsHydration) { + this.mutableSourceEagerHydrationData = null; + } + if (enableSchedulerTracing) { this.interactionThreadID = unstable_getThreadID(); this.memoizedInteractions = new Set(); diff --git a/packages/react-reconciler/src/ReactFiberWorkLoop.new.js b/packages/react-reconciler/src/ReactFiberWorkLoop.new.js index 53ac747aa46..312b74f75b2 100644 --- a/packages/react-reconciler/src/ReactFiberWorkLoop.new.js +++ b/packages/react-reconciler/src/ReactFiberWorkLoop.new.js @@ -62,6 +62,7 @@ import { warnsIfNotActing, beforeActiveInstanceBlur, afterActiveInstanceBlur, + clearContainer, } from './ReactFiberHostConfig'; import { @@ -215,13 +216,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; @@ -724,6 +726,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 @@ -976,6 +987,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 @@ -1016,6 +1036,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 b984a3fa66b..f8dd5185f15 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 54b3825fa3e..b4374af8e28 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'; @@ -247,6 +248,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, + > | null, + // 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.new.js b/packages/react-reconciler/src/ReactMutableSource.new.js index dd21b95781c..61809d33f80 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'; @@ -85,3 +86,23 @@ 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. + if (root.mutableSourceEagerHydrationData == null) { + root.mutableSourceEagerHydrationData = [mutableSource, version]; + } else { + root.mutableSourceEagerHydrationData.push(mutableSource, version); + } +} diff --git a/packages/react-reconciler/src/ReactMutableSource.old.js b/packages/react-reconciler/src/ReactMutableSource.old.js index 6d88953128e..09af3f760f0 100644 --- a/packages/react-reconciler/src/ReactMutableSource.old.js +++ b/packages/react-reconciler/src/ReactMutableSource.old.js @@ -116,3 +116,23 @@ 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. + if (root.mutableSourceEagerHydrationData == null) { + root.mutableSourceEagerHydrationData = [mutableSource, version]; + } else { + 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 00000000000..e0563e04350 --- /dev/null +++ b/packages/react-reconciler/src/__tests__/useMutableSourceHydration-test.js @@ -0,0 +1,396 @@ +/** + * 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, + hydrationOptions: { + mutableSources: [mutableSource], + }, + }); + act(() => { + 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, + hydrationOptions: { + mutableSources: [mutableSource], + }, + }); + expect(() => { + act(() => { + 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, + hydrationOptions: { + mutableSources: [mutableSource], + }, + }); + expect(() => { + act(() => { + 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, + hydrationOptions: { + mutableSources: [mutableSource], + }, + }); + expect(() => { + act(() => { + 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, + hydrationOptions: { + mutableSources: [mutableSource], + }, + }); + expect(() => { + act(() => { + 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); + }); +});