From 709e4e3947fa4317d541f466bd786f191ef0dd0b Mon Sep 17 00:00:00 2001 From: Andrew Clark Date: Thu, 23 Apr 2020 16:38:15 -0700 Subject: [PATCH] Move hide/unhide logic to Offscreen component The Offscreen component is not a public type, yet, but once it is, it will share the same hide/unhide logic as Suspense children. --- .../react-reconciler/src/ReactFiber.new.js | 3 +- .../src/ReactFiberBeginWork.new.js | 91 +++++++++++++++++-- .../src/ReactFiberCommitWork.new.js | 50 ++++++---- .../src/ReactFiberCompleteWork.new.js | 15 ++- .../src/ReactFiberOffscreenComponent.js | 31 +++++++ 5 files changed, 159 insertions(+), 31 deletions(-) create mode 100644 packages/react-reconciler/src/ReactFiberOffscreenComponent.js diff --git a/packages/react-reconciler/src/ReactFiber.new.js b/packages/react-reconciler/src/ReactFiber.new.js index d3332a8a60cc5..2fcd1830c0049 100644 --- a/packages/react-reconciler/src/ReactFiber.new.js +++ b/packages/react-reconciler/src/ReactFiber.new.js @@ -20,6 +20,7 @@ import type {WorkTag} from './ReactWorkTags'; import type {TypeOfMode} from './ReactTypeOfMode'; import type {ExpirationTimeOpaque} from './ReactFiberExpirationTime.new'; import type {SuspenseInstance} from './ReactFiberHostConfig'; +import type {OffscreenProps} from './ReactFiberOffscreenComponent'; import invariant from 'shared/invariant'; import { @@ -738,7 +739,7 @@ export function createFiberFromSuspenseList( } export function createFiberFromOffscreen( - pendingProps: any, + pendingProps: OffscreenProps, mode: TypeOfMode, expirationTime: ExpirationTimeOpaque, key: null | string, diff --git a/packages/react-reconciler/src/ReactFiberBeginWork.new.js b/packages/react-reconciler/src/ReactFiberBeginWork.new.js index aa10baff8cf97..86c95a3978222 100644 --- a/packages/react-reconciler/src/ReactFiberBeginWork.new.js +++ b/packages/react-reconciler/src/ReactFiberBeginWork.new.js @@ -19,6 +19,10 @@ import type { SuspenseListTailMode, } from './ReactFiberSuspenseComponent.new'; import type {SuspenseContext} from './ReactFiberSuspenseContext.new'; +import type { + OffscreenProps, + OffscreenState, +} from './ReactFiberOffscreenComponent'; import checkPropTypes from 'shared/checkPropTypes'; @@ -562,7 +566,20 @@ function updateOffscreenComponent( workInProgress: Fiber, renderExpirationTime: ExpirationTimeOpaque, ) { - const nextChildren = workInProgress.pendingProps; + const nextProps: OffscreenProps = workInProgress.pendingProps; + const nextChildren = nextProps.children; + + if (current !== null) { + if (nextProps.mode === 'hidden') { + // TODO: Should currently be unreachable because Offscreen is only used as + // an implementation detail of Suspense. Once this is a public API, it + // will need to create an OffscreenState. + } else { + // Clear the offscreen state. + workInProgress.memoizedState = null; + } + } + reconcileChildren( current, workInProgress, @@ -1854,12 +1871,16 @@ function updateSuspenseComponent( } if (showFallback) { + const nextPrimaryChildren = nextProps.children; const nextFallbackChildren = nextProps.fallback; const fallbackFragment = mountSuspenseFallbackChildren( workInProgress, + nextPrimaryChildren, nextFallbackChildren, renderExpirationTime, ); + const primaryChildFragment: Fiber = (workInProgress.child: any); + primaryChildFragment.memoizedState = ({baseTime: NoWork}: OffscreenState); workInProgress.memoizedState = mountSuspenseState(renderExpirationTime); return fallbackFragment; } else { @@ -1904,14 +1925,19 @@ function updateSuspenseComponent( } else { // Suspended but we should no longer be in dehydrated mode. // Therefore we now have to render the fallback. + const nextPrimaryChildren = nextProps.children; const nextFallbackChildren = nextProps.fallback; const fallbackChildFragment = mountSuspenseFallbackAfterRetryWithoutHydrating( current, workInProgress, + nextPrimaryChildren, nextFallbackChildren, renderExpirationTime, ); - + const primaryChildFragment: Fiber = (workInProgress.child: any); + primaryChildFragment.memoizedState = ({ + baseTime: NoWork, + }: OffscreenState); workInProgress.memoizedState = updateSuspenseState( current.memoizedState, renderExpirationTime, @@ -1924,13 +1950,18 @@ function updateSuspenseComponent( if (showFallback) { const nextFallbackChildren = nextProps.fallback; + const nextPrimaryChildren = nextProps.children; const fallbackChildFragment = updateSuspenseFallbackChildren( current, workInProgress, + nextPrimaryChildren, nextFallbackChildren, renderExpirationTime, ); const primaryChildFragment: Fiber = (workInProgress.child: any); + primaryChildFragment.memoizedState = ({ + baseTime: NoWork, + }: OffscreenState); primaryChildFragment.childExpirationTime_opaque = getRemainingWorkInPrimaryTree( current, workInProgress, @@ -1957,13 +1988,18 @@ function updateSuspenseComponent( if (showFallback) { // Timed out. const nextFallbackChildren = nextProps.fallback; + const nextPrimaryChildren = nextProps.children; const fallbackChildFragment = updateSuspenseFallbackChildren( current, workInProgress, + nextPrimaryChildren, nextFallbackChildren, renderExpirationTime, ); const primaryChildFragment: Fiber = (workInProgress.child: any); + primaryChildFragment.memoizedState = ({ + baseTime: NoWork, + }: OffscreenState); primaryChildFragment.childExpirationTime_opaque = getRemainingWorkInPrimaryTree( current, workInProgress, @@ -1996,8 +2032,12 @@ function mountSuspensePrimaryChildren( renderExpirationTime, ) { const mode = workInProgress.mode; + const primaryChildProps: OffscreenProps = { + mode: 'visible', + children: primaryChildren, + }; const primaryChildFragment = createFiberFromOffscreen( - primaryChildren, + primaryChildProps, mode, renderExpirationTime, null, @@ -2009,13 +2049,18 @@ function mountSuspensePrimaryChildren( function mountSuspenseFallbackChildren( workInProgress, + primaryChildren, fallbackChildren, renderExpirationTime, ) { const mode = workInProgress.mode; - const progressedPrimaryFragment: Fiber | null = workInProgress.child; + const primaryChildProps: OffscreenProps = { + mode: 'hidden', + children: primaryChildren, + }; + let primaryChildFragment; let fallbackChildFragment; if ((mode & BlockingMode) === NoMode && progressedPrimaryFragment !== null) { @@ -2023,6 +2068,7 @@ function mountSuspenseFallbackChildren( // completed, even though it's in an inconsistent state. primaryChildFragment = progressedPrimaryFragment; primaryChildFragment.childExpirationTime_opaque = NoWork; + primaryChildFragment.pendingProps = primaryChildProps; if (enableProfilerTimer && workInProgress.mode & ProfileMode) { // Reset the durations from the first pass so they aren't included in the @@ -2053,7 +2099,12 @@ function mountSuspenseFallbackChildren( null, ); } else { - primaryChildFragment = createFiberFromOffscreen(null, mode, NoWork, null); + primaryChildFragment = createFiberFromOffscreen( + primaryChildProps, + mode, + NoWork, + null, + ); fallbackChildFragment = createFiberFromFragment( fallbackChildren, mode, @@ -2069,6 +2120,15 @@ function mountSuspenseFallbackChildren( return fallbackChildFragment; } +function createWorkInProgressOffscreenFiber( + current: Fiber, + offscreenProps: OffscreenProps, +) { + // The props argument to `createWorkInProgress` is `any` typed, so we use this + // wrapper function to constrain it. + return createWorkInProgress(current, offscreenProps); +} + function updateSuspensePrimaryChildren( current, workInProgress, @@ -2079,9 +2139,12 @@ function updateSuspensePrimaryChildren( const currentFallbackChildFragment: Fiber | null = currentPrimaryChildFragment.sibling; - const primaryChildFragment = createWorkInProgress( + const primaryChildFragment = createWorkInProgressOffscreenFiber( currentPrimaryChildFragment, - primaryChildren, + { + mode: 'visible', + children: primaryChildren, + }, ); if ((workInProgress.mode & BlockingMode) === NoMode) { primaryChildFragment.expirationTime_opaque = renderExpirationTime; @@ -2102,6 +2165,7 @@ function updateSuspensePrimaryChildren( function updateSuspenseFallbackChildren( current, workInProgress, + primaryChildren, fallbackChildren, renderExpirationTime, ) { @@ -2110,6 +2174,11 @@ function updateSuspenseFallbackChildren( const currentFallbackChildFragment: Fiber | null = currentPrimaryChildFragment.sibling; + const primaryChildProps: OffscreenProps = { + mode: 'hidden', + children: primaryChildren, + }; + let primaryChildFragment; if ((mode & BlockingMode) === NoMode) { // In legacy mode, we commit the primary tree as if it successfully @@ -2117,6 +2186,7 @@ function updateSuspenseFallbackChildren( const progressedPrimaryFragment: Fiber = (workInProgress.child: any); primaryChildFragment = progressedPrimaryFragment; primaryChildFragment.childExpirationTime_opaque = NoWork; + primaryChildFragment.pendingProps = primaryChildProps; if (enableProfilerTimer && workInProgress.mode & ProfileMode) { // Reset the durations from the first pass so they aren't included in the @@ -2142,9 +2212,9 @@ function updateSuspenseFallbackChildren( workInProgress.firstEffect = workInProgress.lastEffect = null; } } else { - primaryChildFragment = createWorkInProgress( + primaryChildFragment = createWorkInProgressOffscreenFiber( currentPrimaryChildFragment, - currentPrimaryChildFragment.pendingProps, + primaryChildProps, ); } let fallbackChildFragment; @@ -2205,12 +2275,13 @@ function retrySuspenseComponentWithoutHydrating( function mountSuspenseFallbackAfterRetryWithoutHydrating( current, workInProgress, + primaryChildren, fallbackChildren, renderExpirationTime, ) { const mode = workInProgress.mode; const primaryChildFragment = createFiberFromOffscreen( - null, + primaryChildren, mode, NoWork, null, diff --git a/packages/react-reconciler/src/ReactFiberCommitWork.new.js b/packages/react-reconciler/src/ReactFiberCommitWork.new.js index 73ed9bebcd3ce..bb8799bab3fd3 100644 --- a/packages/react-reconciler/src/ReactFiberCommitWork.new.js +++ b/packages/react-reconciler/src/ReactFiberCommitWork.new.js @@ -23,6 +23,7 @@ import type {UpdateQueue} from './ReactUpdateQueue.new'; import type {FunctionComponentUpdateQueue} from './ReactFiberHooks.new'; import type {Wakeable} from 'shared/ReactTypes'; import type {ReactPriorityLevel} from './ReactInternalTypes'; +import type {OffscreenState} from './ReactFiberOffscreenComponent'; import {unstable_wrap as Schedule_tracing_wrap} from 'scheduler/tracing'; import { @@ -55,6 +56,7 @@ import { FundamentalComponent, ScopeComponent, Block, + OffscreenComponent, } from './ReactWorkTags'; import { invokeGuardedCallback, @@ -805,6 +807,7 @@ function commitLifeCycles( case IncompleteClassComponent: case FundamentalComponent: case ScopeComponent: + case OffscreenComponent: return; } invariant( @@ -835,16 +838,12 @@ function hideOrUnhideAllChildren(finishedWork, isHidden) { unhideTextInstance(instance, node.memoizedProps); } } else if ( - node.tag === SuspenseComponent && - node.memoizedState !== null && - node.memoizedState.dehydrated === null + node.tag === OffscreenComponent && + (node.memoizedState: OffscreenState) !== null && + node !== finishedWork ) { - // Found a nested Suspense component that timed out. Skip over the - // primary child fragment, which should remain hidden. - const fallbackChildFragment: Fiber = (node.child: any).sibling; - fallbackChildFragment.return = node; - node = fallbackChildFragment; - continue; + // Found a nested Offscreen component that is hidden. Don't search + // any deeper. This tree should remain hidden. } else if (node.child !== null) { node.child.return = node; node = node.child; @@ -1584,6 +1583,9 @@ function commitWork(current: Fiber | null, finishedWork: Fiber): void { } break; } + case OffscreenComponent: { + return; + } } commitContainer(finishedWork); @@ -1720,6 +1722,12 @@ function commitWork(current: Fiber | null, finishedWork: Fiber): void { } break; } + case OffscreenComponent: { + const newState: OffscreenState | null = finishedWork.memoizedState; + const isHidden = newState !== null; + hideOrUnhideAllChildren(finishedWork, isHidden); + return; + } } invariant( false, @@ -1731,18 +1739,22 @@ function commitWork(current: Fiber | null, finishedWork: Fiber): void { function commitSuspenseComponent(finishedWork: Fiber) { const newState: SuspenseState | null = finishedWork.memoizedState; - let newDidTimeout; - let primaryChildParent = finishedWork; - if (newState === null) { - newDidTimeout = false; - } else { - newDidTimeout = true; - primaryChildParent = finishedWork.child; + if (newState !== null) { markCommitTimeOfFallback(); - } - if (supportsMutation && primaryChildParent !== null) { - hideOrUnhideAllChildren(primaryChildParent, newDidTimeout); + if (supportsMutation) { + // Hide the Offscreen component that contains the primary children. TODO: + // Ideally, this effect would have been scheduled on the Offscreen fiber + // itself. That's how unhiding works: the Offscreen component schedules an + // effect on itself. However, in this case, the component didn't complete, + // so the fiber was never added to the effect list in the normal path. We + // could have appended it to the effect list in the Suspense component's + // second pass, but doing it this way is less complicated. This would be + // simpler if we got rid of the effect list and traversed the tree, like + // we're planning to do. + const primaryChildParent: Fiber = (finishedWork.child: any); + hideOrUnhideAllChildren(primaryChildParent, true); + } } if (enableSuspenseCallback && newState !== null) { diff --git a/packages/react-reconciler/src/ReactFiberCompleteWork.new.js b/packages/react-reconciler/src/ReactFiberCompleteWork.new.js index e6fa333510d40..2299e61f6e580 100644 --- a/packages/react-reconciler/src/ReactFiberCompleteWork.new.js +++ b/packages/react-reconciler/src/ReactFiberCompleteWork.new.js @@ -26,6 +26,8 @@ import type { SuspenseListRenderState, } from './ReactFiberSuspenseComponent.new'; import type {SuspenseContext} from './ReactFiberSuspenseContext.new'; +import type {OffscreenState} from './ReactFiberOffscreenComponent'; + import {resetWorkInProgressVersions as resetMutableSourceWorkInProgressVersions} from './ReactMutableSource.new'; import {now} from './SchedulerWithReactIntegration.new'; @@ -1288,8 +1290,19 @@ function completeWork( return null; } break; - case OffscreenComponent: + case OffscreenComponent: { + if (current !== null) { + const nextState: OffscreenState | null = workInProgress.memoizedState; + const prevState: OffscreenState | null = current.memoizedState; + + const prevIsHidden = prevState !== null; + const nextIsHidden = nextState !== null; + if (prevIsHidden !== nextIsHidden) { + workInProgress.effectTag |= Update; + } + } return null; + } } invariant( false, diff --git a/packages/react-reconciler/src/ReactFiberOffscreenComponent.js b/packages/react-reconciler/src/ReactFiberOffscreenComponent.js new file mode 100644 index 0000000000000..0808c4c193177 --- /dev/null +++ b/packages/react-reconciler/src/ReactFiberOffscreenComponent.js @@ -0,0 +1,31 @@ +/** + * 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 {ReactNodeList} from 'shared/ReactTypes'; +import type {ExpirationTimeOpaque} from './ReactFiberExpirationTime.new'; + +export type OffscreenProps = {| + // TODO: Pick an API before exposing the Offscreen type. I've chosen an enum + // for now, since we might have multiple variants. For example, hiding the + // content without changing the layout. + // + // Default mode is visible. Kind of a weird default for a component + // called "Offscreen." Possible alt: ? + mode?: 'hidden' | 'visible' | null | void, + children?: ReactNodeList, +|}; + +// We use the existence of the state object as an indicator that the component +// is hidden. +export type OffscreenState = {| + // TODO: This doesn't do anything, yet. It's always NoWork. But eventually it + // will represent the pending work that must be included in the render in + // order to unhide the component. + baseTime: ExpirationTimeOpaque, +|};