Skip to content

Conversation

acdlite
Copy link
Collaborator

@acdlite acdlite commented Feb 26, 2021

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 traverse the tree separately for each provider – we can propagate all providers in a single pass. A related benefit is that we can stop propagating as soon as we find a matching consumer. We'll never waste cycles searching for context changes inside a subtree that we would have visited during the render phase regardless.

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, 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.

@facebook-github-bot facebook-github-bot added CLA Signed React Core Team Opened by a member of the React Core Team labels Feb 26, 2021
@acdlite acdlite force-pushed the lazy-context-propagation branch from 6f4d5d5 to c713608 Compare February 26, 2021 05:03
@sizebot
Copy link

sizebot commented Feb 26, 2021

Comparing: 7df6572...258b375

Critical size changes

Includes critical production bundles, as well as any change greater than 2%:

Name +/- Base Current +/- gzip Base gzip Current gzip
oss-stable/react-dom/cjs/react-dom.production.min.js +0.02% 123.30 kB 123.32 kB +0.02% 39.67 kB 39.67 kB
oss-experimental/react-dom/cjs/react-dom.production.min.js +0.02% 129.88 kB 129.91 kB = 41.69 kB 41.70 kB
facebook-www/ReactDOM-prod.classic.js +0.32% 407.38 kB 408.70 kB +0.34% 75.56 kB 75.82 kB
facebook-www/ReactDOM-prod.modern.js +0.33% 395.72 kB 397.04 kB +0.35% 73.69 kB 73.95 kB
facebook-www/ReactDOMForked-prod.classic.js +0.32% 407.39 kB 408.71 kB +0.34% 75.56 kB 75.82 kB

Significant size changes

Includes any change greater than 0.2%:

Expand to show
Name +/- Base Current +/- gzip Base gzip Current gzip
facebook-www/ReactIs-dev.classic.js +0.87% 10.02 kB 10.10 kB +0.55% 2.71 kB 2.73 kB
facebook-www/ReactIs-dev.modern.js +0.85% 10.28 kB 10.37 kB +0.54% 2.76 kB 2.77 kB
facebook-www/SchedulerTracing-dev.modern.js +0.77% 11.31 kB 11.40 kB +0.72% 2.49 kB 2.50 kB
facebook-www/SchedulerTracing-dev.classic.js +0.77% 11.31 kB 11.40 kB +0.68% 2.49 kB 2.50 kB
facebook-www/ReactART-prod.modern.js +0.52% 253.26 kB 254.58 kB +0.58% 45.22 kB 45.48 kB
facebook-www/ReactART-prod.classic.js +0.51% 260.62 kB 261.94 kB +0.54% 46.49 kB 46.74 kB
facebook-www/ReactART-dev.modern.js +0.42% 688.37 kB 691.24 kB +0.56% 146.37 kB 147.18 kB
facebook-www/ReactART-dev.classic.js +0.41% 698.66 kB 701.54 kB +0.54% 148.49 kB 149.30 kB
facebook-www/ReactDOM-prod.modern.js +0.33% 395.72 kB 397.04 kB +0.35% 73.69 kB 73.95 kB
facebook-www/ReactDOMForked-prod.modern.js +0.33% 395.73 kB 397.05 kB +0.35% 73.69 kB 73.95 kB
facebook-www/ReactDOM-prod.classic.js +0.32% 407.38 kB 408.70 kB +0.34% 75.56 kB 75.82 kB
facebook-www/ReactDOMForked-prod.classic.js +0.32% 407.39 kB 408.71 kB +0.34% 75.56 kB 75.82 kB
facebook-www/ReactDOM-profiling.modern.js +0.32% 415.61 kB 416.93 kB +0.34% 77.18 kB 77.44 kB
facebook-www/ReactDOMForked-profiling.modern.js +0.32% 415.62 kB 416.94 kB +0.34% 77.18 kB 77.44 kB
facebook-www/ReactDOM-profiling.classic.js +0.31% 427.32 kB 428.64 kB +0.34% 79.02 kB 79.29 kB
facebook-www/ReactDOMForked-profiling.classic.js +0.31% 427.33 kB 428.65 kB +0.34% 79.03 kB 79.29 kB
facebook-www/ReactDOM-dev.modern.js +0.28% 1,030.09 kB 1,032.96 kB +0.36% 228.89 kB 229.72 kB
facebook-www/ReactDOMForked-dev.modern.js +0.28% 1,030.10 kB 1,032.97 kB +0.36% 228.64 kB 229.46 kB
facebook-www/ReactDOM-dev.classic.js +0.27% 1,056.35 kB 1,059.22 kB +0.35% 234.07 kB 234.88 kB
facebook-www/ReactDOMForked-dev.classic.js +0.27% 1,056.36 kB 1,059.23 kB +0.35% 233.82 kB 234.63 kB
facebook-www/JSXDEVRuntime-dev.modern.js +0.21% 42.06 kB 42.15 kB +0.16% 11.76 kB 11.78 kB
facebook-www/JSXDEVRuntime-dev.classic.js +0.21% 42.07 kB 42.15 kB +0.16% 11.76 kB 11.78 kB

