From 1492f4ba8cf425b7778e43574f1a59962999946a Mon Sep 17 00:00:00 2001 From: Andrew Clark Date: Sun, 25 Jul 2021 16:03:11 -0400 Subject: [PATCH] [Fabric] Use container node to hide/show tree MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This changes how we hide and show the contents of Offscreen boundaries in the React Fabric renderer (persistent mode), and also Suspense boundaries which use the same feature.= The way it used to work was that when a boundary is hidden, in the complete phase, instead of calling the normal `cloneInstance` method inside `appendAllChildren`, we would call a forked method called `cloneHiddenInstance` for each of the nearest host nodes within the subtree. This design was largely based on how it works in React DOM (mutation mode), where instead of cloning the nearest host nodes, we mutate their `style.display` property. The motivation for doing it this way in React DOM was because there's no built-in browser API for hiding a collection of DOM nodes without affecting their layout. In Fabric, however, there is no such limitation, so we can instead wrap in an extra host node and apply a hidden style. The immediate motivation for this change is that Fabric on Android has a view pooling mechanism for instances that relies on the assumption that a current Fiber that is cloned and replaced by a new Fiber will never appear in a future commit. When this assumption is broken, it may cause crashes. In the current implementation, that can indeed happen when a node that was previously hidden is toggled back to visible. Although this change sidesteps the issue, we may introduce in other features in the future that would benefit from being able to revert back to an older node without cloning it again, such as animations. The way I've implemented this is to insert an additional HostComponent fiber as the child of each OffscreenComponent. The extra fiber is not ideal — the way I'd prefer to do it is to attach the host instance to the OffscreenComponent. However, the native Fabric implementation currently expects a 1:1 correspondence between HostComponents and host instances, so I've deferred that optimization to a future PR to derisk fixing the Fabric pooling crash. I left a TODO in the host config with a description of the remaining steps, but this alone should be sufficient to unblock. --- .../src/ReactFabricHostConfig.js | 47 ++--- .../src/createReactNoop.js | 184 ++++++++++++++---- .../react-reconciler/src/ReactFiber.new.js | 25 ++- .../react-reconciler/src/ReactFiber.old.js | 25 ++- .../src/ReactFiberBeginWork.new.js | 126 +++++++++++- .../src/ReactFiberBeginWork.old.js | 126 +++++++++++- .../src/ReactFiberCompleteWork.new.js | 135 ++++++------- .../src/ReactFiberCompleteWork.old.js | 135 ++++++------- .../ReactFiberHostConfigWithNoPersistence.js | 4 +- .../src/ReactFiberOffscreenComponent.js | 4 +- .../src/forks/ReactFiberHostConfig.custom.js | 6 +- packages/shared/ReactTypes.js | 5 + 12 files changed, 613 insertions(+), 209 deletions(-) diff --git a/packages/react-native-renderer/src/ReactFabricHostConfig.js b/packages/react-native-renderer/src/ReactFabricHostConfig.js index 842a33291fb07..45f11a0502dac 100644 --- a/packages/react-native-renderer/src/ReactFabricHostConfig.js +++ b/packages/react-native-renderer/src/ReactFabricHostConfig.js @@ -7,6 +7,7 @@ * @flow */ +import type {ReactNodeList, OffscreenMode} from 'shared/ReactTypes'; import type {ElementRef} from 'react'; import type { HostComponent, @@ -413,30 +414,32 @@ export function cloneInstance( }; } -export function cloneHiddenInstance( - instance: Instance, - type: string, - props: Props, - internalInstanceHandle: Object, -): Instance { - const viewConfig = instance.canonical.viewConfig; - const node = instance.node; - const updatePayload = create( - {style: {display: 'none'}}, - viewConfig.validAttributes, - ); - return { - node: cloneNodeWithNewProps(node, updatePayload), - canonical: instance.canonical, - }; +// TODO: These two methods should be replaced with `createOffscreenInstance` and +// `cloneOffscreenInstance`. I did it this way for now because the offscreen +// instance is stored on an extra HostComponent fiber instead of the +// OffscreenComponent fiber, and I didn't want to add an extra check to the +// generic HostComponent path. Instead we should use the OffscreenComponent +// fiber, but currently Fabric expects a 1:1 correspondence between Fabric +// instances and host fibers, so I'm leaving this optimization for later once +// we can confirm this won't break any downstream expectations. +export function getOffscreenContainerType(): string { + return 'RTCView'; } -export function cloneHiddenTextInstance( - instance: Instance, - text: string, - internalInstanceHandle: Object, -): TextInstance { - throw new Error('Not yet implemented.'); +export function getOffscreenContainerProps( + mode: OffscreenMode, + children: ReactNodeList, +): Props { + if (mode === 'hidden') { + return { + children, + style: {display: 'none'}, + }; + } else { + return { + children, + }; + } } export function createContainerChildSet(container: Container): ChildSet { diff --git a/packages/react-noop-renderer/src/createReactNoop.js b/packages/react-noop-renderer/src/createReactNoop.js index 652f32521c249..a05bc87abed57 100644 --- a/packages/react-noop-renderer/src/createReactNoop.js +++ b/packages/react-noop-renderer/src/createReactNoop.js @@ -16,7 +16,7 @@ import type {Fiber} from 'react-reconciler/src/ReactInternalTypes'; import type {UpdateQueue} from 'react-reconciler/src/ReactUpdateQueue'; -import type {ReactNodeList} from 'shared/ReactTypes'; +import type {ReactNodeList, OffscreenMode} from 'shared/ReactTypes'; import type {RootTag} from 'react-reconciler/src/ReactRootTags'; import * as Scheduler from 'scheduler/unstable_mock'; @@ -258,6 +258,9 @@ function createReactNoop(reconciler: Function, useMutation: boolean) { type: string, rootcontainerInstance: Container, ) { + if (type === 'offscreen') { + return parentHostContext; + } if (type === 'uppercase') { return UPPERCASE_CONTEXT; } @@ -539,47 +542,18 @@ function createReactNoop(reconciler: Function, useMutation: boolean) { container.children = newChildren; }, - cloneHiddenInstance( - instance: Instance, - type: string, - props: Props, - internalInstanceHandle: Object, - ): Instance { - const clone = cloneInstance( - instance, - null, - type, - props, - props, - internalInstanceHandle, - true, - null, - ); - clone.hidden = true; - return clone; + getOffscreenContainerType(): string { + return 'offscreen'; }, - cloneHiddenTextInstance( - instance: TextInstance, - text: string, - internalInstanceHandle: Object, - ): TextInstance { - const clone = { - text: instance.text, - id: instanceCounter++, - hidden: true, - context: instance.context, + getOffscreenContainerProps( + mode: OffscreenMode, + children: ReactNodeList, + ): Props { + return { + hidden: mode === 'hidden', + children, }; - // Hide from unit tests - Object.defineProperty(clone, 'id', { - value: clone.id, - enumerable: false, - }); - Object.defineProperty(clone, 'context', { - value: clone.context, - enumerable: false, - }); - return clone; }, }; @@ -646,7 +620,9 @@ function createReactNoop(reconciler: Function, useMutation: boolean) { function getChildren(root) { if (root) { - return root.children; + return useMutation + ? root.children + : removeOffscreenContainersFromChildren(root.children, false); } else { return null; } @@ -654,12 +630,138 @@ function createReactNoop(reconciler: Function, useMutation: boolean) { function getPendingChildren(root) { if (root) { - return root.pendingChildren; + return useMutation + ? root.children + : removeOffscreenContainersFromChildren(root.pendingChildren, false); } else { return null; } } + function removeOffscreenContainersFromChildren(children, hideNearestNode) { + // Mutation mode and persistent mode have different outputs for Offscreen + // and Suspense trees. Persistent mode adds an additional host node wrapper, + // whereas mutation mode does not. + // + // This function removes the offscreen host wrappers so that the output is + // consistent in both modes. That way our tests don't have to fork tree + // assertions based on the renderer mode. + // + // We don't mutate the original tree, but instead return a copy. This + // function is only used in our test assertions. + let didClone = false; + const newChildren = []; + for (let i = 0; i < children.length; i++) { + const child = children[i]; + const innerChildren = child.children; + if (innerChildren !== undefined) { + // This is a host instance instance + const instance: Instance = (child: any); + if (instance.type === 'offscreen') { + // This is an offscreen wrapper instance. Remove it from the tree + // and recursively return its children, as if it were a fragment. + didClone = true; + if (instance.text !== null) { + // If this offscreen tree contains only text, we replace it with + // a text child. Related to `shouldReplaceTextContent` feature. + const offscreenTextInstance: TextInstance = { + text: instance.text, + id: instanceCounter++, + hidden: hideNearestNode || instance.hidden, + context: instance.context, + }; + // Hide from unit tests + Object.defineProperty(offscreenTextInstance, 'id', { + value: offscreenTextInstance.id, + enumerable: false, + }); + Object.defineProperty(offscreenTextInstance, 'context', { + value: offscreenTextInstance.context, + enumerable: false, + }); + newChildren.push(offscreenTextInstance); + } else { + // Skip the offscreen node and replace it with its children + const offscreenChildren = removeOffscreenContainersFromChildren( + innerChildren, + hideNearestNode || instance.hidden, + ); + newChildren.push.apply(newChildren, offscreenChildren); + } + } else { + // This is a regular (non-offscreen) instance. If the nearest + // offscreen boundary is hidden, hide this node. + const hidden = hideNearestNode ? true : instance.hidden; + const clonedChildren = removeOffscreenContainersFromChildren( + instance.children, + // We never need to hide the children of this node, since if we're + // inside a hidden tree, then the hidden style will be applied to + // this node. + false, + ); + if ( + clonedChildren === instance.children && + hidden === instance.hidden + ) { + // No changes. Reuse the original instance without cloning. + newChildren.push(instance); + } else { + didClone = true; + const clone: Instance = { + id: instance.id, + type: instance.type, + children: clonedChildren, + text: instance.text, + prop: instance.prop, + hidden: hideNearestNode ? true : instance.hidden, + context: instance.context, + }; + Object.defineProperty(clone, 'id', { + value: clone.id, + enumerable: false, + }); + Object.defineProperty(clone, 'text', { + value: clone.text, + enumerable: false, + }); + Object.defineProperty(clone, 'context', { + value: clone.context, + enumerable: false, + }); + newChildren.push(clone); + } + } + } else { + // This is a text instance + const textInstance: TextInstance = (child: any); + if (hideNearestNode) { + didClone = true; + const clone = { + text: textInstance.text, + id: textInstance.id, + hidden: textInstance.hidden || hideNearestNode, + context: textInstance.context, + }; + Object.defineProperty(clone, 'id', { + value: clone.id, + enumerable: false, + }); + Object.defineProperty(clone, 'context', { + value: clone.context, + enumerable: false, + }); + + newChildren.push(clone); + } else { + newChildren.push(textInstance); + } + } + } + // There are some tests that assume reference equality, so preserve it + // when possible. An alternative would + return didClone ? newChildren : children; + } + function getChildrenAsJSX(root) { const children = childToJSX(getChildren(root), null); if (children === null) { diff --git a/packages/react-reconciler/src/ReactFiber.new.js b/packages/react-reconciler/src/ReactFiber.new.js index a18514c50c0f2..cbbbaa623385e 100644 --- a/packages/react-reconciler/src/ReactFiber.new.js +++ b/packages/react-reconciler/src/ReactFiber.new.js @@ -14,7 +14,7 @@ import type {RootTag} from './ReactRootTags'; import type {WorkTag} from './ReactWorkTags'; import type {TypeOfMode} from './ReactTypeOfMode'; import type {Lanes} from './ReactFiberLane.new'; -import type {SuspenseInstance} from './ReactFiberHostConfig'; +import type {SuspenseInstance, Props} from './ReactFiberHostConfig'; import type {OffscreenProps} from './ReactFiberOffscreenComponent'; import invariant from 'shared/invariant'; @@ -27,6 +27,10 @@ import { enableSyncDefaultUpdates, allowConcurrentByDefault, } from 'shared/ReactFeatureFlags'; +import { + supportsPersistence, + getOffscreenContainerType, +} from './ReactFiberHostConfig'; import {NoFlags, Placement, StaticMask} from './ReactFiberFlags'; import {ConcurrentRoot} from './ReactRootTags'; import { @@ -585,6 +589,25 @@ export function createFiberFromTypeAndProps( return fiber; } +export function createOffscreenHostContainerFiber( + props: Props, + fiberMode: TypeOfMode, + lanes: Lanes, + key: null | string, +): Fiber { + if (supportsPersistence) { + const type = getOffscreenContainerType(); + const fiber = createFiber(HostComponent, props, key, fiberMode); + fiber.elementType = type; + fiber.type = type; + fiber.lanes = lanes; + return fiber; + } else { + // Only implemented in persistent mode + invariant(false, 'Not implemented.'); + } +} + export function createFiberFromElement( element: ReactElement, mode: TypeOfMode, diff --git a/packages/react-reconciler/src/ReactFiber.old.js b/packages/react-reconciler/src/ReactFiber.old.js index 4fbbb5a00efca..6d99c6837ed21 100644 --- a/packages/react-reconciler/src/ReactFiber.old.js +++ b/packages/react-reconciler/src/ReactFiber.old.js @@ -14,7 +14,7 @@ import type {RootTag} from './ReactRootTags'; import type {WorkTag} from './ReactWorkTags'; import type {TypeOfMode} from './ReactTypeOfMode'; import type {Lanes} from './ReactFiberLane.old'; -import type {SuspenseInstance} from './ReactFiberHostConfig'; +import type {SuspenseInstance, Props} from './ReactFiberHostConfig'; import type {OffscreenProps} from './ReactFiberOffscreenComponent'; import invariant from 'shared/invariant'; @@ -27,6 +27,10 @@ import { enableSyncDefaultUpdates, allowConcurrentByDefault, } from 'shared/ReactFeatureFlags'; +import { + supportsPersistence, + getOffscreenContainerType, +} from './ReactFiberHostConfig'; import {NoFlags, Placement, StaticMask} from './ReactFiberFlags'; import {ConcurrentRoot} from './ReactRootTags'; import { @@ -585,6 +589,25 @@ export function createFiberFromTypeAndProps( return fiber; } +export function createOffscreenHostContainerFiber( + props: Props, + fiberMode: TypeOfMode, + lanes: Lanes, + key: null | string, +): Fiber { + if (supportsPersistence) { + const type = getOffscreenContainerType(); + const fiber = createFiber(HostComponent, props, key, fiberMode); + fiber.elementType = type; + fiber.type = type; + fiber.lanes = lanes; + return fiber; + } else { + // Only implemented in persistent mode + invariant(false, 'Not implemented.'); + } +} + export function createFiberFromElement( element: ReactElement, mode: TypeOfMode, diff --git a/packages/react-reconciler/src/ReactFiberBeginWork.new.js b/packages/react-reconciler/src/ReactFiberBeginWork.new.js index adce432d04a79..0b0c763e4fecc 100644 --- a/packages/react-reconciler/src/ReactFiberBeginWork.new.js +++ b/packages/react-reconciler/src/ReactFiberBeginWork.new.js @@ -142,6 +142,9 @@ import { registerSuspenseInstanceRetry, supportsHydration, isPrimaryRenderer, + supportsMutation, + supportsPersistence, + getOffscreenContainerProps, } from './ReactFiberHostConfig'; import type {SuspenseInstance} from './ReactFiberHostConfig'; import {shouldError, shouldSuspend} from './ReactFiberReconciler'; @@ -199,6 +202,7 @@ import { createFiberFromFragment, createFiberFromOffscreen, createWorkInProgress, + createOffscreenHostContainerFiber, isSimpleFunctionComponent, } from './ReactFiber.new'; import { @@ -224,6 +228,7 @@ import { } from './ReactFiberCacheComponent.new'; import {createCapturedValue} from './ReactCapturedValue'; import {createClassErrorUpdate} from './ReactFiberThrow.new'; +import {completeSuspendedOffscreenHostContainer} from './ReactFiberCompleteWork.new'; import is from 'shared/objectIs'; import {disableLogs, reenableLogs} from 'shared/ConsolePatchingDev'; @@ -728,8 +733,69 @@ function updateOffscreenComponent( workInProgress.updateQueue = spawnedCachePool; } - reconcileChildren(current, workInProgress, nextChildren, renderLanes); - return workInProgress.child; + if (supportsPersistence) { + // In persistent mode, the offscreen children are wrapped in a host node. + // TODO: Optimize this to use the OffscreenComponent fiber instead of + // an extra HostComponent fiber. Need to make sure this doesn't break Fabric + // or some other infra that expects a HostComponent. + const isHidden = + nextProps.mode === 'hidden' && + workInProgress.tag !== LegacyHiddenComponent; + const offscreenContainer = reconcileOffscreenHostContainer( + current, + workInProgress, + isHidden, + nextChildren, + renderLanes, + ); + return offscreenContainer; + } + if (supportsMutation) { + reconcileChildren(current, workInProgress, nextChildren, renderLanes); + return workInProgress.child; + } + return null; +} + +function reconcileOffscreenHostContainer( + currentOffscreen: Fiber | null, + offscreen: Fiber, + isHidden: boolean, + children: any, + renderLanes: Lanes, +) { + const containerProps = getOffscreenContainerProps( + isHidden ? 'hidden' : 'visible', + children, + ); + let hostContainer; + if (currentOffscreen === null) { + hostContainer = createOffscreenHostContainerFiber( + containerProps, + offscreen.mode, + renderLanes, + null, + ); + } else { + const currentHostContainer = currentOffscreen.child; + if (currentHostContainer === null) { + hostContainer = createOffscreenHostContainerFiber( + containerProps, + offscreen.mode, + renderLanes, + null, + ); + hostContainer.flags |= Placement; + } else { + hostContainer = createWorkInProgress( + currentHostContainer, + containerProps, + ); + } + } + hostContainer.return = offscreen; + offscreen.child = hostContainer; + return hostContainer; } // Note: These happen to have identical begin phases, for now. We shouldn't hold @@ -2148,6 +2214,21 @@ function mountSuspenseFallbackChildren( primaryChildFragment.childLanes = NoLanes; primaryChildFragment.pendingProps = primaryChildProps; + if ( + supportsPersistence && + (workInProgress.mode & ConcurrentMode) === NoMode + ) { + const isHidden = true; + const offscreenContainer: Fiber = (primaryChildFragment.child: any); + const containerProps = { + hidden: isHidden, + primaryChildren, + }; + offscreenContainer.pendingProps = containerProps; + offscreenContainer.memoizedProps = containerProps; + completeSuspendedOffscreenHostContainer(null, offscreenContainer); + } + if (enableProfilerTimer && workInProgress.mode & ProfileMode) { // Reset the durations from the first pass so they aren't included in the // final amounts. This seems counterintuitive, since we're intentionally @@ -2290,6 +2371,25 @@ function updateSuspenseFallbackChildren( currentPrimaryChildFragment.treeBaseDuration; } + if (supportsPersistence) { + // In persistent mode, the offscreen children are wrapped in a host node. + // We need to complete it now, because we're going to skip over its normal + // complete phase and go straight to rendering the fallback. + const isHidden = true; + const currentOffscreenContainer = currentPrimaryChildFragment.child; + const offscreenContainer: Fiber = (primaryChildFragment.child: any); + const containerProps = { + hidden: isHidden, + primaryChildren, + }; + offscreenContainer.pendingProps = containerProps; + offscreenContainer.memoizedProps = containerProps; + completeSuspendedOffscreenHostContainer( + currentOffscreenContainer, + offscreenContainer, + ); + } + // The fallback fiber was added as a deletion during the first pass. // However, since we're going to remain on the fallback, we no longer want // to delete it. @@ -2300,6 +2400,28 @@ function updateSuspenseFallbackChildren( primaryChildProps, ); + if (supportsPersistence) { + // In persistent mode, the offscreen children are wrapped in a host node. + // We need to complete it now, because we're going to skip over its normal + // complete phase and go straight to rendering the fallback. + const currentOffscreenContainer = currentPrimaryChildFragment.child; + if (currentOffscreenContainer !== null) { + const isHidden = true; + const offscreenContainer = reconcileOffscreenHostContainer( + currentPrimaryChildFragment, + primaryChildFragment, + isHidden, + primaryChildren, + renderLanes, + ); + offscreenContainer.memoizedProps = offscreenContainer.pendingProps; + completeSuspendedOffscreenHostContainer( + currentOffscreenContainer, + offscreenContainer, + ); + } + } + // Since we're reusing a current tree, we need to reuse the flags, too. // (We don't do this in legacy mode, because in legacy mode we don't re-use // the current tree; see previous branch.) diff --git a/packages/react-reconciler/src/ReactFiberBeginWork.old.js b/packages/react-reconciler/src/ReactFiberBeginWork.old.js index df061e0b22583..c54fc312f97a4 100644 --- a/packages/react-reconciler/src/ReactFiberBeginWork.old.js +++ b/packages/react-reconciler/src/ReactFiberBeginWork.old.js @@ -142,6 +142,9 @@ import { registerSuspenseInstanceRetry, supportsHydration, isPrimaryRenderer, + supportsMutation, + supportsPersistence, + getOffscreenContainerProps, } from './ReactFiberHostConfig'; import type {SuspenseInstance} from './ReactFiberHostConfig'; import {shouldError, shouldSuspend} from './ReactFiberReconciler'; @@ -199,6 +202,7 @@ import { createFiberFromFragment, createFiberFromOffscreen, createWorkInProgress, + createOffscreenHostContainerFiber, isSimpleFunctionComponent, } from './ReactFiber.old'; import { @@ -224,6 +228,7 @@ import { } from './ReactFiberCacheComponent.old'; import {createCapturedValue} from './ReactCapturedValue'; import {createClassErrorUpdate} from './ReactFiberThrow.old'; +import {completeSuspendedOffscreenHostContainer} from './ReactFiberCompleteWork.old'; import is from 'shared/objectIs'; import {disableLogs, reenableLogs} from 'shared/ConsolePatchingDev'; @@ -728,8 +733,69 @@ function updateOffscreenComponent( workInProgress.updateQueue = spawnedCachePool; } - reconcileChildren(current, workInProgress, nextChildren, renderLanes); - return workInProgress.child; + if (supportsPersistence) { + // In persistent mode, the offscreen children are wrapped in a host node. + // TODO: Optimize this to use the OffscreenComponent fiber instead of + // an extra HostComponent fiber. Need to make sure this doesn't break Fabric + // or some other infra that expects a HostComponent. + const isHidden = + nextProps.mode === 'hidden' && + workInProgress.tag !== LegacyHiddenComponent; + const offscreenContainer = reconcileOffscreenHostContainer( + current, + workInProgress, + isHidden, + nextChildren, + renderLanes, + ); + return offscreenContainer; + } + if (supportsMutation) { + reconcileChildren(current, workInProgress, nextChildren, renderLanes); + return workInProgress.child; + } + return null; +} + +function reconcileOffscreenHostContainer( + currentOffscreen: Fiber | null, + offscreen: Fiber, + isHidden: boolean, + children: any, + renderLanes: Lanes, +) { + const containerProps = getOffscreenContainerProps( + isHidden ? 'hidden' : 'visible', + children, + ); + let hostContainer; + if (currentOffscreen === null) { + hostContainer = createOffscreenHostContainerFiber( + containerProps, + offscreen.mode, + renderLanes, + null, + ); + } else { + const currentHostContainer = currentOffscreen.child; + if (currentHostContainer === null) { + hostContainer = createOffscreenHostContainerFiber( + containerProps, + offscreen.mode, + renderLanes, + null, + ); + hostContainer.flags |= Placement; + } else { + hostContainer = createWorkInProgress( + currentHostContainer, + containerProps, + ); + } + } + hostContainer.return = offscreen; + offscreen.child = hostContainer; + return hostContainer; } // Note: These happen to have identical begin phases, for now. We shouldn't hold @@ -2148,6 +2214,21 @@ function mountSuspenseFallbackChildren( primaryChildFragment.childLanes = NoLanes; primaryChildFragment.pendingProps = primaryChildProps; + if ( + supportsPersistence && + (workInProgress.mode & ConcurrentMode) === NoMode + ) { + const isHidden = true; + const offscreenContainer: Fiber = (primaryChildFragment.child: any); + const containerProps = { + hidden: isHidden, + primaryChildren, + }; + offscreenContainer.pendingProps = containerProps; + offscreenContainer.memoizedProps = containerProps; + completeSuspendedOffscreenHostContainer(null, offscreenContainer); + } + if (enableProfilerTimer && workInProgress.mode & ProfileMode) { // Reset the durations from the first pass so they aren't included in the // final amounts. This seems counterintuitive, since we're intentionally @@ -2290,6 +2371,25 @@ function updateSuspenseFallbackChildren( currentPrimaryChildFragment.treeBaseDuration; } + if (supportsPersistence) { + // In persistent mode, the offscreen children are wrapped in a host node. + // We need to complete it now, because we're going to skip over its normal + // complete phase and go straight to rendering the fallback. + const isHidden = true; + const currentOffscreenContainer = currentPrimaryChildFragment.child; + const offscreenContainer: Fiber = (primaryChildFragment.child: any); + const containerProps = { + hidden: isHidden, + primaryChildren, + }; + offscreenContainer.pendingProps = containerProps; + offscreenContainer.memoizedProps = containerProps; + completeSuspendedOffscreenHostContainer( + currentOffscreenContainer, + offscreenContainer, + ); + } + // The fallback fiber was added as a deletion during the first pass. // However, since we're going to remain on the fallback, we no longer want // to delete it. @@ -2300,6 +2400,28 @@ function updateSuspenseFallbackChildren( primaryChildProps, ); + if (supportsPersistence) { + // In persistent mode, the offscreen children are wrapped in a host node. + // We need to complete it now, because we're going to skip over its normal + // complete phase and go straight to rendering the fallback. + const currentOffscreenContainer = currentPrimaryChildFragment.child; + if (currentOffscreenContainer !== null) { + const isHidden = true; + const offscreenContainer = reconcileOffscreenHostContainer( + currentPrimaryChildFragment, + primaryChildFragment, + isHidden, + primaryChildren, + renderLanes, + ); + offscreenContainer.memoizedProps = offscreenContainer.pendingProps; + completeSuspendedOffscreenHostContainer( + currentOffscreenContainer, + offscreenContainer, + ); + } + } + // Since we're reusing a current tree, we need to reuse the flags, too. // (We don't do this in legacy mode, because in legacy mode we don't re-use // the current tree; see previous branch.) diff --git a/packages/react-reconciler/src/ReactFiberCompleteWork.new.js b/packages/react-reconciler/src/ReactFiberCompleteWork.new.js index 9416ac45dd4c5..fea034edaa67f 100644 --- a/packages/react-reconciler/src/ReactFiberCompleteWork.new.js +++ b/packages/react-reconciler/src/ReactFiberCompleteWork.new.js @@ -84,8 +84,6 @@ import { supportsMutation, supportsPersistence, cloneInstance, - cloneHiddenInstance, - cloneHiddenTextInstance, createContainerChildSet, appendChildToContainerChildSet, finalizeContainerChildren, @@ -200,12 +198,7 @@ let updateHostText; if (supportsMutation) { // Mutation mode - appendAllChildren = function( - parent: Instance, - workInProgress: Fiber, - needsVisibilityToggle: boolean, - isHidden: boolean, - ) { + appendAllChildren = function(parent: Instance, workInProgress: Fiber) { // We only have the top Fiber that was created but we need recurse down its // children to find all the terminal nodes. let node = workInProgress.child; @@ -293,49 +286,22 @@ if (supportsMutation) { } else if (supportsPersistence) { // Persistent host tree mode - appendAllChildren = function( - parent: Instance, - workInProgress: Fiber, - needsVisibilityToggle: boolean, - isHidden: boolean, - ) { + appendAllChildren = function(parent: Instance, workInProgress: Fiber) { // We only have the top Fiber that was created but we need recurse down its // children to find all the terminal nodes. let node = workInProgress.child; while (node !== null) { // eslint-disable-next-line no-labels branches: if (node.tag === HostComponent) { - let instance = node.stateNode; - if (needsVisibilityToggle && isHidden) { - // This child is inside a timed out tree. Hide it. - const props = node.memoizedProps; - const type = node.type; - instance = cloneHiddenInstance(instance, type, props, node); - } + const instance = node.stateNode; appendInitialChild(parent, instance); } else if (node.tag === HostText) { - let instance = node.stateNode; - if (needsVisibilityToggle && isHidden) { - // This child is inside a timed out tree. Hide it. - const text = node.memoizedProps; - instance = cloneHiddenTextInstance(instance, text, node); - } + const instance = node.stateNode; appendInitialChild(parent, instance); } else if (node.tag === HostPortal) { // If we have a portal child, then we don't want to traverse // down its children. Instead, we'll get insertions from each child in // the portal directly. - } else if ( - node.tag === OffscreenComponent && - node.memoizedState !== null - ) { - // The children in this boundary are hidden. Toggle their visibility - // before appending. - const child = node.child; - if (child !== null) { - child.return = node; - } - appendAllChildren(parent, node, true, true); } else if (node.child !== null) { node.child.return = node; node = node.child; @@ -361,8 +327,6 @@ if (supportsMutation) { const appendAllChildrenToContainer = function( containerChildSet: ChildSet, workInProgress: Fiber, - needsVisibilityToggle: boolean, - isHidden: boolean, ) { // We only have the top Fiber that was created but we need recurse down its // children to find all the terminal nodes. @@ -370,37 +334,15 @@ if (supportsMutation) { while (node !== null) { // eslint-disable-next-line no-labels branches: if (node.tag === HostComponent) { - let instance = node.stateNode; - if (needsVisibilityToggle && isHidden) { - // This child is inside a timed out tree. Hide it. - const props = node.memoizedProps; - const type = node.type; - instance = cloneHiddenInstance(instance, type, props, node); - } + const instance = node.stateNode; appendChildToContainerChildSet(containerChildSet, instance); } else if (node.tag === HostText) { - let instance = node.stateNode; - if (needsVisibilityToggle && isHidden) { - // This child is inside a timed out tree. Hide it. - const text = node.memoizedProps; - instance = cloneHiddenTextInstance(instance, text, node); - } + const instance = node.stateNode; appendChildToContainerChildSet(containerChildSet, instance); } else if (node.tag === HostPortal) { // If we have a portal child, then we don't want to traverse // down its children. Instead, we'll get insertions from each child in // the portal directly. - } else if ( - node.tag === OffscreenComponent && - node.memoizedState !== null - ) { - // The children in this boundary are hidden. Toggle their visibility - // before appending. - const child = node.child; - if (child !== null) { - child.return = node; - } - appendAllChildrenToContainer(containerChildSet, node, true, true); } else if (node.child !== null) { node.child.return = node; node = node.child; @@ -434,7 +376,7 @@ if (supportsMutation) { const container = portalOrRoot.containerInfo; const newChildSet = createContainerChildSet(container); // If children might have changed, we have to add them all to the set. - appendAllChildrenToContainer(newChildSet, workInProgress, false, false); + appendAllChildrenToContainer(newChildSet, workInProgress); portalOrRoot.pendingChildren = newChildSet; // Schedule an update on the container to swap out the container. markUpdate(workInProgress); @@ -507,7 +449,7 @@ if (supportsMutation) { markUpdate(workInProgress); } else { // If children might have changed, we have to add them all to the set. - appendAllChildren(newInstance, workInProgress, false, false); + appendAllChildren(newInstance, workInProgress); } }; updateHostText = function( @@ -748,6 +690,65 @@ function bubbleProperties(completedWork: Fiber) { return didBailout; } +export function completeSuspendedOffscreenHostContainer( + current: Fiber | null, + workInProgress: Fiber, +) { + // This is a fork of the complete phase for HostComponent. We use it when + // a suspense tree is in its fallback state, because in that case the primary + // tree that includes the offscreen boundary is skipped over without a + // regular complete phase. + // + // We can optimize this path further by inlining the update logic for + // offscreen instances specifically, i.e. skipping the `prepareUpdate` call. + const rootContainerInstance = getRootHostContainer(); + const type = workInProgress.type; + const newProps = workInProgress.memoizedProps; + if (current !== null) { + updateHostComponent( + current, + workInProgress, + type, + newProps, + rootContainerInstance, + ); + } else { + const currentHostContext = getHostContext(); + const instance = createInstance( + type, + newProps, + rootContainerInstance, + currentHostContext, + workInProgress, + ); + + appendAllChildren(instance, workInProgress); + + workInProgress.stateNode = instance; + + // Certain renderers require commit-time effects for initial mount. + // (eg DOM renderer supports auto-focus for certain elements). + // Make sure such renderers get scheduled for later work. + if ( + finalizeInitialChildren( + instance, + type, + newProps, + rootContainerInstance, + currentHostContext, + ) + ) { + markUpdate(workInProgress); + } + + if (workInProgress.ref !== null) { + // If there is a ref on a host node we need to schedule a callback + markRef(workInProgress); + } + } + bubbleProperties(workInProgress); +} + function completeWork( current: Fiber | null, workInProgress: Fiber, @@ -868,7 +869,7 @@ function completeWork( workInProgress, ); - appendAllChildren(instance, workInProgress, false, false); + appendAllChildren(instance, workInProgress); workInProgress.stateNode = instance; diff --git a/packages/react-reconciler/src/ReactFiberCompleteWork.old.js b/packages/react-reconciler/src/ReactFiberCompleteWork.old.js index 35d17f871ab93..4658938c9d5d4 100644 --- a/packages/react-reconciler/src/ReactFiberCompleteWork.old.js +++ b/packages/react-reconciler/src/ReactFiberCompleteWork.old.js @@ -84,8 +84,6 @@ import { supportsMutation, supportsPersistence, cloneInstance, - cloneHiddenInstance, - cloneHiddenTextInstance, createContainerChildSet, appendChildToContainerChildSet, finalizeContainerChildren, @@ -200,12 +198,7 @@ let updateHostText; if (supportsMutation) { // Mutation mode - appendAllChildren = function( - parent: Instance, - workInProgress: Fiber, - needsVisibilityToggle: boolean, - isHidden: boolean, - ) { + appendAllChildren = function(parent: Instance, workInProgress: Fiber) { // We only have the top Fiber that was created but we need recurse down its // children to find all the terminal nodes. let node = workInProgress.child; @@ -293,49 +286,22 @@ if (supportsMutation) { } else if (supportsPersistence) { // Persistent host tree mode - appendAllChildren = function( - parent: Instance, - workInProgress: Fiber, - needsVisibilityToggle: boolean, - isHidden: boolean, - ) { + appendAllChildren = function(parent: Instance, workInProgress: Fiber) { // We only have the top Fiber that was created but we need recurse down its // children to find all the terminal nodes. let node = workInProgress.child; while (node !== null) { // eslint-disable-next-line no-labels branches: if (node.tag === HostComponent) { - let instance = node.stateNode; - if (needsVisibilityToggle && isHidden) { - // This child is inside a timed out tree. Hide it. - const props = node.memoizedProps; - const type = node.type; - instance = cloneHiddenInstance(instance, type, props, node); - } + const instance = node.stateNode; appendInitialChild(parent, instance); } else if (node.tag === HostText) { - let instance = node.stateNode; - if (needsVisibilityToggle && isHidden) { - // This child is inside a timed out tree. Hide it. - const text = node.memoizedProps; - instance = cloneHiddenTextInstance(instance, text, node); - } + const instance = node.stateNode; appendInitialChild(parent, instance); } else if (node.tag === HostPortal) { // If we have a portal child, then we don't want to traverse // down its children. Instead, we'll get insertions from each child in // the portal directly. - } else if ( - node.tag === OffscreenComponent && - node.memoizedState !== null - ) { - // The children in this boundary are hidden. Toggle their visibility - // before appending. - const child = node.child; - if (child !== null) { - child.return = node; - } - appendAllChildren(parent, node, true, true); } else if (node.child !== null) { node.child.return = node; node = node.child; @@ -361,8 +327,6 @@ if (supportsMutation) { const appendAllChildrenToContainer = function( containerChildSet: ChildSet, workInProgress: Fiber, - needsVisibilityToggle: boolean, - isHidden: boolean, ) { // We only have the top Fiber that was created but we need recurse down its // children to find all the terminal nodes. @@ -370,37 +334,15 @@ if (supportsMutation) { while (node !== null) { // eslint-disable-next-line no-labels branches: if (node.tag === HostComponent) { - let instance = node.stateNode; - if (needsVisibilityToggle && isHidden) { - // This child is inside a timed out tree. Hide it. - const props = node.memoizedProps; - const type = node.type; - instance = cloneHiddenInstance(instance, type, props, node); - } + const instance = node.stateNode; appendChildToContainerChildSet(containerChildSet, instance); } else if (node.tag === HostText) { - let instance = node.stateNode; - if (needsVisibilityToggle && isHidden) { - // This child is inside a timed out tree. Hide it. - const text = node.memoizedProps; - instance = cloneHiddenTextInstance(instance, text, node); - } + const instance = node.stateNode; appendChildToContainerChildSet(containerChildSet, instance); } else if (node.tag === HostPortal) { // If we have a portal child, then we don't want to traverse // down its children. Instead, we'll get insertions from each child in // the portal directly. - } else if ( - node.tag === OffscreenComponent && - node.memoizedState !== null - ) { - // The children in this boundary are hidden. Toggle their visibility - // before appending. - const child = node.child; - if (child !== null) { - child.return = node; - } - appendAllChildrenToContainer(containerChildSet, node, true, true); } else if (node.child !== null) { node.child.return = node; node = node.child; @@ -434,7 +376,7 @@ if (supportsMutation) { const container = portalOrRoot.containerInfo; const newChildSet = createContainerChildSet(container); // If children might have changed, we have to add them all to the set. - appendAllChildrenToContainer(newChildSet, workInProgress, false, false); + appendAllChildrenToContainer(newChildSet, workInProgress); portalOrRoot.pendingChildren = newChildSet; // Schedule an update on the container to swap out the container. markUpdate(workInProgress); @@ -507,7 +449,7 @@ if (supportsMutation) { markUpdate(workInProgress); } else { // If children might have changed, we have to add them all to the set. - appendAllChildren(newInstance, workInProgress, false, false); + appendAllChildren(newInstance, workInProgress); } }; updateHostText = function( @@ -748,6 +690,65 @@ function bubbleProperties(completedWork: Fiber) { return didBailout; } +export function completeSuspendedOffscreenHostContainer( + current: Fiber | null, + workInProgress: Fiber, +) { + // This is a fork of the complete phase for HostComponent. We use it when + // a suspense tree is in its fallback state, because in that case the primary + // tree that includes the offscreen boundary is skipped over without a + // regular complete phase. + // + // We can optimize this path further by inlining the update logic for + // offscreen instances specifically, i.e. skipping the `prepareUpdate` call. + const rootContainerInstance = getRootHostContainer(); + const type = workInProgress.type; + const newProps = workInProgress.memoizedProps; + if (current !== null) { + updateHostComponent( + current, + workInProgress, + type, + newProps, + rootContainerInstance, + ); + } else { + const currentHostContext = getHostContext(); + const instance = createInstance( + type, + newProps, + rootContainerInstance, + currentHostContext, + workInProgress, + ); + + appendAllChildren(instance, workInProgress); + + workInProgress.stateNode = instance; + + // Certain renderers require commit-time effects for initial mount. + // (eg DOM renderer supports auto-focus for certain elements). + // Make sure such renderers get scheduled for later work. + if ( + finalizeInitialChildren( + instance, + type, + newProps, + rootContainerInstance, + currentHostContext, + ) + ) { + markUpdate(workInProgress); + } + + if (workInProgress.ref !== null) { + // If there is a ref on a host node we need to schedule a callback + markRef(workInProgress); + } + } + bubbleProperties(workInProgress); +} + function completeWork( current: Fiber | null, workInProgress: Fiber, @@ -868,7 +869,7 @@ function completeWork( workInProgress, ); - appendAllChildren(instance, workInProgress, false, false); + appendAllChildren(instance, workInProgress); workInProgress.stateNode = instance; diff --git a/packages/react-reconciler/src/ReactFiberHostConfigWithNoPersistence.js b/packages/react-reconciler/src/ReactFiberHostConfigWithNoPersistence.js index d5f84cf43fd6d..2dd44342a399c 100644 --- a/packages/react-reconciler/src/ReactFiberHostConfigWithNoPersistence.js +++ b/packages/react-reconciler/src/ReactFiberHostConfigWithNoPersistence.js @@ -28,5 +28,5 @@ export const createContainerChildSet = shim; export const appendChildToContainerChildSet = shim; export const finalizeContainerChildren = shim; export const replaceContainerChildren = shim; -export const cloneHiddenInstance = shim; -export const cloneHiddenTextInstance = shim; +export const getOffscreenContainerType = shim; +export const getOffscreenContainerProps = shim; diff --git a/packages/react-reconciler/src/ReactFiberOffscreenComponent.js b/packages/react-reconciler/src/ReactFiberOffscreenComponent.js index acae8d206194b..87e1eaa244540 100644 --- a/packages/react-reconciler/src/ReactFiberOffscreenComponent.js +++ b/packages/react-reconciler/src/ReactFiberOffscreenComponent.js @@ -7,7 +7,7 @@ * @flow */ -import type {ReactNodeList} from 'shared/ReactTypes'; +import type {ReactNodeList, OffscreenMode} from 'shared/ReactTypes'; import type {Lanes} from './ReactFiberLane.old'; import type {SpawnedCachePool} from './ReactFiberCacheComponent.new'; @@ -18,7 +18,7 @@ export type OffscreenProps = {| // // Default mode is visible. Kind of a weird default for a component // called "Offscreen." Possible alt: ? - mode?: 'hidden' | 'unstable-defer-without-hiding' | 'visible' | null | void, + mode?: OffscreenMode | null | void, children?: ReactNodeList, |}; diff --git a/packages/react-reconciler/src/forks/ReactFiberHostConfig.custom.js b/packages/react-reconciler/src/forks/ReactFiberHostConfig.custom.js index 6a6e9c5e040c3..9dbaa47eb7110 100644 --- a/packages/react-reconciler/src/forks/ReactFiberHostConfig.custom.js +++ b/packages/react-reconciler/src/forks/ReactFiberHostConfig.custom.js @@ -127,8 +127,10 @@ export const appendChildToContainerChildSet = export const finalizeContainerChildren = $$$hostConfig.finalizeContainerChildren; export const replaceContainerChildren = $$$hostConfig.replaceContainerChildren; -export const cloneHiddenInstance = $$$hostConfig.cloneHiddenInstance; -export const cloneHiddenTextInstance = $$$hostConfig.cloneHiddenTextInstance; +export const getOffscreenContainerType = + $$$hostConfig.getOffscreenContainerType; +export const getOffscreenContainerProps = + $$$hostConfig.getOffscreenContainerProps; // ------------------- // Hydration diff --git a/packages/shared/ReactTypes.js b/packages/shared/ReactTypes.js index 03b70aae07e6d..43f42bddb91d3 100644 --- a/packages/shared/ReactTypes.js +++ b/packages/shared/ReactTypes.js @@ -166,3 +166,8 @@ export interface Thenable<+R> { onReject: (error: mixed) => void | Thenable | U, ): void | Thenable; } + +export type OffscreenMode = + | 'hidden' + | 'unstable-defer-without-hiding' + | 'visible';