Skip to content

Commit 62a37a8

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. 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 <[email protected]>
1 parent 258b375 commit 62a37a8

9 files changed

+921
-78
lines changed

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

Lines changed: 107 additions & 23 deletions
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)