Generated by 🚫 dangerJS against 63941d5

@acdlite acdlite force-pushed the lazy-context-propagation branch from c713608 to c4b0b76 Compare February 26, 2021 05:18
}

if (current !== null) {
// TODO: The factoring of this block is weird.
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The bailout logic in this block is getting pretty confusing. I started to refactor it a bit, in part by extracting it into its own function, but there are a bunch of little cases and I didn't want to open up that can of worms right now, so instead I aimed to change it as little as possible. I think what I landed on is fine for now, and we can work on the correct abstractions if/when the feature lands.

@acdlite acdlite force-pushed the lazy-context-propagation branch from c4b0b76 to 9bab764 Compare February 26, 2021 05:26
}

let parent = workInProgress;
while (parent !== null && (parent.flags & DidPropagateContext) === NoFlags) {
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If you're looking for a place to start reviewing, I suggest here

@acdlite acdlite force-pushed the lazy-context-propagation branch 3 times, most recently from 2d4db97 to 557b0ec Compare February 26, 2021 05:50
@acdlite acdlite marked this pull request as ready for review February 26, 2021 05:55
@acdlite
Copy link
Collaborator Author

acdlite commented Feb 26, 2021

@gnoff I can't request a review from you for some reason, so I'm doing it with a comment. If you have the bandwidth, of course!

});

// @gate enableCache
test('context is propagated across retries', async () => {
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would also review these tests closely to get a sense of the type of issues we might see when rolling this out

@gnoff
Copy link
Collaborator

gnoff commented Feb 26, 2021

@acdlite happy to take a look tomorrow

@acdlite
Copy link
Collaborator Author

acdlite commented Feb 26, 2021

Hmm I don't know. Maybe someone changed an admin setting recently. I'll look into it tomorrow.

@gnoff
Copy link
Collaborator

gnoff commented Feb 26, 2021

a gave it a quick glance, love the approach reading up parent path to find providers rather than trying to awkwardly unify them. feels very familiar conceptually, super excited to see this stuff start to come about

}
}
}
parent = parent.return;
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

guess I can comment on line items, not sure what I was seeing before. anyway this whole thing approach is very elegant. it fits in much cleaner than the way I did it

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Still very much based on your PR and RFC 😊

@acdlite acdlite force-pushed the lazy-context-propagation branch 2 times, most recently from 3b6f9c7 to 8f7ac18 Compare February 26, 2021 07:04
@acdlite acdlite force-pushed the lazy-context-propagation branch 2 times, most recently from 5174c09 to bde8c75 Compare February 26, 2021 21:37
Copy link
Collaborator

@gnoff gnoff left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So lots of thoughts on this one but I'll try to keep this bit of feedback focussed. Your current approach defers propagation until a bailout which limits the propagation space significantly but it still does not allow for updates caused by one context to opportunistically cause a deferral of work for another once you hit a bailout. this is probably fine but this is the biggest distinction i think between our approaches (that and the fact you actually got suspense and off screen working... 😆)

I think trying an approach where you still propagate each provider individually but you constrain the propagation space to only work-free trees will allow you to capture this and maybe solve the issue you had with DidPropagateContext. I left some comments on where a few simple changes could cause you go to from your mode to mine. There is still something off about what I proposed in that you can end up with duplicate propagations for some sub-subtrees when you end up scheduling work on a sibling branch. solvable I hope but could mean my approach is unworkable or at least has different performance characteristics

One last thought is that when I was working on #18262 I somewhat came to the conclusion that lazy context propagation was not worth it and that general "speculative" work was more powerful and in many ways simpler and you could get the benefit of context selectors without lazy context propagation. This stuff is over a year old and I'm dusting off cobwebs in my brain but let me know if you want me to extrapolate on that at all

alternate.lanes = mergeLanes(alternate.lanes, renderLanes);
}
scheduleWorkOnParentPath(fiber.return, renderLanes);

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

