Skip to content

Commit bde8c75

Browse files
acdlitegnoff
andcommitted
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. 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](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. (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 <jcs.gnoff@gmail.com>
1 parent fcf0c7b commit bde8c75

9 files changed

+921
-78
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';
@@ -153,6 +154,9 @@ import {findFirstSuspended} from './ReactFiberSuspenseComponent.new';
153154
import {
154155
pushProvider,
155156
propagateContextChange,
157+
lazilyPropagateParentContextChanges,
158+
propagateParentContextChangesToDeferredTree,
159+
checkIfContextChanged,
156160
readContext,
157161
prepareToReadContext,
158162
calculateChangedBits,
@@ -645,6 +649,18 @@ function updateOffscreenComponent(
645649
// We're about to bail out, but we need to push this to the stack anyway
646650
// to avoid a push/pop misalignment.
647651
pushRenderLanes(workInProgress, nextBaseLanes);
652+
653+
if (enableLazyContextPropagation && current !== null) {
654+
// Since this tree will resume rendering in a separate render, we need
655+
// to propagate parent contexts now so we don't lose track of which
656+
// ones changed.
657+
propagateParentContextChangesToDeferredTree(
658+
current,
659+
workInProgress,
660+
renderLanes,
661+
);
662+
}
663+
648664
return null;
649665
} else {
650666
// This is the second render. The surrounding visible content has already
@@ -2444,6 +2460,19 @@ function updateDehydratedSuspenseComponent(
24442460
renderLanes,
24452461
);
24462462
}
2463+
2464+
if (
2465+
enableLazyContextPropagation &&
2466+
// TODO: Factoring is a little weird, since we check this right below, too.
2467+
// But don't want to re-arrange the if-else chain until/unless this
2468+
// feature lands.
2469+
!didReceiveUpdate
2470+
) {
2471+
// We need to check if any children have context before we decide to bail
2472+
// out, so propagate the changes now.
2473+
lazilyPropagateParentContextChanges(current, workInProgress, renderLanes);
2474+
}
2475+
24472476
// We use lanes to indicate that a child might depend on context, so if
24482477
// any context has changed, we need to treat is as if the input might have changed.
24492478
const hasContextChanged = includesSomeLane(renderLanes, current.childLanes);
@@ -2969,25 +2998,37 @@ function updateContextProvider(
29692998

29702999
pushProvider(workInProgress, context, newValue);
29713000

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

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

31113162
function remountFiber(
@@ -3174,7 +3225,7 @@ function beginWork(
31743225
workInProgress: Fiber,
31753226
renderLanes: Lanes,
31763227
): Fiber | null {
3177-
const updateLanes = workInProgress.lanes;
3228+
let updateLanes = workInProgress.lanes;
31783229

31793230
if (__DEV__) {
31803231
if (workInProgress._debugNeedsRemount && current !== null) {
@@ -3195,6 +3246,17 @@ function beginWork(
31953246
}
31963247

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

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

3332-
const hasChildWork = includesSomeLane(
3397+
let hasChildWork = includesSomeLane(
33333398
renderLanes,
33343399
workInProgress.childLanes,
33353400
);
33363401

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

0 commit comments

Comments
 (0)