Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Experiment] Lazily propagate context changes #20890

Merged
merged 5 commits into from
Mar 7, 2021

Commits on Mar 7, 2021

  1. 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.
    acdlite committed Mar 7, 2021
    Configuration menu
    Copy the full SHA
    258b375 View commit details
    Browse the repository at this point in the history
  2. [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]>
    acdlite and gnoff committed Mar 7, 2021
    Configuration menu
    Copy the full SHA
    62a37a8 View commit details
    Browse the repository at this point in the history
  3. 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 <[email protected]>
    acdlite and gnoff committed Mar 7, 2021
    Configuration menu
    Copy the full SHA
    1a7f224 View commit details
    Browse the repository at this point in the history
  4. 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 <[email protected]>
    acdlite and gnoff committed Mar 7, 2021
    Configuration menu
    Copy the full SHA
    9146a17 View commit details
    Browse the repository at this point in the history
  5. 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.
    acdlite committed Mar 7, 2021
    Configuration menu
    Copy the full SHA
    63941d5 View commit details
    Browse the repository at this point in the history