diff --git a/packages/react-reconciler/src/ReactFiberBeginWork.new.js b/packages/react-reconciler/src/ReactFiberBeginWork.new.js index ef7a870c29b9a..2144140d22aac 100644 --- a/packages/react-reconciler/src/ReactFiberBeginWork.new.js +++ b/packages/react-reconciler/src/ReactFiberBeginWork.new.js @@ -81,6 +81,7 @@ import { warnAboutDefaultPropsOnFunctionComponents, enableScopeAPI, enableCache, + enableLazyContextPropagation, } from 'shared/ReactFeatureFlags'; import invariant from 'shared/invariant'; import shallowEqual from 'shared/shallowEqual'; @@ -153,6 +154,9 @@ import {findFirstSuspended} from './ReactFiberSuspenseComponent.new'; import { pushProvider, propagateContextChange, + lazilyPropagateParentContextChanges, + propagateParentContextChangesToDeferredTree, + checkIfContextChanged, readContext, prepareToReadContext, calculateChangedBits, @@ -645,6 +649,18 @@ function updateOffscreenComponent( // We're about to bail out, but we need to push this to the stack anyway // to avoid a push/pop misalignment. pushRenderLanes(workInProgress, nextBaseLanes); + + if (enableLazyContextPropagation && current !== null) { + // Since this tree will resume rendering in a separate render, we need + // to propagate parent contexts now so we don't lose track of which + // ones changed. + propagateParentContextChangesToDeferredTree( + current, + workInProgress, + renderLanes, + ); + } + return null; } else { // This is the second render. The surrounding visible content has already @@ -2444,6 +2460,19 @@ function updateDehydratedSuspenseComponent( renderLanes, ); } + + if ( + enableLazyContextPropagation && + // TODO: Factoring is a little weird, since we check this right below, too. + // But don't want to re-arrange the if-else chain until/unless this + // feature lands. + !didReceiveUpdate + ) { + // We need to check if any children have context before we decide to bail + // out, so propagate the changes now. + lazilyPropagateParentContextChanges(current, workInProgress, renderLanes); + } + // We use lanes to indicate that a child might depend on context, so if // any context has changed, we need to treat is as if the input might have changed. const hasContextChanged = includesSomeLane(renderLanes, current.childLanes); @@ -2969,25 +2998,37 @@ function updateContextProvider( pushProvider(workInProgress, context, newValue); - if (oldProps !== null) { - const oldValue = oldProps.value; - const changedBits = calculateChangedBits(context, newValue, oldValue); - if (changedBits === 0) { - // No change. Bailout early if children are the same. - if ( - oldProps.children === newProps.children && - !hasLegacyContextChanged() - ) { - return bailoutOnAlreadyFinishedWork( - current, + if (enableLazyContextPropagation) { + // In the lazy propagation implementation, we don't scan for matching + // consumers until something bails out, because until something bails out + // we're going to visit those nodes, anyway. The trade-off is that it shifts + // responsibility to the consumer to track whether something has changed. + } else { + if (oldProps !== null) { + const oldValue = oldProps.value; + const changedBits = calculateChangedBits(context, newValue, oldValue); + if (changedBits === 0) { + // No change. Bailout early if children are the same. + if ( + oldProps.children === newProps.children && + !hasLegacyContextChanged() + ) { + return bailoutOnAlreadyFinishedWork( + current, + workInProgress, + renderLanes, + ); + } + } else { + // The context value changed. Search for matching consumers and schedule + // them to update. + propagateContextChange( workInProgress, + context, + changedBits, renderLanes, ); } - } else { - // The context value changed. Search for matching consumers and schedule - // them to update. - propagateContextChange(workInProgress, context, changedBits, renderLanes); } } @@ -3099,13 +3140,23 @@ function bailoutOnAlreadyFinishedWork( // The children don't have any work either. We can skip them. // TODO: Once we add back resuming, we should check if the children are // a work-in-progress set. If so, we need to transfer their effects. - return null; - } else { - // This fiber doesn't have work, but its subtree does. Clone the child - // fibers and continue. - cloneChildFibers(current, workInProgress); - return workInProgress.child; + + if (enableLazyContextPropagation && current !== null) { + // Before bailing out, check if there are any context changes in + // the children. + lazilyPropagateParentContextChanges(current, workInProgress, renderLanes); + if (!includesSomeLane(renderLanes, workInProgress.childLanes)) { + return null; + } + } else { + return null; + } } + + // This fiber doesn't have work, but its subtree does. Clone the child + // fibers and continue. + cloneChildFibers(current, workInProgress); + return workInProgress.child; } function remountFiber( @@ -3174,7 +3225,7 @@ function beginWork( workInProgress: Fiber, renderLanes: Lanes, ): Fiber | null { - const updateLanes = workInProgress.lanes; + let updateLanes = workInProgress.lanes; if (__DEV__) { if (workInProgress._debugNeedsRemount && current !== null) { @@ -3195,6 +3246,17 @@ function beginWork( } if (current !== null) { + // TODO: The factoring of this block is weird. + if ( + enableLazyContextPropagation && + !includesSomeLane(renderLanes, updateLanes) + ) { + const dependencies = current.dependencies; + if (dependencies !== null && checkIfContextChanged(dependencies)) { + updateLanes = mergeLanes(updateLanes, renderLanes); + } + } + const oldProps = current.memoizedProps; const newProps = workInProgress.pendingProps; @@ -3315,6 +3377,9 @@ function beginWork( // primary children and work on the fallback. return child.sibling; } else { + // Note: We can return `null` here because we already checked + // whether there were nested context consumers, via the call to + // `bailoutOnAlreadyFinishedWork` above. return null; } } @@ -3329,11 +3394,30 @@ function beginWork( case SuspenseListComponent: { const didSuspendBefore = (current.flags & DidCapture) !== NoFlags; - const hasChildWork = includesSomeLane( + let hasChildWork = includesSomeLane( renderLanes, workInProgress.childLanes, ); + if (enableLazyContextPropagation && !hasChildWork) { + // Context changes may not have been propagated yet. We need to do + // that now, before we can decide whether to bail out. + // TODO: We use `childLanes` as a heuristic for whether there is + // remaining work in a few places, including + // `bailoutOnAlreadyFinishedWork` and + // `updateDehydratedSuspenseComponent`. We should maybe extract this + // into a dedicated function. + lazilyPropagateParentContextChanges( + current, + workInProgress, + renderLanes, + ); + hasChildWork = includesSomeLane( + renderLanes, + workInProgress.childLanes, + ); + } + if (didSuspendBefore) { if (hasChildWork) { // If something was in fallback state last time, and we have all the diff --git a/packages/react-reconciler/src/ReactFiberBeginWork.old.js b/packages/react-reconciler/src/ReactFiberBeginWork.old.js index fd81ae51d651d..21e30fe08c38e 100644 --- a/packages/react-reconciler/src/ReactFiberBeginWork.old.js +++ b/packages/react-reconciler/src/ReactFiberBeginWork.old.js @@ -81,6 +81,7 @@ import { warnAboutDefaultPropsOnFunctionComponents, enableScopeAPI, enableCache, + enableLazyContextPropagation, } from 'shared/ReactFeatureFlags'; import invariant from 'shared/invariant'; import shallowEqual from 'shared/shallowEqual'; @@ -153,6 +154,9 @@ import {findFirstSuspended} from './ReactFiberSuspenseComponent.old'; import { pushProvider, propagateContextChange, + lazilyPropagateParentContextChanges, + propagateParentContextChangesToDeferredTree, + checkIfContextChanged, readContext, prepareToReadContext, calculateChangedBits, @@ -645,6 +649,18 @@ function updateOffscreenComponent( // We're about to bail out, but we need to push this to the stack anyway // to avoid a push/pop misalignment. pushRenderLanes(workInProgress, nextBaseLanes); + + if (enableLazyContextPropagation && current !== null) { + // Since this tree will resume rendering in a separate render, we need + // to propagate parent contexts now so we don't lose track of which + // ones changed. + propagateParentContextChangesToDeferredTree( + current, + workInProgress, + renderLanes, + ); + } + return null; } else { // This is the second render. The surrounding visible content has already @@ -2444,6 +2460,19 @@ function updateDehydratedSuspenseComponent( renderLanes, ); } + + if ( + enableLazyContextPropagation && + // TODO: Factoring is a little weird, since we check this right below, too. + // But don't want to re-arrange the if-else chain until/unless this + // feature lands. + !didReceiveUpdate + ) { + // We need to check if any children have context before we decide to bail + // out, so propagate the changes now. + lazilyPropagateParentContextChanges(current, workInProgress, renderLanes); + } + // We use lanes to indicate that a child might depend on context, so if // any context has changed, we need to treat is as if the input might have changed. const hasContextChanged = includesSomeLane(renderLanes, current.childLanes); @@ -2969,25 +2998,37 @@ function updateContextProvider( pushProvider(workInProgress, context, newValue); - if (oldProps !== null) { - const oldValue = oldProps.value; - const changedBits = calculateChangedBits(context, newValue, oldValue); - if (changedBits === 0) { - // No change. Bailout early if children are the same. - if ( - oldProps.children === newProps.children && - !hasLegacyContextChanged() - ) { - return bailoutOnAlreadyFinishedWork( - current, + if (enableLazyContextPropagation) { + // In the lazy propagation implementation, we don't scan for matching + // consumers until something bails out, because until something bails out + // we're going to visit those nodes, anyway. The trade-off is that it shifts + // responsibility to the consumer to track whether something has changed. + } else { + if (oldProps !== null) { + const oldValue = oldProps.value; + const changedBits = calculateChangedBits(context, newValue, oldValue); + if (changedBits === 0) { + // No change. Bailout early if children are the same. + if ( + oldProps.children === newProps.children && + !hasLegacyContextChanged() + ) { + return bailoutOnAlreadyFinishedWork( + current, + workInProgress, + renderLanes, + ); + } + } else { + // The context value changed. Search for matching consumers and schedule + // them to update. + propagateContextChange( workInProgress, + context, + changedBits, renderLanes, ); } - } else { - // The context value changed. Search for matching consumers and schedule - // them to update. - propagateContextChange(workInProgress, context, changedBits, renderLanes); } } @@ -3099,13 +3140,23 @@ function bailoutOnAlreadyFinishedWork( // The children don't have any work either. We can skip them. // TODO: Once we add back resuming, we should check if the children are // a work-in-progress set. If so, we need to transfer their effects. - return null; - } else { - // This fiber doesn't have work, but its subtree does. Clone the child - // fibers and continue. - cloneChildFibers(current, workInProgress); - return workInProgress.child; + + if (enableLazyContextPropagation && current !== null) { + // Before bailing out, check if there are any context changes in + // the children. + lazilyPropagateParentContextChanges(current, workInProgress, renderLanes); + if (!includesSomeLane(renderLanes, workInProgress.childLanes)) { + return null; + } + } else { + return null; + } } + + // This fiber doesn't have work, but its subtree does. Clone the child + // fibers and continue. + cloneChildFibers(current, workInProgress); + return workInProgress.child; } function remountFiber( @@ -3174,7 +3225,7 @@ function beginWork( workInProgress: Fiber, renderLanes: Lanes, ): Fiber | null { - const updateLanes = workInProgress.lanes; + let updateLanes = workInProgress.lanes; if (__DEV__) { if (workInProgress._debugNeedsRemount && current !== null) { @@ -3195,6 +3246,17 @@ function beginWork( } if (current !== null) { + // TODO: The factoring of this block is weird. + if ( + enableLazyContextPropagation && + !includesSomeLane(renderLanes, updateLanes) + ) { + const dependencies = current.dependencies; + if (dependencies !== null && checkIfContextChanged(dependencies)) { + updateLanes = mergeLanes(updateLanes, renderLanes); + } + } + const oldProps = current.memoizedProps; const newProps = workInProgress.pendingProps; @@ -3315,6 +3377,9 @@ function beginWork( // primary children and work on the fallback. return child.sibling; } else { + // Note: We can return `null` here because we already checked + // whether there were nested context consumers, via the call to + // `bailoutOnAlreadyFinishedWork` above. return null; } } @@ -3329,11 +3394,30 @@ function beginWork( case SuspenseListComponent: { const didSuspendBefore = (current.flags & DidCapture) !== NoFlags; - const hasChildWork = includesSomeLane( + let hasChildWork = includesSomeLane( renderLanes, workInProgress.childLanes, ); + if (enableLazyContextPropagation && !hasChildWork) { + // Context changes may not have been propagated yet. We need to do + // that now, before we can decide whether to bail out. + // TODO: We use `childLanes` as a heuristic for whether there is + // remaining work in a few places, including + // `bailoutOnAlreadyFinishedWork` and + // `updateDehydratedSuspenseComponent`. We should maybe extract this + // into a dedicated function. + lazilyPropagateParentContextChanges( + current, + workInProgress, + renderLanes, + ); + hasChildWork = includesSomeLane( + renderLanes, + workInProgress.childLanes, + ); + } + if (didSuspendBefore) { if (hasChildWork) { // If something was in fallback state last time, and we have all the diff --git a/packages/react-reconciler/src/ReactFiberFlags.js b/packages/react-reconciler/src/ReactFiberFlags.js index f29a1b645f15a..783e637555111 100644 --- a/packages/react-reconciler/src/ReactFiberFlags.js +++ b/packages/react-reconciler/src/ReactFiberFlags.js @@ -12,49 +12,50 @@ import {enableCreateEventHandleAPI} from 'shared/ReactFeatureFlags'; export type Flags = number; // Don't change these two values. They're used by React Dev Tools. -export const NoFlags = /* */ 0b00000000000000000000; -export const PerformedWork = /* */ 0b00000000000000000001; +export const NoFlags = /* */ 0b000000000000000000000; +export const PerformedWork = /* */ 0b000000000000000000001; // You can change the rest (and add more). -export const Placement = /* */ 0b00000000000000000010; -export const Update = /* */ 0b00000000000000000100; +export const Placement = /* */ 0b000000000000000000010; +export const Update = /* */ 0b000000000000000000100; export const PlacementAndUpdate = /* */ Placement | Update; -export const Deletion = /* */ 0b00000000000000001000; -export const ChildDeletion = /* */ 0b00000000000000010000; -export const ContentReset = /* */ 0b00000000000000100000; -export const Callback = /* */ 0b00000000000001000000; -export const DidCapture = /* */ 0b00000000000010000000; -export const Ref = /* */ 0b00000000000100000000; -export const Snapshot = /* */ 0b00000000001000000000; -export const Passive = /* */ 0b00000000010000000000; -export const Hydrating = /* */ 0b00000000100000000000; +export const Deletion = /* */ 0b000000000000000001000; +export const ChildDeletion = /* */ 0b000000000000000010000; +export const ContentReset = /* */ 0b000000000000000100000; +export const Callback = /* */ 0b000000000000001000000; +export const DidCapture = /* */ 0b000000000000010000000; +export const Ref = /* */ 0b000000000000100000000; +export const Snapshot = /* */ 0b000000000001000000000; +export const Passive = /* */ 0b000000000010000000000; +export const Hydrating = /* */ 0b000000000100000000000; export const HydratingAndUpdate = /* */ Hydrating | Update; -export const Visibility = /* */ 0b00000001000000000000; +export const Visibility = /* */ 0b000000001000000000000; export const LifecycleEffectMask = Passive | Update | Callback | Ref | Snapshot; // Union of all commit flags (flags with the lifetime of a particular commit) -export const HostEffectMask = /* */ 0b00000001111111111111; +export const HostEffectMask = /* */ 0b000000001111111111111; // These are not really side effects, but we still reuse this field. -export const Incomplete = /* */ 0b00000010000000000000; -export const ShouldCapture = /* */ 0b00000100000000000000; +export const Incomplete = /* */ 0b000000010000000000000; +export const ShouldCapture = /* */ 0b000000100000000000000; // TODO (effects) Remove this bit once the new reconciler is synced to the old. -export const PassiveUnmountPendingDev = /* */ 0b00001000000000000000; -export const ForceUpdateForLegacySuspense = /* */ 0b00010000000000000000; +export const PassiveUnmountPendingDev = /* */ 0b000001000000000000000; +export const ForceUpdateForLegacySuspense = /* */ 0b000010000000000000000; +export const DidPropagateContext = /* */ 0b000100000000000000000; // Static tags describe aspects of a fiber that are not specific to a render, // e.g. a fiber uses a passive effect (even if there are no updates on this particular render). // This enables us to defer more work in the unmount case, // since we can defer traversing the tree during layout to look for Passive effects, // and instead rely on the static flag as a signal that there may be cleanup work. -export const PassiveStatic = /* */ 0b00100000000000000000; +export const PassiveStatic = /* */ 0b001000000000000000000; // These flags allow us to traverse to fibers that have effects on mount // without traversing the entire tree after every commit for // double invoking -export const MountLayoutDev = /* */ 0b01000000000000000000; -export const MountPassiveDev = /* */ 0b10000000000000000000; +export const MountLayoutDev = /* */ 0b010000000000000000000; +export const MountPassiveDev = /* */ 0b100000000000000000000; // Groups of flags that are used in the commit phase to skip over trees that // don't contain effects, by checking subtreeFlags. diff --git a/packages/react-reconciler/src/ReactFiberNewContext.new.js b/packages/react-reconciler/src/ReactFiberNewContext.new.js index 91ba7c9ff6516..2a341b70dbd6a 100644 --- a/packages/react-reconciler/src/ReactFiberNewContext.new.js +++ b/packages/react-reconciler/src/ReactFiberNewContext.new.js @@ -7,7 +7,7 @@ * @flow */ -import type {ReactContext} from 'shared/ReactTypes'; +import type {ReactContext, ReactProviderType} from 'shared/ReactTypes'; import type { Fiber, ContextDependency, @@ -33,6 +33,7 @@ import { mergeLanes, pickArbitraryLane, } from './ReactFiberLane.new'; +import {NoFlags, DidPropagateContext} from './ReactFiberFlags'; import invariant from 'shared/invariant'; import is from 'shared/objectIs'; @@ -328,6 +329,88 @@ export function propagateContextChange( } } +export function lazilyPropagateParentContextChanges( + current: Fiber, + workInProgress: Fiber, + renderLanes: Lanes, +) { + propagateParentContextChanges(current, workInProgress, renderLanes, true); +} + +export function propagateParentContextChangesToDeferredTree( + current: Fiber, + workInProgress: Fiber, + renderLanes: Lanes, +) { + propagateParentContextChanges(current, workInProgress, renderLanes, false); +} + +function propagateParentContextChanges( + current: Fiber, + workInProgress: Fiber, + renderLanes: Lanes, + bailoutAfterFirstMatch: boolean, +) { + if (!enableLazyContextPropagation) { + return false; + } + + let parent = workInProgress; + while (parent !== null && (parent.flags & DidPropagateContext) === NoFlags) { + if (parent.tag === ContextProvider) { + const currentParent = parent.alternate; + if (currentParent !== null) { + const oldProps = currentParent.memoizedProps; + if (oldProps !== null) { + const providerType: ReactProviderType = parent.type; + const context: ReactContext = providerType._context; + + const newProps = parent.pendingProps; + const newValue = newProps.value; + + const oldValue = oldProps.value; + + const changedBits = calculateChangedBits(context, newValue, oldValue); + if (changedBits !== 0) { + // The context value changed. Search for matching consumers and + // schedule them to update. + propagateContextChange( + workInProgress, + context, + changedBits, + renderLanes, + ); + // If we found a matching consumer, we can stop any remaining + // propagation until the next level when we bail out. + if ( + // propagateParentContextChangesToDeferredTree doesn't bail out + // after the first match; it propagates *all* the matching + // providers. This is used by Suspense and Offscreen because they + // spawn work in a subtree that isn't resumed until a later + // render, by which time we won't be able to tell that the + // provider has changed. + bailoutAfterFirstMatch && + includesSomeLane(workInProgress.childLanes, renderLanes) + ) { + return; + } + } + } + } + } + parent = parent.return; + } + + // This is an optimization so that we only propagate each provider once. If + // a deeply nested child bails out, and it calls this propagation function, it + // uses this flag to know that the remaining ancestor providers have already + // been propagated. + // + // Note: We can only set this if we propagated every provider ancestor. So + // it must come *after* the early return above. + workInProgress.flags |= DidPropagateContext; +} + export function checkIfContextChanged(currentDependencies: Dependencies) { if (!enableLazyContextPropagation) { return false; diff --git a/packages/react-reconciler/src/ReactFiberNewContext.old.js b/packages/react-reconciler/src/ReactFiberNewContext.old.js index 602eadd6b010a..97cd4f79f3174 100644 --- a/packages/react-reconciler/src/ReactFiberNewContext.old.js +++ b/packages/react-reconciler/src/ReactFiberNewContext.old.js @@ -7,7 +7,7 @@ * @flow */ -import type {ReactContext} from 'shared/ReactTypes'; +import type {ReactContext, ReactProviderType} from 'shared/ReactTypes'; import type { Fiber, ContextDependency, @@ -33,6 +33,7 @@ import { mergeLanes, pickArbitraryLane, } from './ReactFiberLane.old'; +import {NoFlags, DidPropagateContext} from './ReactFiberFlags'; import invariant from 'shared/invariant'; import is from 'shared/objectIs'; @@ -328,6 +329,88 @@ export function propagateContextChange( } } +export function lazilyPropagateParentContextChanges( + current: Fiber, + workInProgress: Fiber, + renderLanes: Lanes, +) { + propagateParentContextChanges(current, workInProgress, renderLanes, true); +} + +export function propagateParentContextChangesToDeferredTree( + current: Fiber, + workInProgress: Fiber, + renderLanes: Lanes, +) { + propagateParentContextChanges(current, workInProgress, renderLanes, false); +} + +function propagateParentContextChanges( + current: Fiber, + workInProgress: Fiber, + renderLanes: Lanes, + bailoutAfterFirstMatch: boolean, +) { + if (!enableLazyContextPropagation) { + return false; + } + + let parent = workInProgress; + while (parent !== null && (parent.flags & DidPropagateContext) === NoFlags) { + if (parent.tag === ContextProvider) { + const currentParent = parent.alternate; + if (currentParent !== null) { + const oldProps = currentParent.memoizedProps; + if (oldProps !== null) { + const providerType: ReactProviderType = parent.type; + const context: ReactContext = providerType._context; + + const newProps = parent.pendingProps; + const newValue = newProps.value; + + const oldValue = oldProps.value; + + const changedBits = calculateChangedBits(context, newValue, oldValue); + if (changedBits !== 0) { + // The context value changed. Search for matching consumers and + // schedule them to update. + propagateContextChange( + workInProgress, + context, + changedBits, + renderLanes, + ); + // If we found a matching consumer, we can stop any remaining + // propagation until the next level when we bail out. + if ( + // propagateParentContextChangesToDeferredTree doesn't bail out + // after the first match; it propagates *all* the matching + // providers. This is used by Suspense and Offscreen because they + // spawn work in a subtree that isn't resumed until a later + // render, by which time we won't be able to tell that the + // provider has changed. + bailoutAfterFirstMatch && + includesSomeLane(workInProgress.childLanes, renderLanes) + ) { + return; + } + } + } + } + } + parent = parent.return; + } + + // This is an optimization so that we only propagate each provider once. If + // a deeply nested child bails out, and it calls this propagation function, it + // uses this flag to know that the remaining ancestor providers have already + // been propagated. + // + // Note: We can only set this if we propagated every provider ancestor. So + // it must come *after* the early return above. + workInProgress.flags |= DidPropagateContext; +} + export function checkIfContextChanged(currentDependencies: Dependencies) { if (!enableLazyContextPropagation) { return false; diff --git a/packages/react-reconciler/src/ReactFiberThrow.new.js b/packages/react-reconciler/src/ReactFiberThrow.new.js index af199f0aa8e81..b91451c7f774a 100644 --- a/packages/react-reconciler/src/ReactFiberThrow.new.js +++ b/packages/react-reconciler/src/ReactFiberThrow.new.js @@ -38,6 +38,7 @@ import {NoMode, BlockingMode, DebugTracingMode} from './ReactTypeOfMode'; import { enableDebugTracing, enableSchedulingProfiler, + enableLazyContextPropagation, } from 'shared/ReactFeatureFlags'; import {createCapturedValue} from './ReactCapturedValue'; import { @@ -60,6 +61,7 @@ import { isAlreadyFailedLegacyErrorBoundary, pingSuspendedRoot, } from './ReactFiberWorkLoop.new'; +import {propagateParentContextChangesToDeferredTree} from './ReactFiberNewContext.new'; import {logCapturedError} from './ReactFiberErrorLogger'; import {logComponentSuspended} from './DebugTracing'; import {markComponentSuspended} from './SchedulingProfiler'; @@ -194,6 +196,23 @@ function throwException( typeof value === 'object' && typeof value.then === 'function' ) { + if (enableLazyContextPropagation) { + const currentSourceFiber = sourceFiber.alternate; + if (currentSourceFiber !== null) { + // Since we never visited the children of the suspended component, we + // need to propagate the context change now, to ensure that we visit + // them during the retry. + // + // We don't have to do this for errors because we retry errors without + // committing in between. So this is specific to Suspense. + propagateParentContextChangesToDeferredTree( + currentSourceFiber, + sourceFiber, + rootRenderLanes, + ); + } + } + // This is a wakeable. const wakeable: Wakeable = (value: any); diff --git a/packages/react-reconciler/src/ReactFiberThrow.old.js b/packages/react-reconciler/src/ReactFiberThrow.old.js index 6ae53d02fc5ce..a8aa2dcf24e0b 100644 --- a/packages/react-reconciler/src/ReactFiberThrow.old.js +++ b/packages/react-reconciler/src/ReactFiberThrow.old.js @@ -38,6 +38,7 @@ import {NoMode, BlockingMode, DebugTracingMode} from './ReactTypeOfMode'; import { enableDebugTracing, enableSchedulingProfiler, + enableLazyContextPropagation, } from 'shared/ReactFeatureFlags'; import {createCapturedValue} from './ReactCapturedValue'; import { @@ -60,6 +61,7 @@ import { isAlreadyFailedLegacyErrorBoundary, pingSuspendedRoot, } from './ReactFiberWorkLoop.old'; +import {propagateParentContextChangesToDeferredTree} from './ReactFiberNewContext.old'; import {logCapturedError} from './ReactFiberErrorLogger'; import {logComponentSuspended} from './DebugTracing'; import {markComponentSuspended} from './SchedulingProfiler'; @@ -194,6 +196,23 @@ function throwException( typeof value === 'object' && typeof value.then === 'function' ) { + if (enableLazyContextPropagation) { + const currentSourceFiber = sourceFiber.alternate; + if (currentSourceFiber !== null) { + // Since we never visited the children of the suspended component, we + // need to propagate the context change now, to ensure that we visit + // them during the retry. + // + // We don't have to do this for errors because we retry errors without + // committing in between. So this is specific to Suspense. + propagateParentContextChangesToDeferredTree( + currentSourceFiber, + sourceFiber, + rootRenderLanes, + ); + } + } + // This is a wakeable. const wakeable: Wakeable = (value: any); diff --git a/packages/react-reconciler/src/__tests__/ReactContextPropagation-test.js b/packages/react-reconciler/src/__tests__/ReactContextPropagation-test.js index bdc89014ad640..0f3f33bc5359d 100644 --- a/packages/react-reconciler/src/__tests__/ReactContextPropagation-test.js +++ b/packages/react-reconciler/src/__tests__/ReactContextPropagation-test.js @@ -3,6 +3,11 @@ let ReactNoop; let Scheduler; let useState; let useContext; +let Suspense; +let SuspenseList; +let getCacheForType; +let caches; +let seededCache; describe('ReactLazyContextPropagation', () => { beforeEach(() => { @@ -13,17 +18,148 @@ describe('ReactLazyContextPropagation', () => { Scheduler = require('scheduler'); useState = React.useState; useContext = React.useContext; + Suspense = React.Suspense; + SuspenseList = React.SuspenseList; + + getCacheForType = React.unstable_getCacheForType; + + caches = []; + seededCache = null; }); + // NOTE: These tests are not specific to the lazy propagation (as opposed to + // eager propagation). The behavior should be the same in both + // implementations. These are tests that are more relevant to the lazy + // propagation implementation, though. + + function createTextCache() { + if (seededCache !== null) { + // Trick to seed a cache before it exists. + // TODO: Need a built-in API to seed data before the initial render (i.e. + // not a refresh because nothing has mounted yet). + const cache = seededCache; + seededCache = null; + return cache; + } + + const data = new Map(); + const version = caches.length + 1; + const cache = { + version, + data, + resolve(text) { + const record = data.get(text); + if (record === undefined) { + const newRecord = { + status: 'resolved', + value: text, + }; + data.set(text, newRecord); + } else if (record.status === 'pending') { + const thenable = record.value; + record.status = 'resolved'; + record.value = text; + thenable.pings.forEach(t => t()); + } + }, + reject(text, error) { + const record = data.get(text); + if (record === undefined) { + const newRecord = { + status: 'rejected', + value: error, + }; + data.set(text, newRecord); + } else if (record.status === 'pending') { + const thenable = record.value; + record.status = 'rejected'; + record.value = error; + thenable.pings.forEach(t => t()); + } + }, + }; + caches.push(cache); + return cache; + } + + function readText(text) { + const textCache = getCacheForType(createTextCache); + const record = textCache.data.get(text); + if (record !== undefined) { + switch (record.status) { + case 'pending': + Scheduler.unstable_yieldValue(`Suspend! [${text}]`); + throw record.value; + case 'rejected': + Scheduler.unstable_yieldValue(`Error! [${text}]`); + throw record.value; + case 'resolved': + return textCache.version; + } + } else { + Scheduler.unstable_yieldValue(`Suspend! [${text}]`); + + const thenable = { + pings: [], + then(resolve) { + if (newRecord.status === 'pending') { + thenable.pings.push(resolve); + } else { + Promise.resolve().then(() => resolve(newRecord.value)); + } + }, + }; + + const newRecord = { + status: 'pending', + value: thenable, + }; + textCache.data.set(text, newRecord); + + throw thenable; + } + } + function Text({text}) { Scheduler.unstable_yieldValue(text); return text; } - // NOTE: These tests are not specific to the lazy propagation (as opposed to - // eager propagation). The behavior should be the same in both - // implementations. These are tests that are more relevant to the lazy - // propagation implementation, though. + // function AsyncText({text, showVersion}) { + // const version = readText(text); + // const fullText = showVersion ? `${text} [v${version}]` : text; + // Scheduler.unstable_yieldValue(fullText); + // return text; + // } + + function seedNextTextCache(text) { + if (seededCache === null) { + seededCache = createTextCache(); + } + seededCache.resolve(text); + } + + function resolveMostRecentTextCache(text) { + if (caches.length === 0) { + throw Error('Cache does not exist.'); + } else { + // Resolve the most recently created cache. An older cache can by + // resolved with `caches[index].resolve(text)`. + caches[caches.length - 1].resolve(text); + } + } + + const resolveText = resolveMostRecentTextCache; + + // function rejectMostRecentTextCache(text, error) { + // if (caches.length === 0) { + // throw Error('Cache does not exist.'); + // } else { + // // Resolve the most recently created cache. An older cache can by + // // resolved with `caches[index].reject(text, error)`. + // caches[caches.length - 1].reject(text, error); + // } + // } test( 'context change should prevent bailout of memoized component (useMemo -> ' + @@ -203,4 +339,355 @@ describe('ReactLazyContextPropagation', () => { expect(Scheduler).toHaveYielded(['Consumer']); expect(root).toMatchRenderedOutput('0'); }); + + // @gate enableCache + test('context is propagated across retries', async () => { + const root = ReactNoop.createRoot(); + + const Context = React.createContext('A'); + + let setContext; + function App() { + const [value, setValue] = useState('A'); + setContext = setValue; + return ( + + }> + + + + + ); + } + + function Async() { + const value = useContext(Context); + readText(value); + + // When `readText` suspends, we haven't yet visited Indirection and all + // of its children. They won't get rendered until a later retry. + return ; + } + + const Indirection = React.memo(() => { + // This child must always be consistent with the sibling Text component. + return ; + }); + + function DeepChild() { + const value = useContext(Context); + return ; + } + + await seedNextTextCache('A'); + await ReactNoop.act(async () => { + root.render(); + }); + expect(Scheduler).toHaveYielded(['A', 'A']); + expect(root).toMatchRenderedOutput('AA'); + + await ReactNoop.act(async () => { + // Intentionally not wrapping in startTransition, so that the fallback + // the fallback displays despite this being a refresh. + setContext('B'); + }); + expect(Scheduler).toHaveYielded(['Suspend! [B]', 'Loading...', 'B']); + expect(root).toMatchRenderedOutput('Loading...B'); + + await ReactNoop.act(async () => { + await resolveText('B'); + }); + expect(Scheduler).toHaveYielded(['B']); + expect(root).toMatchRenderedOutput('BB'); + }); + + // @gate enableCache + test('multiple contexts are propagated across retries', async () => { + // Same as previous test, but with multiple context providers + const root = ReactNoop.createRoot(); + + const Context1 = React.createContext('A'); + const Context2 = React.createContext('A'); + + let setContext; + function App() { + const [value, setValue] = useState('A'); + setContext = setValue; + return ( + + + }> + + + + + + ); + } + + function Async() { + const value = useContext(Context1); + readText(value); + + // When `readText` suspends, we haven't yet visited Indirection and all + // of its children. They won't get rendered until a later retry. + return ( + <> + + + + ); + } + + const Indirection1 = React.memo(() => { + // This child must always be consistent with the sibling Text component. + return ; + }); + + const Indirection2 = React.memo(() => { + // This child must always be consistent with the sibling Text component. + return ; + }); + + function DeepChild1() { + const value = useContext(Context1); + return ; + } + + function DeepChild2() { + const value = useContext(Context2); + return ; + } + + await seedNextTextCache('A'); + await ReactNoop.act(async () => { + root.render(); + }); + expect(Scheduler).toHaveYielded(['A', 'A', 'A']); + expect(root).toMatchRenderedOutput('AAA'); + + await ReactNoop.act(async () => { + // Intentionally not wrapping in startTransition, so that the fallback + // the fallback displays despite this being a refresh. + setContext('B'); + }); + expect(Scheduler).toHaveYielded(['Suspend! [B]', 'Loading...', 'B']); + expect(root).toMatchRenderedOutput('Loading...B'); + + await ReactNoop.act(async () => { + await resolveText('B'); + }); + expect(Scheduler).toHaveYielded(['B', 'B']); + expect(root).toMatchRenderedOutput('BBB'); + }); + + // @gate enableCache + test('context is propagated across retries (legacy)', async () => { + const root = ReactNoop.createLegacyRoot(); + + const Context = React.createContext('A'); + + let setContext; + function App() { + const [value, setValue] = useState('A'); + setContext = setValue; + return ( + + }> + + + + + ); + } + + function Async() { + const value = useContext(Context); + readText(value); + + // When `readText` suspends, we haven't yet visited Indirection and all + // of its children. They won't get rendered until a later retry. + return ; + } + + const Indirection = React.memo(() => { + // This child must always be consistent with the sibling Text component. + return ; + }); + + function DeepChild() { + const value = useContext(Context); + return ; + } + + await seedNextTextCache('A'); + await ReactNoop.act(async () => { + root.render(); + }); + expect(Scheduler).toHaveYielded(['A', 'A']); + expect(root).toMatchRenderedOutput('AA'); + + await ReactNoop.act(async () => { + // Intentionally not wrapping in startTransition, so that the fallback + // the fallback displays despite this being a refresh. + setContext('B'); + }); + expect(Scheduler).toHaveYielded(['Suspend! [B]', 'Loading...', 'B']); + expect(root).toMatchRenderedOutput('Loading...B'); + + await ReactNoop.act(async () => { + await resolveText('B'); + }); + expect(Scheduler).toHaveYielded(['B']); + expect(root).toMatchRenderedOutput('BB'); + }); + + // @gate enableCache + test('context is propagated across through offscreen trees', async () => { + const LegacyHidden = React.unstable_LegacyHidden; + + const root = ReactNoop.createRoot(); + + const Context = React.createContext('A'); + + let setContext; + function App() { + const [value, setValue] = useState('A'); + setContext = setValue; + return ( + + + + + + + ); + } + + const Indirection = React.memo(() => { + // This child must always be consistent with the sibling Text component. + return ; + }); + + function DeepChild() { + const value = useContext(Context); + return ; + } + + await seedNextTextCache('A'); + await ReactNoop.act(async () => { + root.render(); + }); + expect(Scheduler).toHaveYielded(['A', 'A']); + expect(root).toMatchRenderedOutput('AA'); + + await ReactNoop.act(async () => { + setContext('B'); + }); + expect(Scheduler).toHaveYielded(['B', 'B']); + expect(root).toMatchRenderedOutput('BB'); + }); + + // @gate enableCache + test('multiple contexts are propagated across through offscreen trees', async () => { + // Same as previous test, but with multiple context providers + const LegacyHidden = React.unstable_LegacyHidden; + + const root = ReactNoop.createRoot(); + + const Context1 = React.createContext('A'); + const Context2 = React.createContext('A'); + + let setContext; + function App() { + const [value, setValue] = useState('A'); + setContext = setValue; + return ( + + + + + + + + + + ); + } + + const Indirection1 = React.memo(() => { + // This child must always be consistent with the sibling Text component. + return ; + }); + + const Indirection2 = React.memo(() => { + // This child must always be consistent with the sibling Text component. + return ; + }); + + function DeepChild1() { + const value = useContext(Context1); + return ; + } + + function DeepChild2() { + const value = useContext(Context2); + return ; + } + + await seedNextTextCache('A'); + await ReactNoop.act(async () => { + root.render(); + }); + expect(Scheduler).toHaveYielded(['A', 'A', 'A']); + expect(root).toMatchRenderedOutput('AAA'); + + await ReactNoop.act(async () => { + setContext('B'); + }); + expect(Scheduler).toHaveYielded(['B', 'B', 'B']); + expect(root).toMatchRenderedOutput('BBB'); + }); + + // @gate enableCache + test('contexts are propagated through SuspenseList', async () => { + // This kinda tests an implementation detail. SuspenseList has an early + // bailout that doesn't use `bailoutOnAlreadyFinishedWork`. It probably + // should just use that function, though. + const Context = React.createContext('A'); + + let setContext; + function App() { + const [value, setValue] = useState('A'); + setContext = setValue; + const children = React.useMemo( + () => ( + + + + + ), + [], + ); + return {children}; + } + + function Child() { + const value = useContext(Context); + return ; + } + + const root = ReactNoop.createRoot(); + await ReactNoop.act(async () => { + root.render(); + }); + expect(Scheduler).toHaveYielded(['A', 'A']); + expect(root).toMatchRenderedOutput('AA'); + + await ReactNoop.act(async () => { + setContext('B'); + }); + expect(Scheduler).toHaveYielded(['B', 'B']); + expect(root).toMatchRenderedOutput('BB'); + }); }); diff --git a/packages/react-reconciler/src/__tests__/ReactNewContext-test.js b/packages/react-reconciler/src/__tests__/ReactNewContext-test.js index 3f7f4f992955b..43c1e0033c2f3 100644 --- a/packages/react-reconciler/src/__tests__/ReactNewContext-test.js +++ b/packages/react-reconciler/src/__tests__/ReactNewContext-test.js @@ -689,6 +689,7 @@ describe('ReactNewContext', () => { ]); }); + // @gate !enableLazyContextPropagation it('can skip parents with bitmask bailout while updating their children', () => { const Context = React.createContext({foo: 0, bar: 0}, (a, b) => { let result = 0; @@ -1077,10 +1078,13 @@ describe('ReactNewContext', () => { // Update ReactNoop.render(); - expect(() => expect(Scheduler).toFlushWithoutYielding()).toErrorDev( - 'calculateChangedBits: Expected the return value to be a 31-bit ' + - 'integer. Instead received: 4294967295', - ); + + if (gate(flags => !flags.enableLazyContextPropagation)) { + expect(() => expect(Scheduler).toFlushWithoutYielding()).toErrorDev( + 'calculateChangedBits: Expected the return value to be a 31-bit ' + + 'integer. Instead received: 4294967295', + ); + } }); it('warns if no value prop provided', () => {