-
Notifications
You must be signed in to change notification settings - Fork 46.8k
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] Context Selectors #20646
base: main
Are you sure you want to change the base?
Conversation
This pull request is automatically built and testable in CodeSandbox. To see build info of the built libraries, click here or the icon next to each commit SHA. Latest deployment of this branch, based on commit 26d596d:
|
aeace50
to
26d596d
Compare
Comparing: 903384a...eb46705 Critical size changesIncludes critical production bundles, as well as any change greater
Significant size changesIncludes any change greater than 0.2%: Expand to show
|
Obvious question: why |
lol, I was waiting somebody ask. In one of the RFCs, there was a discussion about the idea of |
Think of this PR as a proof of concept. It’s very unlikely to be the final design. We can bikeshed more before release. Re: why it’s a separate hook , makes it easier to track usages internally, and delete if needed. Also avoids a conflict with observed bits, which we still need to remove. This also isn’t the only context-related feature we have planned, and it’s unclear how they’ll overlap. Might be separate hooks, might be all the same hook. |
Gotcha. Out of curiosity, any chance of an RFC or something discussing the other plans for context? |
When they’re more fleshed out, yeah. One of the motivations for this PR was that the other proposals are only useful in combination with this feature (bailing out during render if nothing has changed). |
I think this is better, we can easily bail in/out of the selector functionality anytime. Could also imagine the API being like this: declare function useContext<T>(ctx: Context<T>): T;
declare function useContext<T, R>(ctx: Context<T>, selector: (value: T) => R, isEqual?: (prev: R, next: R) => boolean): R; |
const a = useSelectedContext(Context, context => context.b); | ||
return <Text text={a} />; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
const a = useSelectedContext(Context, context => context.b); | |
return <Text text={a} />; | |
const b = useSelectedContext(Context, context => context.b); | |
return <Text text={b} />; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@acdlite excited to see you pick this up :) lmk if you want to discuss anything related to reactjs/rfcs#118 and reactjs/rfcs#150 when you start looking into optimizations as a whole
function updateSelectedContext<C, S>( | ||
Context: ReactContext<C>, | ||
selector: C => S, | ||
isEqual: ((S, S) => boolean) | void, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is interesting. When I wrote my initial PoC PR I thought about this but excluded it given other hooks don't expose these sorts of overrides and you can easily "break" things with it. Is this something you think might make it into a final implementation? My general impression was hooks should not allow you to be wrong if it can help it
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The motivating use case was selecting multiple values. Like a tuple or record. That would instantly break the Object.is
memoization.
An alternative design would be to pass the previous context value to the selector, so that the selector can choose to return the old one if desired.
I think I like that design more but went with this one for now as a strawman.
// TODO: We could call the selector right here, during propagation. | ||
// That would give us the opportunity to bail out early, without | ||
// even visiting the fiber. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I was trying to avoid this by also implementing changes in reactjs/rfcs#118 because you end up with the ability to run expensive selectors multiple times if there is intermediate work that causes a bailed out fiber to end up rendering anyway.
I have no idea if "expensive selectors" is a real worry of course and so you're likely right that it wouldn't matter a whole lot perf wise.
There is one more edge case that this would trip up on though and that is if a deeper component with a selector is going to get props from an ancestor that will update on the same context change you can run the deeper selector with props that came from an earlier version of the context value.
For instance if you have a context with a key a
and an ancestor component sees a
and passes it to a deeper component which uses it to select from the context. then on a context value update a
is deleted and b
is the key. the ancestor will eventually pass b
to the deeper child but the selector call here during propagation would try to read a
and get an error.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I was trying to avoid this by also implementing changes in reactjs/rfcs#118 because you end up with the ability to run expensive selectors multiple times if there is intermediate work that causes a bailed out fiber to end up rendering anyway.
Yeah that's why I left it as a TODO 👍 Just wanted the comment there for future reference. Also why I called the dependency field "hook" since it's likely we'll need an actual reference to the current hook there, rather than a boolean.
26d596d
to
eb46705
Compare
1a3b6b2
to
a22f61c
Compare
a22f61c
to
bde8c75
Compare
887ee39
to
1a6b6cd
Compare
Pushed some updates. It's now based on top of the Lazy Propagation (#20890) experiment. I also modified the API so that it returns the full context object, instead of a selected value. Added rationale to PR description:
|
1a6b6cd
to
ef1fc5d
Compare
a0bb10a
to
e99f863
Compare
fffc35b
to
6a215af
Compare
This block is getting hard to read so I moved it to a separate function. I'm about to refactor the logic that wraps around this path. Ideally this early bailout path would happen before the begin phase phase. Perhaps during reconcilation of the parent fiber's children.
The only reason we pass `updateLanes` to some begin functions is to check if we can perform an early bail out. But this is also available as `current.lanes`, so we can read it from there instead. I think the only reason we didn't do it this way originally is because components that have two phases — error and Suspense boundaries — use `workInProgress.lanes` to prevent a bail out, since during the initial render there is no `current`. But we can check the `DidCapture` flag instead, which we use elsewhere to detect the second phase.
For internal experimentation only. This implements `unstable_useContextSelector` behind a feature flag. It's based on [RFC 119](reactjs/rfcs#119) and [RFC 118](reactjs/rfcs#118) by @gnoff. Usage: ```js const context = useContextSelector(Context, c => c.selectedField); ``` The key feature is that if the selected value does not change between renders, the component will bail out of rendering its children, a la `memo`, `PureComponent`, or the `useState` bailout mechanism. (Unless some other state, props, or context was updated in the same render.) One difference from the RFC is that it does not return the selected value. It returns the full context object. This serves a few purposes: it discourages you from creating any new objects or derived values inside the selector, because it'll get thrown out regardless. Instead, all the selector will do is return a subfield. Then you can compute the derived value inside the component, and if needed, you memoize that derived value with `useMemo`. If all the selectors do is access a subfield, they're (theoretically) fast enough that we can call them during the propagation scan and bail out really early, without having to visit the component during the render phase. Another benefit is that it's API compatible with `useContext`. So we can put it behind a flag that falls back to regular `useContext`. The longer term vision is that these optimizations (in addition to other memoization checks, like `useMemo` and `useCallback`) are inserted automatically by a compiler. So you would write code like this: ```js const {a, b} = useContext(Context); const derived = computeDerived(a, b); ``` and it would get converted to something like this: ```js const {a} = useContextSelector(Context, context => context.a); const {b} = useContextSelector(Context, context => context.b); const derived = useMemo(() => computeDerived(a, b), [a, b]); ``` (Though not this exactly. Some lower level compiler output target.)
This will to make it easier to A/B test, or to revert if we abandon the experiment. Using a selector will not change the return type of `useContext`. Use a userspace hook to get the selected value: ```js function useContextSelector<C, S>(Context: C, selector: C => S): S { const context = useContext(Context, {unstable_selector: selector}); const selectedContext = selector(context); return selectedContext; } ```
6a215af
to
c0b8b58
Compare
Based on #20890
This is not a final API. It's meant for internal experimentation only. If we land this feature in our stable release channel, it will likely differ from the version presented here.
This implements
unstable_useContextSelector
behind a feature flag. It's based on RFC 119 and RFC 118 by @gnoff.Usage:
The key feature is that if the selected value does not change between renders, the component will bail out of rendering its children, a la
memo
,PureComponent
, or theuseState
bailout mechanism. (Unless some other state, props, or context was updated in the same render.)One difference from the RFC is that it does not return the selected value. It returns the full context object. This serves a few purposes: it discourages you from creating any new objects or derived values inside the selector, because it'll get thrown out regardless. Instead, all the selector will do is return a subfield. Then you can compute the derived value inside the component, and if needed, you memoize that derived value with
useMemo
.If all the selectors do is access a subfield, they're (theoretically) fast enough that we can call them during the propagation scan and bail out really early, without having to visit the component during the render phase.
Another benefit is that it's API compatible with
useContext
. So we can put it behind a flag that falls back to regularuseContext
.The longer term vision is that these optimizations (in addition to other memoization checks, like
useMemo
anduseCallback
) are inserted automatically by a compiler. So you would write code like this:and it would get converted to something like this:
(Though not this exactly. Some lower level compiler output target.)