consider not going deeper on propagation here

Suggested change
nextFiber = fiber.sibling

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The problem with not doing the full traversal is that we can't set DidPropagateContext. We'd need to replace that with something else to avoid propagating the same tree multiple times.

if (
dependency.context === context &&
(dependency.observedBits & changedBits) !== 0
) {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I can't comment on it b/c it didn't change but above

if (list !== null) {

consider bailing out on child work

if (
      enableLazyContextPropagation &&
      includesSomeLane(fiber.childLanes, renderLanes)
    ) {
      // we are going to do work in this subtree anyway, let's skip propagation
      // and check siblings
      nextFiber = fiber.sibling;
    } else if (list !== null) {

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah that's what I did here before I reverted it:

if (enableLazyContextPropagation && !forcePropagateEntireTree) {
// Bail out if there's already work scheduled
nextFiber = includesSomeLane(fiber.childLanes, renderLanes)
? null
: fiber.child;
} else {
nextFiber = fiber.child;
}

Explanation here: #20890 (comment)

}

let parent = workInProgress;
while (parent !== null && (parent.flags & DidPropagateContext) === NoFlags) {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

don't bail out on DidPropagateContext and let the parent loop ride to the root every time.
Then track already propagated providers using a Set or restructure entirely and maintain a "hasChanges" provider set that is added and deleted from on provider push / pop

}
}
}
parent = parent.return;
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This comment is not related to the others where I'm articulating a different approach, it's just an independent finding

There is an edge case where on any given bailout multiple stacked providers of the same context will be visited before you bail out on root or DidPropagateContext. In these cases changed bits are recalculated even for providers fibers that aren't top of stack. it won't technically break anything b/c the context value itself will always read consistently but it could lead to propagations that do not actually need to happen

@acdlite
Copy link
Collaborator Author

acdlite commented Feb 27, 2021

Thanks for the review!

So the change you are suggesting is essentially what I started with, but I had to backtrack for exactly the reason you describe here:

There is still something off about what I proposed in that you can end up with duplicate propagations for some sub-subtrees when you end up scheduling work on a sibling branch. solvable I hope but could mean my approach is unworkable or at least has different performance characteristics

There seems to be this inherent trade-off between deferring propagation until later, and keeping track of which trees have already been propagated. It's tempting to add an additional data structure to track that information but I haven't thought of an efficient way to do that.

Then track already propagated providers using a Set or restructure entirely and maintain a "hasChanges" provider set that is added and deleted from on provider push / pop

I think you would need one of these sets at every level at which you stop propagating. That's a lot of overhead that doesn't seem like it would pay for itself. Unless I misunderstand your suggestion.

One change I could try that would be relatively straightforward is to invert the loops so that there's a single tree traversal that checks all the contexts on the stack in one pass. That would make it more like your PR.

But what I'm realizing is that most of the optimizations we're discussing are only relevant if you have multiple contexts that changed simultaneously. That's usually not the case — it might be more common than necessary today because of the lack of selectors, but once you do have selectors, things that update simultaneously should be combined into a single context type. Then you get the benefits of a unified traversal.

So my current thinking is that we should optimize for the single changed context per render case. I'm going to run some performance experiments to see how much of an impact the current changes have on their own, and then use whatever we find there to inform the next steps.

One last thought is that when I was working on #18262 I somewhat came to the conclusion that lazy context propagation was not worth it and that general "speculative" work was more powerful and in many ways simpler and you could get the benefit of context selectors without lazy context propagation. This stuff is over a year old and I'm dusting off cobwebs in my brain but let me know if you want me to extrapolate on that at all

If by speculative you mean lazily cloning the intermediate work-in-progress nodes, I think that could be promising, but it's also a more significant refactor than we have the bandwidth to support right now. Would probably tackle something like this after we move away from the fiber alternate model (our next big planned refactor, to improve our memory model and get rid of cycles).

However, one interesting thing about the current implementation is that if a consumer matches during context propagation, that node is definitely going to re-render. There's no way to bail out of context updates — but, there will be once we have context selectors. And if we run the selectors during propagation, then we can bail out early and skip cloning that way. So that's what I'm going to try.

That leaves bailing out of state updates early. We do already have an optimization there that works pretty well, too.

So with that in mind, I wonder if lazily cloning really makes that much a difference, since there aren't many remaining cases where we would clone all the way down to a node only to bail out.

@acdlite acdlite force-pushed the lazy-context-propagation branch from bde8c75 to f3fd511 Compare February 27, 2021 18:47
@acdlite
Copy link
Collaborator Author

