From bde8c754d91d2a687a8b3d2aadabd2249915076e Mon Sep 17 00:00:00 2001 From: Andrew Clark Date: Thu, 25 Feb 2021 22:53:02 -0600 Subject: [PATCH] Experiment: Lazily propagate context changes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a context provider changes, we scan the tree for matching consumers and mark them as dirty so that we know they have pending work. This prevents us from bailing out if, say, an intermediate wrapper is memoized. Currently, we propagate these changes eagerly, at the provider. However, in many cases, we would have ended up visiting the consumer nodes anyway, as part of the normal render traversal, because there's no memoized node in between that bails out. We can save CPU cycles by propagating changes only when we hit a memoized component — so, instead of propagating eagerly at the provider, we propagate lazily if or when something bails out. Another neat optimization is that if multiple context providers change simultaneously, we don't need to propagate all of them; we can stop propagating as soon as one of them matches a deep consumer. This works even though the providers have consumers in different parts of the tree, because we'll pick up the propagation algorithm again during the next nested bailout. Most of our bailout logic is centralized in `bailoutOnAlreadyFinishedWork`, so this ended up being not that difficult to implement correctly. There are some exceptions: Suspense and Offscreen. Those are special because they sometimes defer the rendering of their children to a completely separate render cycle. In those cases, we must take extra care to propagate *all* the context changes, not just the first one. I'm pleasantly surprised at how little I needed to change in this initial implementation. I was worried I'd have to use the reconciler fork, but I ended up being able to wrap all my changes in a regular feature flag. So, we could run an experiment in parallel to our other ones. I do consider this a risky rollout overall because of the potential for subtle semantic deviations. However, the model is simple enough that I don't expect us to have trouble fixing regressions if or when they arise during internal dogfooding. --- This is largely based on [RFC #118](https://github.com/reactjs/rfcs/pull/118), by @gnoff. I did deviate in some of the implementation details, though. The main one is how I chose to track context changes. Instead of storing a dirty flag on the stack, I added a `memoizedValue` field to the context dependency object. Then, to check if something has changed, the consumer compares the new context value to the old (memoized) one. This is necessary because of Suspense and Offscreen — those components defer work from one render into a later one. When the subtree continues rendering, the stack from the previous render is no longer available. But the memoized values on the dependencies list are. (Refer to the previous commit where I implemented this as its own atomic change.) This requires a bit more work when a consumer bails out, but nothing considerable, and there are ways we could optimize it even further. Concpeutally, this model is really appealing, since it matches how our other features "reactively" detect changes — `useMemo`, `useEffect`, `getDerivedStateFromProps`, the built-in cache, and so on. I also intentionally dropped support for `unstable_calculateChangedBits`. We're planning to remove this API anyway before the next major release, in favor of context selectors. It's an unstable feature that we never advertised; I don't think it's seen much adoption. Co-Authored-By: Josh Story --- .../src/ReactFiberBeginWork.new.js | 130 ++++- .../src/ReactFiberBeginWork.old.js | 130 ++++- .../react-reconciler/src/ReactFiberFlags.js | 45 +- .../src/ReactFiberNewContext.new.js | 74 ++- .../src/ReactFiberNewContext.old.js | 74 ++- .../src/ReactFiberThrow.new.js | 19 + .../src/ReactFiberThrow.old.js | 19 + .../__tests__/ReactContextPropagation-test.js | 496 +++++++++++++++++- .../src/__tests__/ReactNewContext-test.js | 12 +- 9 files changed, 921 insertions(+), 78 deletions(-) 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..55d35510e2299 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,77 @@ export function propagateContextChange( } } +export function lazilyPropagateParentContextChanges( + current: Fiber, + workInProgress: Fiber, + renderLanes: Lanes, +) { + propagateParentContextChanges(current, workInProgress, renderLanes, false); +} + +export function propagateParentContextChangesToDeferredTree( + current: Fiber, + workInProgress: Fiber, + renderLanes: Lanes, +) { + propagateParentContextChanges(current, workInProgress, renderLanes, true); +} + +function propagateParentContextChanges( + current: Fiber, + workInProgress: Fiber, + renderLanes: Lanes, + // TODO: This argument is currently unused. I think there's a way to optimize + // for the many providers case, where if the first propagation finds a match, + // the second one can avoid scanning down that same path. The trouble is that + // there could be a nested bailout below that. + forcePropagateEntireTree: 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, + ); + } + } + } + } + parent = parent.return; + } + + // This is an optimization so that we only propagate each provider once per + // subtree. (We will propagate the same provider to different subtrees, though + // — that's why the flag is on the fiber that bailed out, not the provider.) + // 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. + 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..f6025075bdb87 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,77 @@ export function propagateContextChange( } } +export function lazilyPropagateParentContextChanges( + current: Fiber, + workInProgress: Fiber, + renderLanes: Lanes, +) { + propagateParentContextChanges(current, workInProgress, renderLanes, false); +} + +export function propagateParentContextChangesToDeferredTree( + current: Fiber, + workInProgress: Fiber, + renderLanes: Lanes, +) { + propagateParentContextChanges(current, workInProgress, renderLanes, true); +} + +function propagateParentContextChanges( + current: Fiber, + workInProgress: Fiber, + renderLanes: Lanes, + // TODO: This argument is currently unused. I think there's a way to optimize + // for the many providers case, where if the first propagation finds a match, + // the second one can avoid scanning down that same path. The trouble is that + // there could be a nested bailout below that. + forcePropagateEntireTree: 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, + ); + } + } + } + } + parent = parent.return; + } + + // This is an optimization so that we only propagate each provider once per + // subtree. (We will propagate the same provider to different subtrees, though + // — that's why the flag is on the fiber that bailed out, not the provider.) + // 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. + 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..557cea6931f42 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.unstable_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,356 @@ 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 + // @gate experimental + 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', () => {