Skip to content

Commit

Permalink
[Experiment] Lazily propagate context changes
Browse files Browse the repository at this point in the history
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>
  • Loading branch information
acdlite and gnoff committed Feb 26, 2021
1 parent fcf0c7b commit f3fd511
Show file tree
Hide file tree
Showing 9 changed files with 921 additions and 78 deletions.
130 changes: 107 additions & 23 deletions packages/react-reconciler/src/ReactFiberBeginWork.new.js
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ import {
warnAboutDefaultPropsOnFunctionComponents,
enableScopeAPI,
enableCache,
enableLazyContextPropagation,
} from 'shared/ReactFeatureFlags';
import invariant from 'shared/invariant';
import shallowEqual from 'shared/shallowEqual';
Expand Down Expand Up @@ -153,6 +154,9 @@ import {findFirstSuspended} from './ReactFiberSuspenseComponent.new';
import {
pushProvider,
propagateContextChange,
lazilyPropagateParentContextChanges,
propagateParentContextChangesToDeferredTree,
checkIfContextChanged,
readContext,
prepareToReadContext,
calculateChangedBits,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
}
}

Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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) {
Expand All @@ -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;

Expand Down Expand Up @@ -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;
}
}
Expand All @@ -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
Expand Down
Loading

0 comments on commit f3fd511

Please sign in to comment.