acdlite commented Feb 27, 2021

Another idea I had was to refactor the propagation function so that the deepest nodes are checked first; in other words, check when leaving a node instead of when entering it. If something matches, then we can bail out of checking any of its parents.

If we do it this way, we could also unify the traversal with scheduleWorkOnParentPath.

@ntucker
Copy link

ntucker commented Feb 27, 2021

Does this work with variable number of items selected? i.e.,

const fullContext = useContextSelector(Context, context => context.a.map(k => context[k]));

@acdlite
Copy link
Collaborator Author

acdlite commented Feb 27, 2021

@ntucker I think that's more relevant to the Context selectors RFC: reactjs/rfcs#119

@acdlite
Copy link
Collaborator Author

acdlite commented Mar 1, 2021

In your diagram, are the green nodes the siblings of the blue nodes? Why does the top pair have a line connecting them but not the ones below?

If the green nodes aren't direct siblings, but "cousins" instead, then we would never visit them during the render phase. We have a hard bailout where we return null inside bailoutOnAlreadyFinishedWork and reuse the current subtree.

If the green nodes are direct siblings of the blue nodes, then we will set DidPropagateContext at every level. So it will bail out as soon as it checks the parent. Nvm, I see what you're saying. The blue parents don't have DidPropagateContext set because they had work in their subtree. I thought you were saying earlier that we would end up propagating changes to the subtrees twice, but based on your diagram I take it you meant we'll traverse parent contexts that definitely couldn't have changed. You're right, this isn't ideal, but I kinda group this together with the idea that we shouldn't be entering the begin phase for those nodes at all; we should just clone them and immediately exit. But maybe there's a way to optimize this more that doesn't do completely that.

EDIT: Actually never mind again, the part I crossed out is correct. We always call propagateParentContexts inside bailoutOnAlreadyFinishedWork, so the green siblings will stop traversing the return path as soon as they check the parent.


I pushed another change. I got rid of the Set of matched consumers and replaced it with a flag called NeedsPropagation, which gets set any time context is read.

That means it gets set for any context consumer that was matched during the last propagation. But it also gets set on consumers that were re-rendered due to new props or state. So, it's a superset of what I was previously putting inside deferredPropagation. However, that's not a problem because anything that re-renders due to props or state needs propagation if it bails out, too. So the flag is correct regardless.

So the main downside is that I had to add an extra flag assignment to a non-bailout-specific path (readContext), which I was trying not to do. However, it's only set for the first context node per fiber. And in exchange we get to remove a lot of complexity from the bail out path.

The rest of the DidPropagateContext algorithm is the same as before.

@acdlite acdlite force-pushed the lazy-context-propagation branch from a77c31c to 620f8b7 Compare March 1, 2021 09:32
@ghost
Copy link

ghost commented Mar 1, 2021

Nvm, I see what you're saying. The blue parents don't have DidPropagateContext set because they had work in their subtree.

Yup this is what i meant, we re-check the contexts of the green nodes when it is impossible for anything to have changed and thus don't need to

we should just clone them and immediately exit.

Yeah actually why isn't it this simple? mark the fibers with no childwork on cloneChildFibers with a flag and skip them at the very top of beginWork?

I pushed another change.

I'll check it out

edit: posted this from another gh profile but it's still me (gnoff)

@acdlite
Copy link
Collaborator Author

acdlite commented Mar 1, 2021

It’s theoretically possible but it’d be a significant refactor because although the begin phase can be skipped, there’s still stuff we have to do in the complete phase.

That’s why there’s a giant bailout block at the top of beginWork, that mirrors a bunch of stuff that the regular begin phase does.

We’d lift out that bailout branch and put it in, say, cloneChildFibers instead.

