From 02b65fd8c5dbc6bfe2c976841f2f70a593ac9129 Mon Sep 17 00:00:00 2001 From: Andrew Clark Date: Fri, 11 Mar 2022 19:48:36 -0500 Subject: [PATCH] Allow updates at lower pri without forcing client render Currently, if a root is updated before the shell has finished hydrating (for example, due to a top-level navigation), we immediately revert to client rendering. This is rare because the root is expected is finish quickly, but not exceedingly rare because the root may be suspended. This adds support for updating the root without forcing a client render as long as the update has lower priority than the initial hydration, i.e. if the update is wrapped in startTransition. To implement this, I had to do some refactoring. The main idea here is to make it closer to how we implement hydration in Suspense boundaries: - I moved isDehydrated from the shared FiberRoot object to the HostRoot's state object. - In the begin phase, I check if the root has received an by comparing the new children to the initial children. If they are different, we revert to client rendering, and set isDehydrated to false using a derived state update (a la getDerivedStateFromProps). - There are a few places where we used to set root.isDehydrated to false as a way to force a client render. Instead, I set the ForceClientRender flag on the root work-in-progress fiber. - Whenever we fall back to client rendering, I log a recoverable error. The overall code structure is almost identical to the corresponding logic for Suspense components. The reason this works is because if the update has lower priority than the initial hydration, it won't be processed during the hydration render, so the children will be the same. We can go even further and allow updates at _higher_ priority (though not sync) by implementing selective hydration at the root, like we do for Suspense boundaries: interrupt the current render, attempt hydration at slightly higher priority than the update, then continue rendering the update. I haven't implemented this yet, but I've structured the code in anticipation of adding this later. --- .../ReactDOMFizzShellHydration-test.js | 36 +++- .../src/ReactFiberBeginWork.new.js | 160 +++++++++++++----- .../src/ReactFiberBeginWork.old.js | 160 +++++++++++++----- .../src/ReactFiberCommitWork.new.js | 24 +-- .../src/ReactFiberCommitWork.old.js | 24 +-- .../src/ReactFiberCompleteWork.new.js | 31 +++- .../src/ReactFiberCompleteWork.old.js | 31 +++- .../src/ReactFiberReconciler.new.js | 7 +- .../src/ReactFiberReconciler.old.js | 7 +- .../src/ReactFiberRoot.new.js | 14 +- .../src/ReactFiberRoot.old.js | 14 +- .../src/ReactFiberShellHydration.js | 4 +- .../src/ReactFiberWorkLoop.new.js | 77 ++++----- .../src/ReactFiberWorkLoop.old.js | 77 ++++----- .../src/ReactInternalTypes.js | 2 - 15 files changed, 435 insertions(+), 233 deletions(-) diff --git a/packages/react-dom/src/__tests__/ReactDOMFizzShellHydration-test.js b/packages/react-dom/src/__tests__/ReactDOMFizzShellHydration-test.js index f3ff64daeec43..3584715f91e2f 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 4feff7d260668..7dc29bd2b0f08 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 bdb53d6b36e5a..206773b03b334 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 6f6b59b4ecee6..776c402a00eb9 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 af044c79cada7..a53d4a2a87525 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 e00fdf5850aeb..bea984c19f1ce 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 1c47a291caf00..ef3d4f7979f29 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 b814c35cf8350..849470551a2bc 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 9665ca44bc52a..f7166095ba13a 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 00dd694be4f5f..7ff03ceead0e3 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 1e561e49facb3..179b9c17ae416 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 2f1afde6de969..caadb978f69d0 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 caab7a666f655..558440effa77a 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 04a324cdc83eb..c1c090d82b0b5 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 dd2e09c03b210..1fa3d4b6680d1 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<