Skip to content

Commit c7b4497

Browse files
acdlitegnoff
andauthored
[Experiment] Lazily propagate context changes (#20890)
* Move context comparison to consumer In the lazy context implementation, not all context changes are propagated from the provider, so we can't rely on the propagation alone to mark the consumer as dirty. The consumer needs to compare to the previous value, like we do for state and context. I added a `memoizedValue` field to the context dependency type. Then in the consumer, we iterate over the current dependencies to see if something changed. We only do this iteration after props and state has already bailed out, so it's a relatively uncommon path, except at the root of a changed subtree. Alternatively, we could move these comparisons into `readContext`, but that's a much hotter path, so I think this is an appropriate trade off. * [Experiment] Lazily propagate context changes 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. 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](reactjs/rfcs#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. This requires a bit more work when a consumer bails out, but nothing considerable, and there are ways we could optimize it even further. Conceptually, 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 <jcs.gnoff@gmail.com> * Propagate all contexts in single pass Instead of propagating the tree once per changed context, we can check all the contexts in a single propagation. This inverts the two loops so that the faster loop (O(numberOfContexts)) is inside the more expensive loop (O(numberOfFibers * avgContextDepsPerFiber)). This adds a bit of overhead to the case where only a single context changes because you have to unwrap the context from the array. I'm also unsure if this will hurt cache locality. Co-Authored-By: Josh Story <jcs.gnoff@gmail.com> * Stop propagating at nearest dependency match Because we now propagate all context providers in a single traversal, we can defer context propagation to a subtree without losing information about which context providers we're deferring — it's all of them. Theoretically, this is a big optimization because it means we'll never propagate to any tree that has work scheduled on it, nor will we ever propagate the same tree twice. There's an awkward case related to bailing out of the siblings of a context consumer. Because those siblings don't bail out until after they've already entered the begin phase, we have to do extra work to make sure they don't unecessarily propagate context again. We could avoid this by adding an earlier bailout for sibling nodes, something we've discussed in the past. We should consider this during the next refactor of the fiber tree structure. Co-Authored-By: Josh Story <jcs.gnoff@gmail.com> * Mark trees that need propagation in readContext Instead of storing matched context consumers in a Set, we can mark when a consumer receives an update inside `readContext`. I hesistated to put anything in this function because it's such a hot path, but so are bail outs. Fortunately, we only need to set this flag once, the first time a context is read. So I think it's a reasonable trade off. In exchange, propagation is faster because we no longer need to accumulate a Set of matched consumers, and fiber bailouts are faster because we don't need to consult that Set. And the code is simpler. Co-authored-by: Josh Story <jcs.gnoff@gmail.com>
1 parent 258b375 commit c7b4497

10 files changed

+1621
-151
lines changed

packages/react-reconciler/src/ReactFiberBeginWork.new.js

+107-23
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,7 @@ import {
8181
warnAboutDefaultPropsOnFunctionComponents,
8282
enableScopeAPI,
8383
enableCache,
84+
enableLazyContextPropagation,
8485
} from 'shared/ReactFeatureFlags';
8586
import invariant from 'shared/invariant';
8687
import shallowEqual from 'shared/shallowEqual';
@@ -154,6 +155,9 @@ import {findFirstSuspended} from './ReactFiberSuspenseComponent.new';
154155
import {
155156
pushProvider,
156157
propagateContextChange,
158+
lazilyPropagateParentContextChanges,
159+
propagateParentContextChangesToDeferredTree,
160+
checkIfContextChanged,
157161
readContext,
158162
prepareToReadContext,
159163
calculateChangedBits,
@@ -646,6 +650,18 @@ function updateOffscreenComponent(
646650
// We're about to bail out, but we need to push this to the stack anyway
647651
// to avoid a push/pop misalignment.
648652
pushRenderLanes(workInProgress, nextBaseLanes);
653+
654+
if (enableLazyContextPropagation && current !== null) {
655+
// Since this tree will resume rendering in a separate render, we need
656+
// to propagate parent contexts now so we don't lose track of which
657+
// ones changed.
658+
propagateParentContextChangesToDeferredTree(
659+
current,
660+
workInProgress,
661+
renderLanes,
662+
);
663+
}
664+
649665
return null;
650666
} else {
651667
// This is the second render. The surrounding visible content has already
@@ -2445,6 +2461,19 @@ function updateDehydratedSuspenseComponent(
24452461
renderLanes,
24462462
);
24472463
}
2464+
2465+
if (
2466+
enableLazyContextPropagation &&
2467+
// TODO: Factoring is a little weird, since we check this right below, too.
2468+
// But don't want to re-arrange the if-else chain until/unless this
2469+
// feature lands.
2470+
!didReceiveUpdate
2471+
) {
2472+
// We need to check if any children have context before we decide to bail
2473+
// out, so propagate the changes now.
2474+
lazilyPropagateParentContextChanges(current, workInProgress, renderLanes);
2475+
}
2476+
24482477
// We use lanes to indicate that a child might depend on context, so if
24492478
// any context has changed, we need to treat is as if the input might have changed.
24502479
const hasContextChanged = includesSomeLane(renderLanes, current.childLanes);
@@ -2970,25 +2999,37 @@ function updateContextProvider(
29702999

29713000
pushProvider(workInProgress, context, newValue);
29723001

2973-
if (oldProps !== null) {
2974-
const oldValue = oldProps.value;
2975-
const changedBits = calculateChangedBits(context, newValue, oldValue);
2976-
if (changedBits === 0) {
2977-
// No change. Bailout early if children are the same.
2978-
if (
2979-
oldProps.children === newProps.children &&
2980-
!hasLegacyContextChanged()
2981-
) {
2982-
return bailoutOnAlreadyFinishedWork(
2983-
current,
3002+
if (enableLazyContextPropagation) {
3003+
// In the lazy propagation implementation, we don't scan for matching
3004+
// consumers until something bails out, because until something bails out
3005+
// we're going to visit those nodes, anyway. The trade-off is that it shifts
3006+
// responsibility to the consumer to track whether something has changed.
3007+
} else {
3008+
if (oldProps !== null) {
3009+
const oldValue = oldProps.value;
3010+
const changedBits = calculateChangedBits(context, newValue, oldValue);
3011+
if (changedBits === 0) {
3012+
// No change. Bailout early if children are the same.
3013+
if (
3014+
oldProps.children === newProps.children &&
3015+
!hasLegacyContextChanged()
3016+
) {
3017+
return bailoutOnAlreadyFinishedWork(
3018+
current,
3019+
workInProgress,
3020+
renderLanes,
3021+
);
3022+
}
3023+
} else {
3024+
// The context value changed. Search for matching consumers and schedule
3025+
// them to update.
3026+
propagateContextChange(
29843027
workInProgress,
3028+
context,
3029+
changedBits,
29853030
renderLanes,
29863031
);
29873032
}
2988-
} else {
2989-
// The context value changed. Search for matching consumers and schedule
2990-
// them to update.
2991-
propagateContextChange(workInProgress, context, changedBits, renderLanes);
29923033
}
29933034
}
29943035

@@ -3100,13 +3141,23 @@ function bailoutOnAlreadyFinishedWork(
31003141
// The children don't have any work either. We can skip them.
31013142
// TODO: Once we add back resuming, we should check if the children are
31023143
// a work-in-progress set. If so, we need to transfer their effects.
3103-
return null;
3104-
} else {
3105-
// This fiber doesn't have work, but its subtree does. Clone the child
3106-
// fibers and continue.
3107-
cloneChildFibers(current, workInProgress);
3108-
return workInProgress.child;
3144+
3145+
if (enableLazyContextPropagation && current !== null) {
3146+
// Before bailing out, check if there are any context changes in
3147+
// the children.
3148+
lazilyPropagateParentContextChanges(current, workInProgress, renderLanes);
3149+
if (!includesSomeLane(renderLanes, workInProgress.childLanes)) {
3150+
return null;
3151+
}
3152+
} else {
3153+
return null;
3154+
}
31093155
}
3156+
3157+
// This fiber doesn't have work, but its subtree does. Clone the child
3158+
// fibers and continue.
3159+
cloneChildFibers(current, workInProgress);
3160+
return workInProgress.child;
31103161
}
31113162

31123163
function remountFiber(
@@ -3175,7 +3226,7 @@ function beginWork(
31753226
workInProgress: Fiber,
31763227
renderLanes: Lanes,
31773228
): Fiber | null {
3178-
const updateLanes = workInProgress.lanes;
3229+
let updateLanes = workInProgress.lanes;
31793230

31803231
if (__DEV__) {
31813232
if (workInProgress._debugNeedsRemount && current !== null) {
@@ -3196,6 +3247,17 @@ function beginWork(
31963247
}
31973248

31983249
if (current !== null) {
3250+
// TODO: The factoring of this block is weird.
3251+
if (
3252+
enableLazyContextPropagation &&
3253+
!includesSomeLane(renderLanes, updateLanes)
3254+
) {
3255+
const dependencies = current.dependencies;
3256+
if (dependencies !== null && checkIfContextChanged(dependencies)) {
3257+
updateLanes = mergeLanes(updateLanes, renderLanes);
3258+
}
3259+
}
3260+
31993261
const oldProps = current.memoizedProps;
32003262
const newProps = workInProgress.pendingProps;
32013263

@@ -3316,6 +3378,9 @@ function beginWork(
33163378
// primary children and work on the fallback.
33173379
return child.sibling;
33183380
} else {
3381+
// Note: We can return `null` here because we already checked
3382+
// whether there were nested context consumers, via the call to
3383+
// `bailoutOnAlreadyFinishedWork` above.
33193384
return null;
33203385
}
33213386
}
@@ -3330,11 +3395,30 @@ function beginWork(
33303395
case SuspenseListComponent: {
33313396
const didSuspendBefore = (current.flags & DidCapture) !== NoFlags;
33323397

3333-
const hasChildWork = includesSomeLane(
3398+
let hasChildWork = includesSomeLane(
33343399
renderLanes,
33353400
workInProgress.childLanes,
33363401
);
33373402

3403+
if (enableLazyContextPropagation && !hasChildWork) {
3404+
// Context changes may not have been propagated yet. We need to do
3405+
// that now, before we can decide whether to bail out.
3406+
// TODO: We use `childLanes` as a heuristic for whether there is
3407+
// remaining work in a few places, including
3408+
// `bailoutOnAlreadyFinishedWork` and
3409+
// `updateDehydratedSuspenseComponent`. We should maybe extract this
3410+
// into a dedicated function.
3411+
lazilyPropagateParentContextChanges(
3412+
current,
3413+
workInProgress,
3414+
renderLanes,
3415+
);
3416+
hasChildWork = includesSomeLane(
3417+
renderLanes,
3418+
workInProgress.childLanes,
3419+
);
3420+
}
3421+
33383422
if (didSuspendBefore) {
33393423
if (hasChildWork) {
33403424
// If something was in fallback state last time, and we have all the

0 commit comments

Comments
 (0)