From 2e0d86d22192ff0b13b71b4ad68fea46bf523ef6 Mon Sep 17 00:00:00 2001 From: Andrew Clark Date: Sun, 20 Mar 2022 16:18:51 -0400 Subject: [PATCH] Allow updating dehydrated root at lower priority without forcing client render (#24082) * Pass children to hydration root constructor I already made this change for the concurrent root API in #23309. This does the same thing for the legacy API. Doesn't change any behavior, but I will use this in the next steps. * Add isRootDehydrated function Currently this does nothing except read a boolean field, but I'm about to change this logic. Since this is accessed by React DOM, too, I put the function in a separate module that can be deep imported. Previously, it was accessing the FiberRoot directly. The reason it's a separate module is to break a circular dependency between React DOM and the reconciler. * 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. * Wrap useMutableSource logic in feature flag --- packages/react-art/src/ReactART.js | 1 - .../src/backend/renderer.js | 9 +- .../ReactDOMFizzShellHydration-test.js | 36 +++- .../src/__tests__/ReactDOMRoot-test.js | 9 + .../react-dom/src/client/ReactDOMLegacy.js | 119 ++++++++----- packages/react-dom/src/client/ReactDOMRoot.js | 2 +- .../src/events/ReactDOMEventListener.js | 3 +- .../src/events/ReactDOMEventReplaying.js | 3 +- .../react-native-renderer/src/ReactFabric.js | 1 - .../src/ReactNativeRenderer.js | 1 - .../src/createReactNoop.js | 3 - .../src/ReactFiberBeginWork.new.js | 164 ++++++++++++------ .../src/ReactFiberBeginWork.old.js | 164 ++++++++++++------ .../src/ReactFiberCommitWork.new.js | 23 +-- .../src/ReactFiberCommitWork.old.js | 23 +-- .../src/ReactFiberCompleteWork.new.js | 30 +++- .../src/ReactFiberCompleteWork.old.js | 30 +++- .../src/ReactFiberReconciler.new.js | 20 ++- .../src/ReactFiberReconciler.old.js | 20 ++- .../src/ReactFiberRoot.new.js | 14 +- .../src/ReactFiberRoot.old.js | 14 +- .../src/ReactFiberShellHydration.js | 19 ++ .../src/ReactFiberWorkLoop.new.js | 80 ++++----- .../src/ReactFiberWorkLoop.old.js | 80 ++++----- .../src/ReactInternalTypes.js | 2 - .../ReactFiberHostContext-test.internal.js | 2 - .../src/ReactTestRenderer.js | 1 - 27 files changed, 571 insertions(+), 302 deletions(-) create mode 100644 packages/react-reconciler/src/ReactFiberShellHydration.js diff --git a/packages/react-art/src/ReactART.js b/packages/react-art/src/ReactART.js index 9d1b6a16c2038..44cdf4f240a21 100644 --- a/packages/react-art/src/ReactART.js +++ b/packages/react-art/src/ReactART.js @@ -69,7 +69,6 @@ class Surface extends React.Component { this._mountNode = createContainer( this._surface, LegacyRoot, - false, null, false, false, diff --git a/packages/react-devtools-shared/src/backend/renderer.js b/packages/react-devtools-shared/src/backend/renderer.js index 4b67a7a8ebe9c..3c18ce4083099 100644 --- a/packages/react-devtools-shared/src/backend/renderer.js +++ b/packages/react-devtools-shared/src/backend/renderer.js @@ -2704,9 +2704,14 @@ export function attach( // TODO: relying on this seems a bit fishy. const wasMounted = alternate.memoizedState != null && - alternate.memoizedState.element != null; + alternate.memoizedState.element != null && + // A dehydrated root is not considered mounted + alternate.memoizedState.isDehydrated !== true; const isMounted = - current.memoizedState != null && current.memoizedState.element != null; + current.memoizedState != null && + current.memoizedState.element != null && + // A dehydrated root is not considered mounted + current.memoizedState.isDehydrated !== true; if (!wasMounted && isMounted) { // Mount a new root. setRootPseudoKey(currentRootID, current); 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-dom/src/__tests__/ReactDOMRoot-test.js b/packages/react-dom/src/__tests__/ReactDOMRoot-test.js index df693b8784992..9d6a38188376d 100644 --- a/packages/react-dom/src/__tests__/ReactDOMRoot-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMRoot-test.js @@ -253,6 +253,15 @@ describe('ReactDOMRoot', () => { ); }); + it('callback passed to legacy hydrate() API', () => { + container.innerHTML = '
Hi
'; + ReactDOM.hydrate(
Hi
, container, () => { + Scheduler.unstable_yieldValue('callback'); + }); + expect(container.textContent).toEqual('Hi'); + expect(Scheduler).toHaveYielded(['callback']); + }); + it('warns when unmounting with legacy API (no previous content)', () => { const root = ReactDOMClient.createRoot(container); root.render(
Hi
); diff --git a/packages/react-dom/src/client/ReactDOMLegacy.js b/packages/react-dom/src/client/ReactDOMLegacy.js index 3b751405a3034..af0e35e128bd4 100644 --- a/packages/react-dom/src/client/ReactDOMLegacy.js +++ b/packages/react-dom/src/client/ReactDOMLegacy.js @@ -27,6 +27,7 @@ import { import { createContainer, + createHydrationContainer, findHostInstanceWithNoPortals, updateContainer, flushSync, @@ -109,34 +110,81 @@ function noopOnRecoverableError() { function legacyCreateRootFromDOMContainer( container: Container, - forceHydrate: boolean, + initialChildren: ReactNodeList, + parentComponent: ?React$Component, + callback: ?Function, + isHydrationContainer: boolean, ): FiberRoot { - // First clear any existing content. - if (!forceHydrate) { + if (isHydrationContainer) { + if (typeof callback === 'function') { + const originalCallback = callback; + callback = function() { + const instance = getPublicRootInstance(root); + originalCallback.call(instance); + }; + } + + const root = createHydrationContainer( + initialChildren, + callback, + container, + LegacyRoot, + null, // hydrationCallbacks + false, // isStrictMode + false, // concurrentUpdatesByDefaultOverride, + '', // identifierPrefix + noopOnRecoverableError, + // TODO(luna) Support hydration later + null, + ); + container._reactRootContainer = root; + markContainerAsRoot(root.current, container); + + const rootContainerElement = + container.nodeType === COMMENT_NODE ? container.parentNode : container; + listenToAllSupportedEvents(rootContainerElement); + + flushSync(); + return root; + } else { + // First clear any existing content. let rootSibling; while ((rootSibling = container.lastChild)) { container.removeChild(rootSibling); } - } - const root = createContainer( - container, - LegacyRoot, - forceHydrate, - null, // hydrationCallbacks - false, // isStrictMode - false, // concurrentUpdatesByDefaultOverride, - '', // identifierPrefix - noopOnRecoverableError, // onRecoverableError - null, // transitionCallbacks - ); - markContainerAsRoot(root.current, container); + if (typeof callback === 'function') { + const originalCallback = callback; + callback = function() { + const instance = getPublicRootInstance(root); + originalCallback.call(instance); + }; + } + + const root = createContainer( + container, + LegacyRoot, + null, // hydrationCallbacks + false, // isStrictMode + false, // concurrentUpdatesByDefaultOverride, + '', // identifierPrefix + noopOnRecoverableError, // onRecoverableError + null, // transitionCallbacks + ); + container._reactRootContainer = root; + markContainerAsRoot(root.current, container); + + const rootContainerElement = + container.nodeType === COMMENT_NODE ? container.parentNode : container; + listenToAllSupportedEvents(rootContainerElement); - const rootContainerElement = - container.nodeType === COMMENT_NODE ? container.parentNode : container; - listenToAllSupportedEvents(rootContainerElement); + // Initial mount should not be batched. + flushSync(() => { + updateContainer(initialChildren, root, parentComponent, callback); + }); - return root; + return root; + } } function warnOnInvalidCallback(callback: mixed, callerName: string): void { @@ -164,39 +212,30 @@ function legacyRenderSubtreeIntoContainer( warnOnInvalidCallback(callback === undefined ? null : callback, 'render'); } - let root = container._reactRootContainer; - let fiberRoot: FiberRoot; - if (!root) { + const maybeRoot = container._reactRootContainer; + let root: FiberRoot; + if (!maybeRoot) { // Initial mount - root = container._reactRootContainer = legacyCreateRootFromDOMContainer( + root = legacyCreateRootFromDOMContainer( container, + children, + parentComponent, + callback, forceHydrate, ); - fiberRoot = root; - if (typeof callback === 'function') { - const originalCallback = callback; - callback = function() { - const instance = getPublicRootInstance(fiberRoot); - originalCallback.call(instance); - }; - } - // Initial mount should not be batched. - flushSync(() => { - updateContainer(children, fiberRoot, parentComponent, callback); - }); } else { - fiberRoot = root; + root = maybeRoot; if (typeof callback === 'function') { const originalCallback = callback; callback = function() { - const instance = getPublicRootInstance(fiberRoot); + const instance = getPublicRootInstance(root); originalCallback.call(instance); }; } // Update - updateContainer(children, fiberRoot, parentComponent, callback); + updateContainer(children, root, parentComponent, callback); } - return getPublicRootInstance(fiberRoot); + return getPublicRootInstance(root); } export function findDOMNode( diff --git a/packages/react-dom/src/client/ReactDOMRoot.js b/packages/react-dom/src/client/ReactDOMRoot.js index 33074054f1fe5..c7820a703a0b0 100644 --- a/packages/react-dom/src/client/ReactDOMRoot.js +++ b/packages/react-dom/src/client/ReactDOMRoot.js @@ -224,7 +224,6 @@ export function createRoot( const root = createContainer( container, ConcurrentRoot, - false, null, isStrictMode, concurrentUpdatesByDefaultOverride, @@ -303,6 +302,7 @@ export function hydrateRoot( const root = createHydrationContainer( initialChildren, + null, container, ConcurrentRoot, hydrationCallbacks, diff --git a/packages/react-dom/src/events/ReactDOMEventListener.js b/packages/react-dom/src/events/ReactDOMEventListener.js index f8b8231f8f40e..e2974586ec6ac 100644 --- a/packages/react-dom/src/events/ReactDOMEventListener.js +++ b/packages/react-dom/src/events/ReactDOMEventListener.js @@ -53,6 +53,7 @@ import { setCurrentUpdatePriority, } from 'react-reconciler/src/ReactEventPriorities'; import ReactSharedInternals from 'shared/ReactSharedInternals'; +import {isRootDehydrated} from 'react-reconciler/src/ReactFiberShellHydration'; const {ReactCurrentBatchConfig} = ReactSharedInternals; @@ -386,7 +387,7 @@ export function findInstanceBlockingEvent( targetInst = null; } else if (tag === HostRoot) { const root: FiberRoot = nearestMounted.stateNode; - if (root.isDehydrated) { + if (isRootDehydrated(root)) { // If this happens during a replay something went wrong and it might block // the whole system. return getContainerFromFiber(nearestMounted); diff --git a/packages/react-dom/src/events/ReactDOMEventReplaying.js b/packages/react-dom/src/events/ReactDOMEventReplaying.js index 744f5dfda9d9b..9de82a99a7be3 100644 --- a/packages/react-dom/src/events/ReactDOMEventReplaying.js +++ b/packages/react-dom/src/events/ReactDOMEventReplaying.js @@ -39,6 +39,7 @@ import { } from '../client/ReactDOMComponentTree'; import {HostRoot, SuspenseComponent} from 'react-reconciler/src/ReactWorkTags'; import {isHigherEventPriority} from 'react-reconciler/src/ReactEventPriorities'; +import {isRootDehydrated} from 'react-reconciler/src/ReactFiberShellHydration'; let _attemptSynchronousHydration: (fiber: Object) => void; @@ -414,7 +415,7 @@ function attemptExplicitHydrationTarget( } } else if (tag === HostRoot) { const root: FiberRoot = nearestMounted.stateNode; - if (root.isDehydrated) { + if (isRootDehydrated(root)) { queuedTarget.blockedOn = getContainerFromFiber(nearestMounted); // We don't currently have a way to increase the priority of // a root other than sync. diff --git a/packages/react-native-renderer/src/ReactFabric.js b/packages/react-native-renderer/src/ReactFabric.js index 127b20fd5dacf..e08a98653fb6c 100644 --- a/packages/react-native-renderer/src/ReactFabric.js +++ b/packages/react-native-renderer/src/ReactFabric.js @@ -215,7 +215,6 @@ function render( root = createContainer( containerTag, concurrentRoot ? ConcurrentRoot : LegacyRoot, - false, null, false, null, diff --git a/packages/react-native-renderer/src/ReactNativeRenderer.js b/packages/react-native-renderer/src/ReactNativeRenderer.js index 1dca2cd7a1d93..e751195dda00a 100644 --- a/packages/react-native-renderer/src/ReactNativeRenderer.js +++ b/packages/react-native-renderer/src/ReactNativeRenderer.js @@ -211,7 +211,6 @@ function render( root = createContainer( containerTag, LegacyRoot, - false, null, false, null, diff --git a/packages/react-noop-renderer/src/createReactNoop.js b/packages/react-noop-renderer/src/createReactNoop.js index e0ba72076a1af..8e4050dcfa336 100644 --- a/packages/react-noop-renderer/src/createReactNoop.js +++ b/packages/react-noop-renderer/src/createReactNoop.js @@ -974,7 +974,6 @@ function createReactNoop(reconciler: Function, useMutation: boolean) { root = NoopRenderer.createContainer( container, tag, - false, null, null, false, @@ -996,7 +995,6 @@ function createReactNoop(reconciler: Function, useMutation: boolean) { const fiberRoot = NoopRenderer.createContainer( container, ConcurrentRoot, - false, null, null, false, @@ -1029,7 +1027,6 @@ function createReactNoop(reconciler: Function, useMutation: boolean) { const fiberRoot = NoopRenderer.createContainer( container, LegacyRoot, - false, null, null, false, diff --git a/packages/react-reconciler/src/ReactFiberBeginWork.new.js b/packages/react-reconciler/src/ReactFiberBeginWork.new.js index 4bae5d0b7b982..6fee8d948ebe2 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,9 +33,11 @@ import type { SpawnedCachePool, } from './ReactFiberCacheComponent.new'; import type {UpdateQueue} from './ReactUpdateQueue.new'; +import type {RootState} from './ReactFiberRoot.new'; import { enableSuspenseAvoidThisFallback, enableCPUSuspense, + enableUseMutableSource, } from 'shared/ReactFeatureFlags'; import checkPropTypes from 'shared/checkPropTypes'; @@ -1311,14 +1317,9 @@ function pushHostRootContext(workInProgress) { function updateHostRoot(current, workInProgress, renderLanes) { pushHostRootContext(workInProgress); - const updateQueue = workInProgress.updateQueue; - if (current === null || updateQueue === null) { - throw new Error( - 'If the root does not have an updateQueue, we should have already ' + - 'bailed out. This error is likely caused by a bug in React. Please ' + - 'file an issue.', - ); + if (current === null) { + throw new Error('Should have a current fiber. This is a bug in React.'); } const nextProps = workInProgress.pendingProps; @@ -1326,8 +1327,8 @@ 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; if (enableCache) { @@ -1341,64 +1342,127 @@ 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 (root.isDehydrated && 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. + + // 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, + }; + const updateQueue: UpdateQueue = (workInProgress.updateQueue: any); + // `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; + + 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, + 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, + nextChildren, + renderLanes, + recoverableError, + ); + } else { + // The outermost shell has not hydrated yet. Start hydrating. + enterHydrationState(workInProgress); + if (enableUseMutableSource && 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, + nextChildren: ReactNodeList, + renderLanes: Lanes, + recoverableError: Error, +) { + // Revert to client rendering. + resetHydrationState(); + + queueHydrationError(recoverableError); + + workInProgress.flags |= ForceClientRender; + + 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 583539dd08b52..fc4912e7ec393 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,9 +33,11 @@ import type { SpawnedCachePool, } from './ReactFiberCacheComponent.old'; import type {UpdateQueue} from './ReactUpdateQueue.old'; +import type {RootState} from './ReactFiberRoot.old'; import { enableSuspenseAvoidThisFallback, enableCPUSuspense, + enableUseMutableSource, } from 'shared/ReactFeatureFlags'; import checkPropTypes from 'shared/checkPropTypes'; @@ -1311,14 +1317,9 @@ function pushHostRootContext(workInProgress) { function updateHostRoot(current, workInProgress, renderLanes) { pushHostRootContext(workInProgress); - const updateQueue = workInProgress.updateQueue; - if (current === null || updateQueue === null) { - throw new Error( - 'If the root does not have an updateQueue, we should have already ' + - 'bailed out. This error is likely caused by a bug in React. Please ' + - 'file an issue.', - ); + if (current === null) { + throw new Error('Should have a current fiber. This is a bug in React.'); } const nextProps = workInProgress.pendingProps; @@ -1326,8 +1327,8 @@ 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; if (enableCache) { @@ -1341,64 +1342,127 @@ 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 (root.isDehydrated && 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. + + // 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, + }; + const updateQueue: UpdateQueue = (workInProgress.updateQueue: any); + // `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; + + 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, + 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, + nextChildren, + renderLanes, + recoverableError, + ); + } else { + // The outermost shell has not hydrated yet. Start hydrating. + enterHydrationState(workInProgress); + if (enableUseMutableSource && 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, + nextChildren: ReactNodeList, + renderLanes: Lanes, + recoverableError: Error, +) { + // Revert to client rendering. + resetHydrationState(); + + queueHydrationError(recoverableError); + + workInProgress.flags |= ForceClientRender; + + 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 7a941dc15f401..fb60401095184 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, @@ -1869,11 +1870,12 @@ function commitWork(current: Fiber | null, finishedWork: Fiber): void { } case HostRoot: { if (supportsHydration) { - const root: FiberRoot = finishedWork.stateNode; - if (root.isDehydrated) { - // 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; @@ -1977,11 +1979,12 @@ function commitWork(current: Fiber | null, finishedWork: Fiber): void { } case HostRoot: { if (supportsHydration) { - const root: FiberRoot = finishedWork.stateNode; - if (root.isDehydrated) { - // 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 e4b2c3a5387a1..e962039d9ca9c 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, @@ -1869,11 +1870,12 @@ function commitWork(current: Fiber | null, finishedWork: Fiber): void { } case HostRoot: { if (supportsHydration) { - const root: FiberRoot = finishedWork.stateNode; - if (root.isDehydrated) { - // 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; @@ -1977,11 +1979,12 @@ function commitWork(current: Fiber | null, finishedWork: Fiber): void { } case HostRoot: { if (supportsHydration) { - const root: FiberRoot = finishedWork.stateNode; - if (root.isDehydrated) { - // 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 2a44cf94a14aa..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, @@ -890,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 (!fiberRoot.isDehydrated) { - // 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 f02a20222d0fe..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, @@ -890,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 (!fiberRoot.isDehydrated) { - // 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 8607b227e9b40..136276637d3ea 100644 --- a/packages/react-reconciler/src/ReactFiberReconciler.new.js +++ b/packages/react-reconciler/src/ReactFiberReconciler.new.js @@ -48,6 +48,7 @@ import { isContextProvider as isLegacyContextProvider, } from './ReactFiberContext.new'; import {createFiberRoot} from './ReactFiberRoot.new'; +import {isRootDehydrated} from './ReactFiberShellHydration'; import { injectInternals, markRenderScheduled, @@ -245,9 +246,6 @@ function findHostInstanceWithWarning( export function createContainer( containerInfo: Container, tag: RootTag, - // TODO: We can remove hydration-specific stuff from createContainer once - // we delete legacy mode. The new root API uses createHydrationContainer. - hydrate: boolean, hydrationCallbacks: null | SuspenseHydrationCallbacks, isStrictMode: boolean, concurrentUpdatesByDefaultOverride: null | boolean, @@ -255,10 +253,13 @@ export function createContainer( onRecoverableError: (error: mixed) => void, transitionCallbacks: null | TransitionTracingCallbacks, ): OpaqueRoot { + const hydrate = false; + const initialChildren = null; return createFiberRoot( containerInfo, tag, hydrate, + initialChildren, hydrationCallbacks, isStrictMode, concurrentUpdatesByDefaultOverride, @@ -270,6 +271,8 @@ export function createContainer( export function createHydrationContainer( initialChildren: ReactNodeList, + // TODO: Remove `callback` when we delete legacy mode. + callback: ?Function, containerInfo: Container, tag: RootTag, hydrationCallbacks: null | SuspenseHydrationCallbacks, @@ -284,6 +287,7 @@ export function createHydrationContainer( containerInfo, tag, hydrate, + initialChildren, hydrationCallbacks, isStrictMode, concurrentUpdatesByDefaultOverride, @@ -298,13 +302,15 @@ export function createHydrationContainer( // Schedule the initial render. In a hydration root, this is different from // a regular update because the initial render must match was was rendered // on the server. + // NOTE: This update intentionally doesn't have a payload. We're only using + // the update to schedule work on the root fiber (and, for legacy roots, to + // enqueue the callback if one is provided). const current = root.current; 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.callback = + callback !== undefined && callback !== null ? callback : null; enqueueUpdate(current, update, lane); scheduleInitialHydrationOnRoot(root, lane, eventTime); @@ -409,7 +415,7 @@ export function attemptSynchronousHydration(fiber: Fiber): void { switch (fiber.tag) { case HostRoot: const root: FiberRoot = fiber.stateNode; - if (root.isDehydrated) { + if (isRootDehydrated(root)) { // Flush the first scheduled "update". const lanes = getHighestPriorityPendingLanes(root); flushRoot(root, lanes); diff --git a/packages/react-reconciler/src/ReactFiberReconciler.old.js b/packages/react-reconciler/src/ReactFiberReconciler.old.js index 4970b685b1c1a..e014519320a51 100644 --- a/packages/react-reconciler/src/ReactFiberReconciler.old.js +++ b/packages/react-reconciler/src/ReactFiberReconciler.old.js @@ -48,6 +48,7 @@ import { isContextProvider as isLegacyContextProvider, } from './ReactFiberContext.old'; import {createFiberRoot} from './ReactFiberRoot.old'; +import {isRootDehydrated} from './ReactFiberShellHydration'; import { injectInternals, markRenderScheduled, @@ -245,9 +246,6 @@ function findHostInstanceWithWarning( export function createContainer( containerInfo: Container, tag: RootTag, - // TODO: We can remove hydration-specific stuff from createContainer once - // we delete legacy mode. The new root API uses createHydrationContainer. - hydrate: boolean, hydrationCallbacks: null | SuspenseHydrationCallbacks, isStrictMode: boolean, concurrentUpdatesByDefaultOverride: null | boolean, @@ -255,10 +253,13 @@ export function createContainer( onRecoverableError: (error: mixed) => void, transitionCallbacks: null | TransitionTracingCallbacks, ): OpaqueRoot { + const hydrate = false; + const initialChildren = null; return createFiberRoot( containerInfo, tag, hydrate, + initialChildren, hydrationCallbacks, isStrictMode, concurrentUpdatesByDefaultOverride, @@ -270,6 +271,8 @@ export function createContainer( export function createHydrationContainer( initialChildren: ReactNodeList, + // TODO: Remove `callback` when we delete legacy mode. + callback: ?Function, containerInfo: Container, tag: RootTag, hydrationCallbacks: null | SuspenseHydrationCallbacks, @@ -284,6 +287,7 @@ export function createHydrationContainer( containerInfo, tag, hydrate, + initialChildren, hydrationCallbacks, isStrictMode, concurrentUpdatesByDefaultOverride, @@ -298,13 +302,15 @@ export function createHydrationContainer( // Schedule the initial render. In a hydration root, this is different from // a regular update because the initial render must match was was rendered // on the server. + // NOTE: This update intentionally doesn't have a payload. We're only using + // the update to schedule work on the root fiber (and, for legacy roots, to + // enqueue the callback if one is provided). const current = root.current; 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.callback = + callback !== undefined && callback !== null ? callback : null; enqueueUpdate(current, update, lane); scheduleInitialHydrationOnRoot(root, lane, eventTime); @@ -409,7 +415,7 @@ export function attemptSynchronousHydration(fiber: Fiber): void { switch (fiber.tag) { case HostRoot: const root: FiberRoot = fiber.stateNode; - if (root.isDehydrated) { + if (isRootDehydrated(root)) { // Flush the first scheduled "update". const lanes = getHighestPriorityPendingLanes(root); flushRoot(root, lanes); 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 new file mode 100644 index 0000000000000..caadb978f69d0 --- /dev/null +++ b/packages/react-reconciler/src/ReactFiberShellHydration.js @@ -0,0 +1,19 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + */ + +import 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) { + 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 7223ad7d052b0..558440effa77a 100644 --- a/packages/react-reconciler/src/ReactFiberWorkLoop.new.js +++ b/packages/react-reconciler/src/ReactFiberWorkLoop.new.js @@ -88,6 +88,7 @@ import { createWorkInProgress, assignFiberPropertiesInDEV, } from './ReactFiber.new'; +import {isRootDehydrated} from './ReactFiberShellHydration'; import {NoMode, ProfileMode, ConcurrentMode} from './ReactTypeOfMode'; import { HostRoot, @@ -109,6 +110,7 @@ import { StoreConsistency, HostEffectMask, Hydrating, + ForceClientRender, BeforeMutationMask, MutationMask, LayoutMask, @@ -581,34 +583,7 @@ export function scheduleUpdateOnFiber( } } - if (root.isDehydrated && 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 @@ -1016,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. - if (root.isDehydrated) { - root.isDehydrated = false; + + // Before rendering again, save the errors from the previous attempt. + const errorsFromFirstAttempt = workInProgressRootConcurrentErrors; + + if (isRootDehydrated(root)) { + // 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. @@ -1453,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; @@ -1479,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; @@ -1495,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 d8bb6b16e29fb..c1c090d82b0b5 100644 --- a/packages/react-reconciler/src/ReactFiberWorkLoop.old.js +++ b/packages/react-reconciler/src/ReactFiberWorkLoop.old.js @@ -88,6 +88,7 @@ import { createWorkInProgress, assignFiberPropertiesInDEV, } from './ReactFiber.old'; +import {isRootDehydrated} from './ReactFiberShellHydration'; import {NoMode, ProfileMode, ConcurrentMode} from './ReactTypeOfMode'; import { HostRoot, @@ -109,6 +110,7 @@ import { StoreConsistency, HostEffectMask, Hydrating, + ForceClientRender, BeforeMutationMask, MutationMask, LayoutMask, @@ -581,34 +583,7 @@ export function scheduleUpdateOnFiber( } } - if (root.isDehydrated && 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 @@ -1016,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. - if (root.isDehydrated) { - root.isDehydrated = false; + + // Before rendering again, save the errors from the previous attempt. + const errorsFromFirstAttempt = workInProgressRootConcurrentErrors; + + if (isRootDehydrated(root)) { + // 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. @@ -1453,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; @@ -1479,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; @@ -1495,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< diff --git a/packages/react-reconciler/src/__tests__/ReactFiberHostContext-test.internal.js b/packages/react-reconciler/src/__tests__/ReactFiberHostContext-test.internal.js index d0c3d5b236ea4..82e23de9965da 100644 --- a/packages/react-reconciler/src/__tests__/ReactFiberHostContext-test.internal.js +++ b/packages/react-reconciler/src/__tests__/ReactFiberHostContext-test.internal.js @@ -72,7 +72,6 @@ describe('ReactFiberHostContext', () => { const container = Renderer.createContainer( /* root: */ null, ConcurrentRoot, - false, null, false, '', @@ -136,7 +135,6 @@ describe('ReactFiberHostContext', () => { const container = Renderer.createContainer( rootContext, ConcurrentRoot, - false, null, false, '', diff --git a/packages/react-test-renderer/src/ReactTestRenderer.js b/packages/react-test-renderer/src/ReactTestRenderer.js index e850086439a67..76911d701de79 100644 --- a/packages/react-test-renderer/src/ReactTestRenderer.js +++ b/packages/react-test-renderer/src/ReactTestRenderer.js @@ -473,7 +473,6 @@ function create(element: React$Element, options: TestRendererOptions) { let root: FiberRoot | null = createContainer( container, isConcurrent ? ConcurrentRoot : LegacyRoot, - false, null, isStrictMode, concurrentUpdatesByDefault,