diff --git a/packages/react-dom/src/__tests__/ReactDOMFizzShellHydration-test.js b/packages/react-dom/src/__tests__/ReactDOMFizzShellHydration-test.js index f3ff64daee..3584715f91 100644 --- a/packages/react-dom/src/__tests__/ReactDOMFizzShellHydration-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMFizzShellHydration-test.js @@ -9,6 +9,7 @@ let JSDOM; let React; +let startTransition; let ReactDOMClient; let Scheduler; let clientAct; @@ -33,6 +34,8 @@ describe('ReactDOMFizzShellHydration', () => { ReactDOMFizzServer = require('react-dom/server'); Stream = require('stream'); + startTransition = React.startTransition; + textCache = new Map(); // Test Environment @@ -214,7 +217,36 @@ describe('ReactDOMFizzShellHydration', () => { expect(container.textContent).toBe('Shell'); }); - test('updating the root before the shell hydrates forces a client render', async () => { + test( + 'updating the root at lower priority than initial hydration does not ' + + 'force a client render', + async () => { + function App() { + return ; + } + + // Server render + await resolveText('Initial'); + await serverAct(async () => { + const {pipe} = ReactDOMFizzServer.renderToPipeableStream(); + pipe(writable); + }); + expect(Scheduler).toHaveYielded(['Initial']); + + await clientAct(async () => { + const root = ReactDOMClient.hydrateRoot(container, ); + // This has lower priority than the initial hydration, so the update + // won't be processed until after hydration finishes. + startTransition(() => { + root.render(); + }); + }); + expect(Scheduler).toHaveYielded(['Initial', 'Updated']); + expect(container.textContent).toBe('Updated'); + }, + ); + + test('updating the root while the shell is suspended forces a client render', async () => { function App() { return ; } @@ -245,9 +277,9 @@ describe('ReactDOMFizzShellHydration', () => { root.render(); }); expect(Scheduler).toHaveYielded([ + 'New screen', 'This root received an early update, before anything was able ' + 'hydrate. Switched the entire root to client rendering.', - 'New screen', ]); expect(container.textContent).toBe('New screen'); }); diff --git a/packages/react-reconciler/src/ReactFiberBeginWork.new.js b/packages/react-reconciler/src/ReactFiberBeginWork.new.js index 4feff7d260..7dc29bd2b0 100644 --- a/packages/react-reconciler/src/ReactFiberBeginWork.new.js +++ b/packages/react-reconciler/src/ReactFiberBeginWork.new.js @@ -7,7 +7,11 @@ * @flow */ -import type {ReactProviderType, ReactContext} from 'shared/ReactTypes'; +import type { + ReactProviderType, + ReactContext, + ReactNodeList, +} from 'shared/ReactTypes'; import type {LazyComponent as LazyComponentType} from 'react/src/ReactLazy'; import type {Fiber, FiberRoot} from './ReactInternalTypes'; import type {TypeOfMode} from './ReactTypeOfMode'; @@ -29,6 +33,7 @@ import type { SpawnedCachePool, } from './ReactFiberCacheComponent.new'; import type {UpdateQueue} from './ReactUpdateQueue.new'; +import type {RootState} from './ReactFiberRoot.new'; import { enableSuspenseAvoidThisFallback, enableCPUSuspense, @@ -223,7 +228,6 @@ import { createOffscreenHostContainerFiber, isSimpleFunctionComponent, } from './ReactFiber.new'; -import {isRootDehydrated} from './ReactFiberShellHydration'; import { retryDehydratedSuspenseBoundary, scheduleUpdateOnFiber, @@ -1312,7 +1316,7 @@ function pushHostRootContext(workInProgress) { function updateHostRoot(current, workInProgress, renderLanes) { pushHostRootContext(workInProgress); - const updateQueue = workInProgress.updateQueue; + const updateQueue: UpdateQueue = (workInProgress.updateQueue: any); if (current === null || updateQueue === null) { throw new Error( @@ -1327,7 +1331,7 @@ function updateHostRoot(current, workInProgress, renderLanes) { const prevChildren = prevState.element; cloneUpdateQueue(current, workInProgress); processUpdateQueue(workInProgress, nextProps, null, renderLanes); - const nextState = workInProgress.memoizedState; + const nextState: RootState = workInProgress.memoizedState; const root: FiberRoot = workInProgress.stateNode; @@ -1342,64 +1346,130 @@ function updateHostRoot(current, workInProgress, renderLanes) { } if (enableTransitionTracing) { + // FIXME: Slipped past code review. This is not a safe mutation: + // workInProgress.memoizedState is a shared object. Need to fix before + // rolling out the Transition Tracing experiment. workInProgress.memoizedState.transitions = getWorkInProgressTransitions(); } // Caution: React DevTools currently depends on this property // being called "element". const nextChildren = nextState.element; - if (nextChildren === prevChildren) { - resetHydrationState(); - return bailoutOnAlreadyFinishedWork(current, workInProgress, renderLanes); - } - if (isRootDehydrated(root) && enterHydrationState(workInProgress)) { - // If we don't have any current children this might be the first pass. - // We always try to hydrate. If this isn't a hydration pass there won't - // 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); + if (supportsHydration && prevState.isDehydrated) { + // This is a hydration root whose shell has not yet hydrated. We should + // attempt to hydrate. + if (workInProgress.flags & ForceClientRender) { + // Something errored during a previous attempt to hydrate the shell, so we + // forced a client render. + const recoverableError = new Error( + 'There was an error while hydrating. Because the error happened outside ' + + 'of a Suspense boundary, the entire root will switch to ' + + 'client rendering.', + ); + return mountHostRootWithoutHydrating( + current, + workInProgress, + updateQueue, + nextState, + nextChildren, + renderLanes, + recoverableError, + ); + } else if (nextChildren !== prevChildren) { + const recoverableError = new Error( + 'This root received an early update, before anything was able ' + + 'hydrate. Switched the entire root to client rendering.', + ); + return mountHostRootWithoutHydrating( + current, + workInProgress, + updateQueue, + nextState, + nextChildren, + renderLanes, + recoverableError, + ); + } else { + // The outermost shell has not hydrated yet. Start hydrating. + enterHydrationState(workInProgress); + 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, - nextChildren, - renderLanes, - ); - workInProgress.child = child; + const child = mountChildFibers( + workInProgress, + null, + nextChildren, + renderLanes, + ); + workInProgress.child = child; - let node = child; - while (node) { - // Mark each child as hydrating. This is a fast path to know whether this - // tree is part of a hydrating tree. This is used to determine if a child - // node has fully mounted yet, and for scheduling event replaying. - // Conceptually this is similar to Placement in that a new subtree is - // inserted into the React tree here. It just happens to not need DOM - // mutations because it already exists. - node.flags = (node.flags & ~Placement) | Hydrating; - node = node.sibling; + let node = child; + while (node) { + // Mark each child as hydrating. This is a fast path to know whether this + // tree is part of a hydrating tree. This is used to determine if a child + // node has fully mounted yet, and for scheduling event replaying. + // Conceptually this is similar to Placement in that a new subtree is + // inserted into the React tree here. It just happens to not need DOM + // mutations because it already exists. + node.flags = (node.flags & ~Placement) | Hydrating; + node = node.sibling; + } } } else { - // Otherwise reset hydration state in case we aborted and resumed another - // root. - reconcileChildren(current, workInProgress, nextChildren, renderLanes); + // Root is not dehydrated. Either this is a client-only root, or it + // already hydrated. resetHydrationState(); + if (nextChildren === prevChildren) { + return bailoutOnAlreadyFinishedWork(current, workInProgress, renderLanes); + } + reconcileChildren(current, workInProgress, nextChildren, renderLanes); } return workInProgress.child; } +function mountHostRootWithoutHydrating( + current: Fiber, + workInProgress: Fiber, + updateQueue: UpdateQueue, + nextState: RootState, + nextChildren: ReactNodeList, + renderLanes: Lanes, + recoverableError: Error, +) { + // Revert to client rendering. + resetHydrationState(); + + queueHydrationError(recoverableError); + + workInProgress.flags |= ForceClientRender; + + // Flip isDehydrated to false to indicate that when this render + // finishes, the root will no longer be dehydrated. + const overrideState: RootState = { + element: nextChildren, + isDehydrated: false, + cache: nextState.cache, + transitions: nextState.transitions, + }; + // `baseState` can always be the last state because the root doesn't + // have reducer functions so it doesn't need rebasing. + updateQueue.baseState = overrideState; + workInProgress.memoizedState = overrideState; + reconcileChildren(current, workInProgress, nextChildren, renderLanes); + return workInProgress.child; +} + function updateHostComponent( current: Fiber | null, workInProgress: Fiber, diff --git a/packages/react-reconciler/src/ReactFiberBeginWork.old.js b/packages/react-reconciler/src/ReactFiberBeginWork.old.js index bdb53d6b36..206773b03b 100644 --- a/packages/react-reconciler/src/ReactFiberBeginWork.old.js +++ b/packages/react-reconciler/src/ReactFiberBeginWork.old.js @@ -7,7 +7,11 @@ * @flow */ -import type {ReactProviderType, ReactContext} from 'shared/ReactTypes'; +import type { + ReactProviderType, + ReactContext, + ReactNodeList, +} from 'shared/ReactTypes'; import type {LazyComponent as LazyComponentType} from 'react/src/ReactLazy'; import type {Fiber, FiberRoot} from './ReactInternalTypes'; import type {TypeOfMode} from './ReactTypeOfMode'; @@ -29,6 +33,7 @@ import type { SpawnedCachePool, } from './ReactFiberCacheComponent.old'; import type {UpdateQueue} from './ReactUpdateQueue.old'; +import type {RootState} from './ReactFiberRoot.old'; import { enableSuspenseAvoidThisFallback, enableCPUSuspense, @@ -223,7 +228,6 @@ import { createOffscreenHostContainerFiber, isSimpleFunctionComponent, } from './ReactFiber.old'; -import {isRootDehydrated} from './ReactFiberShellHydration'; import { retryDehydratedSuspenseBoundary, scheduleUpdateOnFiber, @@ -1312,7 +1316,7 @@ function pushHostRootContext(workInProgress) { function updateHostRoot(current, workInProgress, renderLanes) { pushHostRootContext(workInProgress); - const updateQueue = workInProgress.updateQueue; + const updateQueue: UpdateQueue = (workInProgress.updateQueue: any); if (current === null || updateQueue === null) { throw new Error( @@ -1327,7 +1331,7 @@ function updateHostRoot(current, workInProgress, renderLanes) { const prevChildren = prevState.element; cloneUpdateQueue(current, workInProgress); processUpdateQueue(workInProgress, nextProps, null, renderLanes); - const nextState = workInProgress.memoizedState; + const nextState: RootState = workInProgress.memoizedState; const root: FiberRoot = workInProgress.stateNode; @@ -1342,64 +1346,130 @@ function updateHostRoot(current, workInProgress, renderLanes) { } if (enableTransitionTracing) { + // FIXME: Slipped past code review. This is not a safe mutation: + // workInProgress.memoizedState is a shared object. Need to fix before + // rolling out the Transition Tracing experiment. workInProgress.memoizedState.transitions = getWorkInProgressTransitions(); } // Caution: React DevTools currently depends on this property // being called "element". const nextChildren = nextState.element; - if (nextChildren === prevChildren) { - resetHydrationState(); - return bailoutOnAlreadyFinishedWork(current, workInProgress, renderLanes); - } - if (isRootDehydrated(root) && enterHydrationState(workInProgress)) { - // If we don't have any current children this might be the first pass. - // We always try to hydrate. If this isn't a hydration pass there won't - // 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); + if (supportsHydration && prevState.isDehydrated) { + // This is a hydration root whose shell has not yet hydrated. We should + // attempt to hydrate. + if (workInProgress.flags & ForceClientRender) { + // Something errored during a previous attempt to hydrate the shell, so we + // forced a client render. + const recoverableError = new Error( + 'There was an error while hydrating. Because the error happened outside ' + + 'of a Suspense boundary, the entire root will switch to ' + + 'client rendering.', + ); + return mountHostRootWithoutHydrating( + current, + workInProgress, + updateQueue, + nextState, + nextChildren, + renderLanes, + recoverableError, + ); + } else if (nextChildren !== prevChildren) { + const recoverableError = new Error( + 'This root received an early update, before anything was able ' + + 'hydrate. Switched the entire root to client rendering.', + ); + return mountHostRootWithoutHydrating( + current, + workInProgress, + updateQueue, + nextState, + nextChildren, + renderLanes, + recoverableError, + ); + } else { + // The outermost shell has not hydrated yet. Start hydrating. + enterHydrationState(workInProgress); + 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, - nextChildren, - renderLanes, - ); - workInProgress.child = child; + const child = mountChildFibers( + workInProgress, + null, + nextChildren, + renderLanes, + ); + workInProgress.child = child; - let node = child; - while (node) { - // Mark each child as hydrating. This is a fast path to know whether this - // tree is part of a hydrating tree. This is used to determine if a child - // node has fully mounted yet, and for scheduling event replaying. - // Conceptually this is similar to Placement in that a new subtree is - // inserted into the React tree here. It just happens to not need DOM - // mutations because it already exists. - node.flags = (node.flags & ~Placement) | Hydrating; - node = node.sibling; + let node = child; + while (node) { + // Mark each child as hydrating. This is a fast path to know whether this + // tree is part of a hydrating tree. This is used to determine if a child + // node has fully mounted yet, and for scheduling event replaying. + // Conceptually this is similar to Placement in that a new subtree is + // inserted into the React tree here. It just happens to not need DOM + // mutations because it already exists. + node.flags = (node.flags & ~Placement) | Hydrating; + node = node.sibling; + } } } else { - // Otherwise reset hydration state in case we aborted and resumed another - // root. - reconcileChildren(current, workInProgress, nextChildren, renderLanes); + // Root is not dehydrated. Either this is a client-only root, or it + // already hydrated. resetHydrationState(); + if (nextChildren === prevChildren) { + return bailoutOnAlreadyFinishedWork(current, workInProgress, renderLanes); + } + reconcileChildren(current, workInProgress, nextChildren, renderLanes); } return workInProgress.child; } +function mountHostRootWithoutHydrating( + current: Fiber, + workInProgress: Fiber, + updateQueue: UpdateQueue, + nextState: RootState, + nextChildren: ReactNodeList, + renderLanes: Lanes, + recoverableError: Error, +) { + // Revert to client rendering. + resetHydrationState(); + + queueHydrationError(recoverableError); + + workInProgress.flags |= ForceClientRender; + + // Flip isDehydrated to false to indicate that when this render + // finishes, the root will no longer be dehydrated. + const overrideState: RootState = { + element: nextChildren, + isDehydrated: false, + cache: nextState.cache, + transitions: nextState.transitions, + }; + // `baseState` can always be the last state because the root doesn't + // have reducer functions so it doesn't need rebasing. + updateQueue.baseState = overrideState; + workInProgress.memoizedState = overrideState; + reconcileChildren(current, workInProgress, nextChildren, renderLanes); + return workInProgress.child; +} + function updateHostComponent( current: Fiber | null, workInProgress: Fiber, diff --git a/packages/react-reconciler/src/ReactFiberCommitWork.new.js b/packages/react-reconciler/src/ReactFiberCommitWork.new.js index 6f6b59b4ec..776c402a00 100644 --- a/packages/react-reconciler/src/ReactFiberCommitWork.new.js +++ b/packages/react-reconciler/src/ReactFiberCommitWork.new.js @@ -25,6 +25,7 @@ import type {Wakeable} from 'shared/ReactTypes'; import type {OffscreenState} from './ReactFiberOffscreenComponent'; import type {HookFlags} from './ReactHookEffectTags'; import type {Cache} from './ReactFiberCacheComponent.new'; +import type {RootState} from './ReactFiberRoot.new'; import { enableCreateEventHandleAPI, @@ -82,7 +83,6 @@ import { Visibility, } from './ReactFiberFlags'; import getComponentNameFromFiber from 'react-reconciler/src/getComponentNameFromFiber'; -import {isRootDehydrated} from './ReactFiberShellHydration'; import { resetCurrentFiber as resetCurrentDebugFiberInDEV, setCurrentFiber as setCurrentDebugFiberInDEV, @@ -1878,11 +1878,12 @@ function commitWork(current: Fiber | null, finishedWork: Fiber): void { } case HostRoot: { if (supportsHydration) { - const root: FiberRoot = finishedWork.stateNode; - if (isRootDehydrated(root)) { - // We've just hydrated. No need to hydrate again. - root.isDehydrated = false; - commitHydratedContainer(root.containerInfo); + if (current !== null) { + const prevRootState: RootState = current.memoizedState; + if (prevRootState.isDehydrated) { + const root: FiberRoot = finishedWork.stateNode; + commitHydratedContainer(root.containerInfo); + } } } break; @@ -1986,11 +1987,12 @@ function commitWork(current: Fiber | null, finishedWork: Fiber): void { } case HostRoot: { if (supportsHydration) { - const root: FiberRoot = finishedWork.stateNode; - if (isRootDehydrated(root)) { - // We've just hydrated. No need to hydrate again. - root.isDehydrated = false; - commitHydratedContainer(root.containerInfo); + if (current !== null) { + const prevRootState: RootState = current.memoizedState; + if (prevRootState.isDehydrated) { + const root: FiberRoot = finishedWork.stateNode; + commitHydratedContainer(root.containerInfo); + } } } return; diff --git a/packages/react-reconciler/src/ReactFiberCommitWork.old.js b/packages/react-reconciler/src/ReactFiberCommitWork.old.js index af044c79ca..a53d4a2a87 100644 --- a/packages/react-reconciler/src/ReactFiberCommitWork.old.js +++ b/packages/react-reconciler/src/ReactFiberCommitWork.old.js @@ -25,6 +25,7 @@ import type {Wakeable} from 'shared/ReactTypes'; import type {OffscreenState} from './ReactFiberOffscreenComponent'; import type {HookFlags} from './ReactHookEffectTags'; import type {Cache} from './ReactFiberCacheComponent.old'; +import type {RootState} from './ReactFiberRoot.old'; import { enableCreateEventHandleAPI, @@ -82,7 +83,6 @@ import { Visibility, } from './ReactFiberFlags'; import getComponentNameFromFiber from 'react-reconciler/src/getComponentNameFromFiber'; -import {isRootDehydrated} from './ReactFiberShellHydration'; import { resetCurrentFiber as resetCurrentDebugFiberInDEV, setCurrentFiber as setCurrentDebugFiberInDEV, @@ -1878,11 +1878,12 @@ function commitWork(current: Fiber | null, finishedWork: Fiber): void { } case HostRoot: { if (supportsHydration) { - const root: FiberRoot = finishedWork.stateNode; - if (isRootDehydrated(root)) { - // We've just hydrated. No need to hydrate again. - root.isDehydrated = false; - commitHydratedContainer(root.containerInfo); + if (current !== null) { + const prevRootState: RootState = current.memoizedState; + if (prevRootState.isDehydrated) { + const root: FiberRoot = finishedWork.stateNode; + commitHydratedContainer(root.containerInfo); + } } } break; @@ -1986,11 +1987,12 @@ function commitWork(current: Fiber | null, finishedWork: Fiber): void { } case HostRoot: { if (supportsHydration) { - const root: FiberRoot = finishedWork.stateNode; - if (isRootDehydrated(root)) { - // We've just hydrated. No need to hydrate again. - root.isDehydrated = false; - commitHydratedContainer(root.containerInfo); + if (current !== null) { + const prevRootState: RootState = current.memoizedState; + if (prevRootState.isDehydrated) { + const root: FiberRoot = finishedWork.stateNode; + commitHydratedContainer(root.containerInfo); + } } } return; diff --git a/packages/react-reconciler/src/ReactFiberCompleteWork.new.js b/packages/react-reconciler/src/ReactFiberCompleteWork.new.js index e00fdf5850..bea984c19f 100644 --- a/packages/react-reconciler/src/ReactFiberCompleteWork.new.js +++ b/packages/react-reconciler/src/ReactFiberCompleteWork.new.js @@ -8,6 +8,7 @@ */ import type {Fiber} from './ReactInternalTypes'; +import type {RootState} from './ReactFiberRoot.new'; import type {Lanes, Lane} from './ReactFiberLane.new'; import type { ReactScopeInstance, @@ -160,7 +161,6 @@ import { includesSomeLane, mergeLanes, } from './ReactFiberLane.new'; -import {isRootDehydrated} from './ReactFiberShellHydration'; import {resetChildFibers} from './ReactChildFiber.new'; import {createScopeInstance} from './ReactFiberScope.new'; import {transferActualDuration} from './ReactProfilerTimer.new'; @@ -891,12 +891,29 @@ function completeWork( // If we hydrated, then we'll need to schedule an update for // the commit side-effects on the root. markUpdate(workInProgress); - } else if (!isRootDehydrated(fiberRoot)) { - // Schedule an effect to clear this container at the start of the next commit. - // This handles the case of React rendering into a container with previous children. - // It's also safe to do for updates too, because current.child would only be null - // if the previous render was null (so the container would already be empty). - workInProgress.flags |= Snapshot; + } else { + if (current !== null) { + const prevState: RootState = current.memoizedState; + if ( + // Check if this is a client root + !prevState.isDehydrated || + // Check if we reverted to client rendering (e.g. due to an error) + (workInProgress.flags & ForceClientRender) !== NoFlags + ) { + // Schedule an effect to clear this container at the start of the + // next commit. This handles the case of React rendering into a + // container with previous children. It's also safe to do for + // updates too, because current.child would only be null if the + // previous render was null (so the container would already + // be empty). + workInProgress.flags |= Snapshot; + + // If this was a forced client render, there may have been + // recoverable errors during first hydration attempt. If so, add + // them to a queue so we can log them in the commit phase. + upgradeHydrationErrorsToRecoverable(); + } + } } } updateHostContainer(current, workInProgress); diff --git a/packages/react-reconciler/src/ReactFiberCompleteWork.old.js b/packages/react-reconciler/src/ReactFiberCompleteWork.old.js index 1c47a291ca..ef3d4f7979 100644 --- a/packages/react-reconciler/src/ReactFiberCompleteWork.old.js +++ b/packages/react-reconciler/src/ReactFiberCompleteWork.old.js @@ -8,6 +8,7 @@ */ import type {Fiber} from './ReactInternalTypes'; +import type {RootState} from './ReactFiberRoot.old'; import type {Lanes, Lane} from './ReactFiberLane.old'; import type { ReactScopeInstance, @@ -160,7 +161,6 @@ import { includesSomeLane, mergeLanes, } from './ReactFiberLane.old'; -import {isRootDehydrated} from './ReactFiberShellHydration'; import {resetChildFibers} from './ReactChildFiber.old'; import {createScopeInstance} from './ReactFiberScope.old'; import {transferActualDuration} from './ReactProfilerTimer.old'; @@ -891,12 +891,29 @@ function completeWork( // If we hydrated, then we'll need to schedule an update for // the commit side-effects on the root. markUpdate(workInProgress); - } else if (!isRootDehydrated(fiberRoot)) { - // Schedule an effect to clear this container at the start of the next commit. - // This handles the case of React rendering into a container with previous children. - // It's also safe to do for updates too, because current.child would only be null - // if the previous render was null (so the container would already be empty). - workInProgress.flags |= Snapshot; + } else { + if (current !== null) { + const prevState: RootState = current.memoizedState; + if ( + // Check if this is a client root + !prevState.isDehydrated || + // Check if we reverted to client rendering (e.g. due to an error) + (workInProgress.flags & ForceClientRender) !== NoFlags + ) { + // Schedule an effect to clear this container at the start of the + // next commit. This handles the case of React rendering into a + // container with previous children. It's also safe to do for + // updates too, because current.child would only be null if the + // previous render was null (so the container would already + // be empty). + workInProgress.flags |= Snapshot; + + // If this was a forced client render, there may have been + // recoverable errors during first hydration attempt. If so, add + // them to a queue so we can log them in the commit phase. + upgradeHydrationErrorsToRecoverable(); + } + } } } updateHostContainer(current, workInProgress); diff --git a/packages/react-reconciler/src/ReactFiberReconciler.new.js b/packages/react-reconciler/src/ReactFiberReconciler.new.js index b814c35cf8..849470551a 100644 --- a/packages/react-reconciler/src/ReactFiberReconciler.new.js +++ b/packages/react-reconciler/src/ReactFiberReconciler.new.js @@ -254,10 +254,12 @@ export function createContainer( transitionCallbacks: null | TransitionTracingCallbacks, ): OpaqueRoot { const hydrate = false; + const initialChildren = null; return createFiberRoot( containerInfo, tag, hydrate, + initialChildren, hydrationCallbacks, isStrictMode, concurrentUpdatesByDefaultOverride, @@ -285,6 +287,7 @@ export function createHydrationContainer( containerInfo, tag, hydrate, + initialChildren, hydrationCallbacks, isStrictMode, concurrentUpdatesByDefaultOverride, @@ -303,9 +306,7 @@ export function createHydrationContainer( const eventTime = requestEventTime(); const lane = requestUpdateLane(current); const update = createUpdate(eventTime, lane); - // Caution: React DevTools currently depends on this property - // being called "element". - update.payload = {element: initialChildren}; + update.payload = {isDehydrated: false}; update.callback = callback !== undefined && callback !== null ? callback : null; enqueueUpdate(current, update, lane); diff --git a/packages/react-reconciler/src/ReactFiberReconciler.old.js b/packages/react-reconciler/src/ReactFiberReconciler.old.js index 9665ca44bc..f7166095ba 100644 --- a/packages/react-reconciler/src/ReactFiberReconciler.old.js +++ b/packages/react-reconciler/src/ReactFiberReconciler.old.js @@ -254,10 +254,12 @@ export function createContainer( transitionCallbacks: null | TransitionTracingCallbacks, ): OpaqueRoot { const hydrate = false; + const initialChildren = null; return createFiberRoot( containerInfo, tag, hydrate, + initialChildren, hydrationCallbacks, isStrictMode, concurrentUpdatesByDefaultOverride, @@ -285,6 +287,7 @@ export function createHydrationContainer( containerInfo, tag, hydrate, + initialChildren, hydrationCallbacks, isStrictMode, concurrentUpdatesByDefaultOverride, @@ -303,9 +306,7 @@ export function createHydrationContainer( const eventTime = requestEventTime(); const lane = requestUpdateLane(current); const update = createUpdate(eventTime, lane); - // Caution: React DevTools currently depends on this property - // being called "element". - update.payload = {element: initialChildren}; + update.payload = {isDehydrated: false}; update.callback = callback !== undefined && callback !== null ? callback : null; enqueueUpdate(current, update, lane); diff --git a/packages/react-reconciler/src/ReactFiberRoot.new.js b/packages/react-reconciler/src/ReactFiberRoot.new.js index 00dd694be4..7ff03ceead 100644 --- a/packages/react-reconciler/src/ReactFiberRoot.new.js +++ b/packages/react-reconciler/src/ReactFiberRoot.new.js @@ -7,6 +7,7 @@ * @flow */ +import type {ReactNodeList} from 'shared/ReactTypes'; import type { FiberRoot, SuspenseHydrationCallbacks, @@ -39,7 +40,8 @@ import {createCache, retainCache} from './ReactFiberCacheComponent.new'; export type RootState = { element: any, - cache: Cache | null, + isDehydrated: boolean, + cache: Cache, transitions: Transitions | null, }; @@ -59,7 +61,6 @@ function FiberRootNode( this.timeoutHandle = noTimeout; this.context = null; this.pendingContext = null; - this.isDehydrated = hydrate; this.callbackNode = null; this.callbackPriority = NoLane; this.eventTimes = createLaneMap(NoLanes); @@ -128,6 +129,7 @@ export function createFiberRoot( containerInfo: any, tag: RootTag, hydrate: boolean, + initialChildren: ReactNodeList, hydrationCallbacks: null | SuspenseHydrationCallbacks, isStrictMode: boolean, concurrentUpdatesByDefaultOverride: null | boolean, @@ -178,15 +180,17 @@ export function createFiberRoot( root.pooledCache = initialCache; retainCache(initialCache); const initialState: RootState = { - element: null, + element: initialChildren, + isDehydrated: hydrate, cache: initialCache, transitions: null, }; uninitializedFiber.memoizedState = initialState; } else { const initialState: RootState = { - element: null, - cache: null, + element: initialChildren, + isDehydrated: hydrate, + cache: (null: any), // not enabled yet transitions: null, }; uninitializedFiber.memoizedState = initialState; diff --git a/packages/react-reconciler/src/ReactFiberRoot.old.js b/packages/react-reconciler/src/ReactFiberRoot.old.js index 1e561e49fa..179b9c17ae 100644 --- a/packages/react-reconciler/src/ReactFiberRoot.old.js +++ b/packages/react-reconciler/src/ReactFiberRoot.old.js @@ -7,6 +7,7 @@ * @flow */ +import type {ReactNodeList} from 'shared/ReactTypes'; import type { FiberRoot, SuspenseHydrationCallbacks, @@ -39,7 +40,8 @@ import {createCache, retainCache} from './ReactFiberCacheComponent.old'; export type RootState = { element: any, - cache: Cache | null, + isDehydrated: boolean, + cache: Cache, transitions: Transitions | null, }; @@ -59,7 +61,6 @@ function FiberRootNode( this.timeoutHandle = noTimeout; this.context = null; this.pendingContext = null; - this.isDehydrated = hydrate; this.callbackNode = null; this.callbackPriority = NoLane; this.eventTimes = createLaneMap(NoLanes); @@ -128,6 +129,7 @@ export function createFiberRoot( containerInfo: any, tag: RootTag, hydrate: boolean, + initialChildren: ReactNodeList, hydrationCallbacks: null | SuspenseHydrationCallbacks, isStrictMode: boolean, concurrentUpdatesByDefaultOverride: null | boolean, @@ -178,15 +180,17 @@ export function createFiberRoot( root.pooledCache = initialCache; retainCache(initialCache); const initialState: RootState = { - element: null, + element: initialChildren, + isDehydrated: hydrate, cache: initialCache, transitions: null, }; uninitializedFiber.memoizedState = initialState; } else { const initialState: RootState = { - element: null, - cache: null, + element: initialChildren, + isDehydrated: hydrate, + cache: (null: any), // not enabled yet transitions: null, }; uninitializedFiber.memoizedState = initialState; diff --git a/packages/react-reconciler/src/ReactFiberShellHydration.js b/packages/react-reconciler/src/ReactFiberShellHydration.js index 2f1afde6de..caadb978f6 100644 --- a/packages/react-reconciler/src/ReactFiberShellHydration.js +++ b/packages/react-reconciler/src/ReactFiberShellHydration.js @@ -8,10 +8,12 @@ */ import type {FiberRoot} from './ReactInternalTypes'; +import type {RootState} from './ReactFiberRoot.new'; // This is imported by the event replaying implementation in React DOM. It's // in a separate file to break a circular dependency between the renderer and // the reconciler. export function isRootDehydrated(root: FiberRoot) { - return root.isDehydrated; + const currentState: RootState = root.current.memoizedState; + return currentState.isDehydrated; } diff --git a/packages/react-reconciler/src/ReactFiberWorkLoop.new.js b/packages/react-reconciler/src/ReactFiberWorkLoop.new.js index caab7a666f..558440effa 100644 --- a/packages/react-reconciler/src/ReactFiberWorkLoop.new.js +++ b/packages/react-reconciler/src/ReactFiberWorkLoop.new.js @@ -110,6 +110,7 @@ import { StoreConsistency, HostEffectMask, Hydrating, + ForceClientRender, BeforeMutationMask, MutationMask, LayoutMask, @@ -582,34 +583,7 @@ export function scheduleUpdateOnFiber( } } - if (isRootDehydrated(root) && root.tag !== LegacyRoot) { - // This root's shell hasn't hydrated yet. Revert to client rendering. - if (workInProgressRoot === root) { - // If this happened during an interleaved event, interrupt the - // in-progress hydration. Theoretically, we could attempt to force a - // synchronous hydration before switching to client rendering, but the - // most common reason the shell hasn't hydrated yet is because it - // suspended. So it's very likely to suspend again anyway. For - // simplicity, we'll skip that atttempt and go straight to - // client rendering. - // - // Another way to model this would be to give the initial hydration its - // own special lane. However, it may not be worth adding a lane solely - // for this purpose, so we'll wait until we find another use case before - // adding it. - // - // TODO: Consider only interrupting hydration if the priority of the - // update is higher than default. - prepareFreshStack(root, NoLanes); - } - root.isDehydrated = false; - const error = new Error( - 'This root received an early update, before anything was able ' + - 'hydrate. Switched the entire root to client rendering.', - ); - const onRecoverableError = root.onRecoverableError; - onRecoverableError(error); - } else if (root === workInProgressRoot) { + if (root === workInProgressRoot) { // TODO: Consolidate with `isInterleavedUpdate` check // Received an update to a tree that's in the middle of rendering. Mark @@ -1017,28 +991,42 @@ function performConcurrentWorkOnRoot(root, didTimeout) { function recoverFromConcurrentError(root, errorRetryLanes) { // If an error occurred during hydration, discard server response and fall // back to client side render. + + // Before rendering again, save the errors from the previous attempt. + const errorsFromFirstAttempt = workInProgressRootConcurrentErrors; + if (isRootDehydrated(root)) { - root.isDehydrated = false; + // The shell failed to hydrate. Set a flag to force a client rendering + // during the next attempt. To do this, we call prepareFreshStack now + // to create the root work-in-progress fiber. This is a bit weird in terms + // of factoring, because it relies on renderRootSync not calling + // prepareFreshStack again in the call below, which happens because the + // root and lanes haven't changed. + // + // TODO: I think what we should do is set ForceClientRender inside + // throwException, like we do for nested Suspense boundaries. The reason + // it's here instead is so we can switch to the synchronous work loop, too. + // Something to consider for a future refactor. + const rootWorkInProgress = prepareFreshStack(root, errorRetryLanes); + rootWorkInProgress.flags |= ForceClientRender; if (__DEV__) { errorHydratingContainer(root.containerInfo); } - const error = new Error( - 'There was an error while hydrating. Because the error happened outside ' + - 'of a Suspense boundary, the entire root will switch to ' + - 'client rendering.', - ); - renderDidError(error); } - const errorsFromFirstAttempt = workInProgressRootConcurrentErrors; const exitStatus = renderRootSync(root, errorRetryLanes); if (exitStatus !== RootErrored) { // Successfully finished rendering on retry - if (errorsFromFirstAttempt !== null) { - // The errors from the failed first attempt have been recovered. Add - // them to the collection of recoverable errors. We'll log them in the - // commit phase. - queueRecoverableErrors(errorsFromFirstAttempt); + + // The errors from the failed first attempt have been recovered. Add + // them to the collection of recoverable errors. We'll log them in the + // commit phase. + const errorsFromSecondAttempt = workInProgressRootRecoverableErrors; + workInProgressRootRecoverableErrors = errorsFromFirstAttempt; + // The errors from the second attempt should be queued after the errors + // from the first attempt, to preserve the causal sequence. + if (errorsFromSecondAttempt !== null) { + queueRecoverableErrors(errorsFromSecondAttempt); } } else { // The UI failed to recover. @@ -1454,7 +1442,7 @@ export function popRenderLanes(fiber: Fiber) { popFromStack(subtreeRenderLanesCursor, fiber); } -function prepareFreshStack(root: FiberRoot, lanes: Lanes) { +function prepareFreshStack(root: FiberRoot, lanes: Lanes): Fiber { root.finishedWork = null; root.finishedLanes = NoLanes; @@ -1480,7 +1468,8 @@ function prepareFreshStack(root: FiberRoot, lanes: Lanes) { } } workInProgressRoot = root; - workInProgress = createWorkInProgress(root.current, null); + const rootWorkInProgress = createWorkInProgress(root.current, null); + workInProgress = rootWorkInProgress; workInProgressRootRenderLanes = subtreeRenderLanes = workInProgressRootIncludedLanes = lanes; workInProgressRootExitStatus = RootInProgress; workInProgressRootFatalError = null; @@ -1496,6 +1485,8 @@ function prepareFreshStack(root: FiberRoot, lanes: Lanes) { if (__DEV__) { ReactStrictModeWarnings.discardPendingWarnings(); } + + return rootWorkInProgress; } function handleError(root, thrownValue): void { diff --git a/packages/react-reconciler/src/ReactFiberWorkLoop.old.js b/packages/react-reconciler/src/ReactFiberWorkLoop.old.js index 04a324cdc8..c1c090d82b 100644 --- a/packages/react-reconciler/src/ReactFiberWorkLoop.old.js +++ b/packages/react-reconciler/src/ReactFiberWorkLoop.old.js @@ -110,6 +110,7 @@ import { StoreConsistency, HostEffectMask, Hydrating, + ForceClientRender, BeforeMutationMask, MutationMask, LayoutMask, @@ -582,34 +583,7 @@ export function scheduleUpdateOnFiber( } } - if (isRootDehydrated(root) && root.tag !== LegacyRoot) { - // This root's shell hasn't hydrated yet. Revert to client rendering. - if (workInProgressRoot === root) { - // If this happened during an interleaved event, interrupt the - // in-progress hydration. Theoretically, we could attempt to force a - // synchronous hydration before switching to client rendering, but the - // most common reason the shell hasn't hydrated yet is because it - // suspended. So it's very likely to suspend again anyway. For - // simplicity, we'll skip that atttempt and go straight to - // client rendering. - // - // Another way to model this would be to give the initial hydration its - // own special lane. However, it may not be worth adding a lane solely - // for this purpose, so we'll wait until we find another use case before - // adding it. - // - // TODO: Consider only interrupting hydration if the priority of the - // update is higher than default. - prepareFreshStack(root, NoLanes); - } - root.isDehydrated = false; - const error = new Error( - 'This root received an early update, before anything was able ' + - 'hydrate. Switched the entire root to client rendering.', - ); - const onRecoverableError = root.onRecoverableError; - onRecoverableError(error); - } else if (root === workInProgressRoot) { + if (root === workInProgressRoot) { // TODO: Consolidate with `isInterleavedUpdate` check // Received an update to a tree that's in the middle of rendering. Mark @@ -1017,28 +991,42 @@ function performConcurrentWorkOnRoot(root, didTimeout) { function recoverFromConcurrentError(root, errorRetryLanes) { // If an error occurred during hydration, discard server response and fall // back to client side render. + + // Before rendering again, save the errors from the previous attempt. + const errorsFromFirstAttempt = workInProgressRootConcurrentErrors; + if (isRootDehydrated(root)) { - root.isDehydrated = false; + // The shell failed to hydrate. Set a flag to force a client rendering + // during the next attempt. To do this, we call prepareFreshStack now + // to create the root work-in-progress fiber. This is a bit weird in terms + // of factoring, because it relies on renderRootSync not calling + // prepareFreshStack again in the call below, which happens because the + // root and lanes haven't changed. + // + // TODO: I think what we should do is set ForceClientRender inside + // throwException, like we do for nested Suspense boundaries. The reason + // it's here instead is so we can switch to the synchronous work loop, too. + // Something to consider for a future refactor. + const rootWorkInProgress = prepareFreshStack(root, errorRetryLanes); + rootWorkInProgress.flags |= ForceClientRender; if (__DEV__) { errorHydratingContainer(root.containerInfo); } - const error = new Error( - 'There was an error while hydrating. Because the error happened outside ' + - 'of a Suspense boundary, the entire root will switch to ' + - 'client rendering.', - ); - renderDidError(error); } - const errorsFromFirstAttempt = workInProgressRootConcurrentErrors; const exitStatus = renderRootSync(root, errorRetryLanes); if (exitStatus !== RootErrored) { // Successfully finished rendering on retry - if (errorsFromFirstAttempt !== null) { - // The errors from the failed first attempt have been recovered. Add - // them to the collection of recoverable errors. We'll log them in the - // commit phase. - queueRecoverableErrors(errorsFromFirstAttempt); + + // The errors from the failed first attempt have been recovered. Add + // them to the collection of recoverable errors. We'll log them in the + // commit phase. + const errorsFromSecondAttempt = workInProgressRootRecoverableErrors; + workInProgressRootRecoverableErrors = errorsFromFirstAttempt; + // The errors from the second attempt should be queued after the errors + // from the first attempt, to preserve the causal sequence. + if (errorsFromSecondAttempt !== null) { + queueRecoverableErrors(errorsFromSecondAttempt); } } else { // The UI failed to recover. @@ -1454,7 +1442,7 @@ export function popRenderLanes(fiber: Fiber) { popFromStack(subtreeRenderLanesCursor, fiber); } -function prepareFreshStack(root: FiberRoot, lanes: Lanes) { +function prepareFreshStack(root: FiberRoot, lanes: Lanes): Fiber { root.finishedWork = null; root.finishedLanes = NoLanes; @@ -1480,7 +1468,8 @@ function prepareFreshStack(root: FiberRoot, lanes: Lanes) { } } workInProgressRoot = root; - workInProgress = createWorkInProgress(root.current, null); + const rootWorkInProgress = createWorkInProgress(root.current, null); + workInProgress = rootWorkInProgress; workInProgressRootRenderLanes = subtreeRenderLanes = workInProgressRootIncludedLanes = lanes; workInProgressRootExitStatus = RootInProgress; workInProgressRootFatalError = null; @@ -1496,6 +1485,8 @@ function prepareFreshStack(root: FiberRoot, lanes: Lanes) { if (__DEV__) { ReactStrictModeWarnings.discardPendingWarnings(); } + + return rootWorkInProgress; } function handleError(root, thrownValue): void { diff --git a/packages/react-reconciler/src/ReactInternalTypes.js b/packages/react-reconciler/src/ReactInternalTypes.js index dd2e09c03b..1fa3d4b668 100644 --- a/packages/react-reconciler/src/ReactInternalTypes.js +++ b/packages/react-reconciler/src/ReactInternalTypes.js @@ -213,8 +213,6 @@ type BaseFiberRootProperties = {| // Top context object, used by renderSubtreeIntoContainer context: Object | null, pendingContext: Object | null, - // Determines if we should attempt to hydrate on the initial mount - +isDehydrated: boolean, // Used by useMutableSource hook to avoid tearing during hydration. mutableSourceEagerHydrationData?: Array<