Comment on lines +525 to +534
if (!isInsidePropagationBailout) {
if ((parent.flags & NeedsPropagation) !== NoFlags) {
isInsidePropagationBailout = true;
} else if ((parent.flags & DidPropagateContext) !== NoFlags) {
break;
}
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok this actually seems pretty nice. you're super close to my ideal where you could safely lift the selector bailout to the propagation. the only limitation I see is if you propagate through a scheduled update for state then that state update could change the tree where we already visited and did work (i.e. the selector might have closed over props and the state update changed the props the component will see). I wonder if you could force a NeedsPropagation flag to be written when an intermediate node that does not readContext and then stop propagation at any work, not just work the propagation 'found'

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I know we're back to the whole "we're operating on the current tree" problem but perhaps the NeedsPropagation flag is ok to drop on that tree because even if a render restarted it'll only cause additional propagations it won't ever let a necessary one go missing

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if you propagate through a scheduled update for state then that state update could change the tree where we already visited and did work

So this shouldn't happen because we always check that there are no matching childLanes before starting propagation, and if there's an update in a child then its childLanes would have been set.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

so that accounts for prop and state updates. The only other source of work is context, which we accounted for by combining all the contexts into a simultaneous propagation pass.

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So this shouldn't happen because we always check that there are no matching childLanes before starting propagation, and if there's an update in a child then its childLanes would have been set.

🤯 That's right! Did you really do it?!? I think this is all I hoped for 💪

@acdlite acdlite force-pushed the lazy-context-propagation branch 2 times, most recently from 51f7179 to d1bfb29 Compare March 2, 2021 21:37
Copy link
Member

@rickhanlonii rickhanlonii left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is awesome! Reviewed the overall strategy together offline, and left a couple non-blockers after reviewing here.

): void {
// Only used by lazy implemenation
if (!enableLazyContextPropagation) {
return;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this throw or are you calling this function in the old impl?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since this is probably for GCC stripping it, I'm specifically asking if we should throw here if GCC doesn't strip it for some reason.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Throwing isn't dead code so it doesn't help dead code elimination. It only helps indirectly by forcing us to wrap the caller in a condition. But I prefer to wrap in both places.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, for me that's a feature not a bug. If this were to accidentally be included, it would silently fail this way.

'calculateChangedBits: Expected the return value to be a 31-bit ' +
'integer. Instead received: 4294967295',
);
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I like having else blocks required for in-line feature flags so you either know what the expected behavior is in the other branch or can explain why it's missing, something like:

Suggested change
}
} else {
// TODO: Decide what to do with calculateChangedBits in enableLazyContextPropagation
}

// sCU is not called in this case because React force updates when a provider re-renders
expect(shouldComponentUpdateWasCalled).toBe(false);
if (gate(flags => flags.enableLazyContextPropagation)) {
expect(shouldComponentUpdateWasCalled).toBe(true);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Consider adding a comment similar to the other branch so that when that branch is deleted there's an explanation of the expectation here. Something like:

Suggested change
expect(shouldComponentUpdateWasCalled).toBe(true);
// sCU is called in this case because React does not force updates when a provider re-renders.
// Instead, we check context changes lazily, after sCU is called.
expect(shouldComponentUpdateWasCalled).toBe(true);

"385": "A mutable source was mutated while the %s component was rendering. This is not supported. Move any mutations into event handlers or effects.",
"386": "The current renderer does not support microtasks. This error is likely caused by a bug in React. Please file an issue."
"386": "The current renderer does not support microtasks. This error is likely caused by a bug in React. Please file an issue.",
"387": "Should have a current fiber. This is a bug in React.",
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What do you think about providing more context here so if we hear about it then we know it's related to the context propagation? As in "Should have a current fiber while propagating context. This is a bug in React." Same for the next error.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I went with a generic one so that we don't have to create a new code every time we assert that a current fiber is non-null. The stack and/or line number disambiguates.

Copy link
Member

@rickhanlonii rickhanlonii Mar 7, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah that's fair, but the trade off is that stack frames can change over time, are missing from reporting pipelines, or not reported by users (requiring an extra step). There's also value in having unique error codes for unique callsites for better reporting and easier debugging.

For example here, if this said it was related to lazy propagation, users opting into the feature (or us reading a bug report) would instantly know it was related to this gated feature instead of the process of finding the stack frame, parsing it, looking it up in the code base, and then knowing enough about the code to know it's related to this feature.

@acdlite
Copy link
Collaborator Author

acdlite commented Mar 7, 2021

I'm going to land in two steps so if one of them breaks they can be bisected

acdlite and others added 5 commits March 7, 2021 00:37
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.
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]>
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 <[email protected]>
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 <[email protected]>
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.
@acdlite acdlite force-pushed the lazy-context-propagation branch from d1bfb29 to 258b375 Compare March 7, 2021 06:37
@acdlite acdlite merged commit c7b4497 into facebook:master Mar 7, 2021
@gnoff
Copy link
Collaborator

gnoff commented Mar 7, 2021

🎉

@budarin
Copy link

budarin commented Apr 22, 2021

yet another one realization https://github.com/edriang/use-context-selection

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CLA Signed React Core Team Opened by a member of the React Core Team

Projects

None yet

Development

Successfully merging this pull request may close these issues.

7 participants