-
Notifications
You must be signed in to change notification settings - Fork 4.2k
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
@wordpress/data: Introduce useSelect custom hook. #15737
Conversation
packages/data/README.md
Outdated
|
||
_Returns_ | ||
|
||
- `Component`: Enhanced component with merged state data props. |
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 documentation will get replace once I add inline docs to the new withSelect
. I just want to gather initial feedback on approach before doing so.
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 guess this needs an update too.
useIsomorphicLayoutEffect( () => { | ||
latestIsAsync.current = isAsync; | ||
renderQueue.flush( queueContext ); | ||
}, [ isAsync ] ); |
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.
Assuming the approach is sound, these could be extracted to internal only named hooks to make it a bit easier to follow in the main exported hook.
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.
You can include this in the previous hook:
useIsomorphicLayoutEffect( () => {
latestMapSelect.current = mapSelect;
if ( latestIsAsync.current !== isAsync ) {
latestIsAsync.current = isAsync;
renderQueue.flush( queueContext );
}
isMounted.current = true;
} );
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.
hmm, ya I guess. I mostly liked the separation because the flush would only happen if isAsync
changes (hence the depedency (so removes the need for the conditional check). So tomahtoes/tomatoes :)
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.
Yeah, I was just thinking it could cause some issues with the order of effect clean-up/execution, but I think it's fine.
@@ -0,0 +1,208 @@ | |||
/** |
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 file is just kept in this pull initially as a reference to compare the logic in the useSelect hook against what withSelect
was doing. Once an approach is signed off on, then this can get removed.
*/ | ||
// const useIsomorphicLayoutEffect = typeof window !== 'undefined' ? | ||
// useLayoutEffect : useEffect; | ||
const useIsomorphicLayoutEffect = typeof window !== 'undefined' ? |
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.
Do we really care about this? I mean even if we do (server-side usage), this feels like something that should be done across the whole codebase. I wonder if we should keep it separate and just use useLayoutEffect
here.
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.
ya I wondered the same thing. I also tried just using useEffect
and the page doesn't load at all (which I guess is to be expected). Which makes me wonder if this would even work with server side rendering.
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.
useEffect
is a no-op in the server.
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.
in a7b799f I just switched to useLayoutEffect
only. Redux needs to do the isomorphic effect because as a library it's used in more environments but until GB supports server side rendering generally we shouldn't implement.
}, [ registry, nextProps ] ); | ||
|
||
useIsomorphicLayoutEffect( () => { | ||
const unsubscribe = registry.subscribe( () => { |
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 expect this will break the re-rendering order, because the children will subcribe before the parents. Right?
I think the solution might be to subscribe
synchronously at the render level (but only manage to do it once).
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.
hmm...
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.
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.
hmm so the subscription callback would likely have to be a ref maybe?
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 basically means for me that subscriptions should happen at the root level of the hook and not inside a useEffect
.
Something like
const registry = useRegistry();
const isAsync = useAsyncMode();
const previousRegistry = usePrevious( registry );
const previousIsAsync = usePrevious( isAsync );
if ( registry !== previousRegistry || isAsync !== previousIsAsync ) {
// Trigger subscription
}
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.
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.
Do you think you'd be able to provide an example (codepen or something) where the React Async rendering would break this. Asking because that would be a good way to try and test alternative implementations addressing this issue.
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.
That would take a while to set up. The entire React API relies on render being idempotent. Note that the current implementation of withSelect
also has this issue as it has side effects in the constructor.
From the React docs:
Conceptually, React does work in two phases:
- The render phase determines what changes need to be made to e.g. the DOM. During this phase, React calls render and then compares the result to the previous render.
- The commit phase is when React applies any changes. (In the case of React DOM, this is when React inserts, updates, and removes DOM nodes.) React also calls lifecycles like componentDidMount and componentDidUpdate during this phase.
The commit phase is usually very fast, but rendering can be slow. For this reason, the upcoming async mode (which is not enabled by default yet) breaks the rendering work into pieces, pausing and resuming the work to avoid blocking the browser. This means that React may invoke render phase lifecycles more than once before committing, or it may invoke them without committing at all (because of an error or a higher priority interruption).
Render phase lifecycles include the following class component methods:
- constructor
- componentWillMount
- componentWillReceiveProps
- componentWillUpdate
- getDerivedStateFromProps
- shouldComponentUpdate
- render
- setState updater functions (the first argument)
Because the above methods might be called more than once, it’s important that they do not contain side-effects. Ignoring this rule can lead to a variety of problems, including memory leaks and invalid application state. Unfortunately, it can be difficult to detect these problems as they can often be non-deterministic.
Strict mode can’t automatically detect side effects for you, but it can help you spot them by making them a little more deterministic. This is done by intentionally double-invoking the following methods:
- Class component constructor method
- The render method
- setState updater functions (the first argument)
- The static getDerivedStateFromProps lifecycle
Note:
This only applies to development mode. Lifecycles will not be double-invoked in production mode.
For example, consider the following code:
class TopLevelRoute extends React.Component {
constructor(props) {
super(props);
SharedApplicationState.recordEvent('ExampleComponent');
}
}
At first glance, this code might not seem problematic. But if SharedApplicationState.recordEvent
is not idempotent, then instantiating this component multiple times could lead to invalid application state. This sort of subtle bug might not manifest during development, or it might do so inconsistently and so be overlooked.
By intentionally double-invoking methods like the component constructor, strict mode makes patterns like this easier to spot.
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.
Thanks for sharing. I think we probably have a lot of components that suffer from these issues. Makes me think we don't really support async mode. Not to say that we shouldn't but if feels like something we should invest in at a more global level.
From what I understand, say we have a global counter, we could increment (side effect) from within hooks. If the render function increments this counter, we'll still be certain that children components will pick a higher-number from this counter than their parent as even if react could double invoke parents render function ... it will still call the render of the children after the parents or is this a wrong assumption (assuming initial rendering)?
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.
Here is the execution order:
render parent
render childA
render childB
useLayoutEffect cleanup childA
useLayoutEffect cleanup childB
useLayoutEffect cleanup parent
useLayoutEffect effect childA
useLayoutEffect effect childB
useLayoutEffect effect parent
useEffect cleanup childA
useEffect effect childA
useEffect cleanup childB
useEffect effect childB
useEffect cleanup parent
useEffect effect parent
You can't run side effects in a parent before running them in its children, because side effects only happen after the first render. This is true without hooks as well. The only alternative I see would be to stagger-render (pause render at each level of the tree to run side effects), but performance would suffer, and you would still have issues when children are rendered conditionally/dynamically.
*/ | ||
useIsomorphicLayoutEffect( () => { | ||
onStoreChange(); | ||
}, [ registry, nextProps ] ); |
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.
onStoreChange
can be the dependency here.
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.
It seems that this means this hooks makes the "nextProps" (the dependencies of the callback) mandatory. I'd think that we should also support a naive version where the mapping is attempted on each render if nextProps
is undefined.
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.
It seems that this means this hooks makes the "nextProps" (the dependencies of the callback) mandatory.
undefined === undefined
, so it will only run when the registry changes.
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 almost think it should be mandatory. This allows useSelect
to effectively "know" the dependencies. I'm thinking then it's possible for implementations of just the hook to describe what dependencies the provided mapSelect
callback has.
Thus the new withSelect
hoc is provided all the props coming in from the parent as dependencies (simply because there's no way to know what mappers will be provided by the components composed with withSelect
).
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 almost think it should be mandatory.
I agree with that but conceptually speaking, it behaves differently than the useEffect
dependencies and could mislead people.
useEffect( something )
updates on each render, I expect useSelect( something )
to update on each render as well.
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.
nextProps
is problematic, because it's an object so you put the burden on the user to memoize it.
onStoreChange
doesn't need to be memoized. It can just read refs.
nextProps
should be an array that gets passed as the dependency array to the useIsomorphicLayoutEffect
of this chat thread.
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.
You also don't want to run this during the first render, so this should come before the effect that sets isMounted
, and it should check for it to be true.
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.
There was another comment attached to an outdated code snippet about assuming nextProps
is an array and spreading it here (thus being more consistent with dependency shape with useEffect
). I think that's probably what we should do here and have withSelect
do the necessary implementation to follow that signature expectation for useSelect
.
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.
Yeah that's better, but it still means the component re-renders twice. First when nextProps change and then after the selector is run.
Take a look at this approach: https://github.com/reduxjs/react-redux/blob/v7-hooks-alpha/src/hooks/useSelector.js
Here:
https://github.com/reduxjs/react-redux/blob/0e41eaeebf5d8d123daaa50a91ee1c219c4830de/src/hooks/useSelector.js#L61-L64
You would also check if nextProps
have changed.
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 actually removed the need for nextProps
.
I updated my gist to implement all the behavior you want. Look at how we can implement withSelect
now.
https://gist.github.com/epiqueras/7eae39ba6b903286cf17a4907902a630
unsubscribe(); | ||
renderQueue.flush( queueContext ); | ||
}; | ||
}, [ registry ] ); |
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.
onStoreChange
is also a dependency here.
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.
Hmm, there is risk of that causing more subscription re-renders though right?
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 don't think onStoreChange
should be memoized. It can be a ref if you also need to use it in the other effect.
See #15737 (comment)
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.
see changes in 69f3b80
@@ -0,0 +1,25 @@ | |||
/** |
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.
Nice!
setMapOutput( newMapOutput ); | ||
} | ||
} | ||
}, [ registry, nextProps ] ); |
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 feel this should be ...nextProps
and not nextProps
I gave this some more thought now that we want the callback to run again when It makes more sense for the callback to be called again when it changes. This was needed regardless, because the callback might have new closures every time it changes and the component wouldn't reflect that until the next store update. Now, users can just memoize their callback if they don't want it to run on every render, using a traditional hook dependency array. This is what I did in my implementation of See the updated gist: https://gist.github.com/epiqueras/7eae39ba6b903286cf17a4907902a630. I also added the queue context and flushing that @nerrad added in this PR. You can copy and paste this into this PR if you don't see any issues with 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.
Thanks again @epiqueras for the code example. It's looking more and more like the redux useSelect
implementation :)
This appears to work in rough testing and I mostly implemented the code from your gist with an exception wrt to the memoized mapSelect callback in the withSelect
hoc (see my comments).
* Fallback to useEffect for server rendered components because currently React | ||
* throws a warning when using useLayoutEffect in that environment. | ||
*/ | ||
const useIsomorphicLayoutEffect = |
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 re-implemented this. I realize that GB as a whole isn't really supporting server rendering but with this being a separate package that could be used in a server rendering environment, this might be a valid thing to do (and follows a pattern that Redux is using for their hooks)
ownProps, | ||
registry | ||
), | ||
[ ownProps ] |
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.
Note, I could not do Object.values( ownProps )
here because of this error:
Warning: The final argument passed to useCallback changed size between renders. The order and size of this array must remain constant.
I experimented with generating a consistent array of values from the prop keys in the same order over multiple renders (and filling missing values with undefined) but this caused the same error (because it looks like the number of props can increase between re-renders as well). This almost smells like a problem with implementation in some component in the editor tree but I'm uncertain what to do here. On the surface just passing in the prop object seems to be sufficient for this case.
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.
ownProps
changes on every render so this is like not memoizing it and running the callback every time.
I think we can just wrap the inner component in a React.memo
.
Updated: https://gist.github.com/epiqueras/7eae39ba6b903286cf17a4907902a630#file-with-select-js
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.
ownProps changes on every render so this is like not memoizing it and running the callback every time.
I couldn't implement React.memo
because it resulted in invalid blocks on existing content. I'm not sure of the internal workings of it but it's a no go based on results. So I implemented pure
which should work similarly.
I'm not seeing much difference in behaviour between the two when I do a rough comparison using the react dev profiler tool:
However pure does seem marginally better so probably good to use 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.
memo
doesn't implement shouldComponentUpdate
like pure
does, it just adds a special flag for the renderer to handle.
Both have the same functionality, but internally React has different rules for when to ignore a shouldComponentUpdate
or a memo
.
It sounds like these blocks are relying on some indeterministic and undocumented behavior.
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 blocks throwing the invalidation issue were basic blocks included with GB (paragraph block for instance).
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.
Could there be a state/props mutation somewhere in there?
@youknowriad there's still some failing e2e tests in here, it's unclear to me what the issue is with the tests (because of my lack of familiarity with them) but I picked a few of the tests that I can replicate in testing myself (such as re-enabling nux tips) and I'm not reproducing the e2e fails. |
For reference, here are the failing e2e test:
|
useIsomorphicLayoutEffect( () => { | ||
const onStoreChange = () => { | ||
if ( isMounted.current ) { | ||
const newMapOutput = latestMapSelect.current( registry.select, registry ); |
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.
So So if I'm reading this properly, the mapping function is never called when the mapping function changes or when the props used in the mapping function changes. This feels like a bug to me, what if a prop changes and the store wasn't updated in the meantime.
I guess what I'm saying here is I'm still not sure why we dropped the dependencies array from the arguments.
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.
So if I'm reading this properly, the mapping function is never called when the mapping function changes
I think it is:
if ( latestMapSelect.current !== mapSelect ) {
latestMapSelect
gets set to the current mapping function on ever render and the next render compares that value with the previous. If they differ, then the function is invoked again to get the mapOutput.
I guess what I'm saying here is I'm still not sure why we dropped the dependencies array from the arguments.
The dependency is now on the mapSelect
callback. This gives more control to developers to optimize on their mapSelect function.
I think there's two approaches we could go here:
useSelect
requires a dependency array and uses that to optimize rendering.
Disadvantages:
withSelect
will be harder to optimize for becauseownProps
fluctuates too much so we can't just doObject.values( ownProps )
- Easier for developers to shoot themselves in the foot because their dependency array doesn't correctly all the dependencies in their
mapSelect
callback (granted that might be somewhat resolveable via an ES lit rule).
Advantages:
- enforces optimal approaches for performance.
- makes the dependencies more clear and obvious.
Developers do their own memoization of the mapSelect
callback.
Disadvantages:
- performance responsibility is shifted to developers, so they can still shoot themselves in the foot wrt performance (granted, something being non performant via not working are too different things :) )
Advantages:
- More flexibility for developers with regards to how they optimize things.
withSelect
is a bit more easier to optimize (although there's still some difficulties as noted in the comments for my latest commit).
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 have a strong preference for having a dependencies
argument in the signature, mimic useEffect
, and absorb the optimizations in the framework personally.
Will we need more fine-tuning? Maybe, but it seems like this could be a useSelectAdvanced
similar to how there's a connectAdvanced
in react-redux
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 think the connector's job is to update the component when it has to update. Memoizing a selector for performance is a separate concern.
Keep in mind that a lot of the time, specially for computationally expensive selectors, they will already be defined elsewhere without closures over props, removing the need for inline hook callback memoization. This codebase is a good example of that.
I also like the fact that the public API remains simple and developers have one less thing to learn. They already have to learn how to use primitive memoization hooks, so we can build on that.
Let's look at a few examples:
// Callback never changes.
// Performance is good.
useSelect( importedSelector );
// Callback changes and re-runs on every render.
// `.getItem` is memoized.
// Performance is good.
useSelect( ( select ) => select( 'store' ).getItem() );
// Callback changes and re-runs on every render.
// `.getItem` is memoized with `prop`.
// Performance is good.
useSelect( ( select ) => select( 'store' ).getItem( prop ) );
// Callback changes and re-runs on every render.
// `.getItem` is not memoized.
// Performance suffers.
// We memoize ourselves.
useSelect( useCallback( ( select ) => select( 'store' ).getItem( prop ), [ prop ] ) );
// Callback changes and re-runs on every render.
// `.getItem` is not memoized.
// Performance suffers.
// We delegate memoization to the implementation.
useSelect( ( select ) => select( 'store' ).getItem( prop ), [ prop ] );
See where the two approaches differ in the last two calls. My main issue with having the dependencies
array is that the user has to learn a new interpretation of it. Does it memoize the callback everywhere? Only for renders? Does it always use the latest one in store updates? Does it update the memoized value after that?
It very quickly makes it much harder to think about and learn. Developers already know how useCallback
works and it's much easier to say: This hook calls your callback every time it changes and when the store updates, and then returns its output. If your callback is expensive, you can memoize it, because it's only called when it changes or when the store updates.
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 think the connector's job is to update the component when it has to update. Memoizing a selector for performance is a separate concern.
The same thing could have been said about useEffect
hook. Why the React team decided to implement the dependencies there and not rely on useCallback
to ensure it runs the effect only when the callback changes?
I understand that a "pure" implementation is better for an advanced developper that understands how to override the default behavior and build on top of it but let's take a look at the common use-case from a user's perspective:
const { block } = useSelect( ( select ) => {
return {
block: select( 'core/editor' ).getBlock()
// call other selectors
};
} );
In our previous attempts to improve the performance of the editor, the fact that the selectors were memoized didn't prove to be enough to address the editor performance issues once we load a lot of blocks. The problem was that a lot of selectors were being run (memoized or not) on each subscribe. So for me, the more we avoid running selectors entirely, the better is.
I understand that I'm basing this on previous metrics, that might not transpose 1-1 to the current use-cases. Ideally, we'd perform performance tests and compare.
My main issue with having the dependencies array is that the user has to learn a new interpretation of it
That's my main issue with the dependencies-less implementation as well. Users need to learn how to memoize things by them selves (using other hooks) while the dependencies array is a common pattern already used in useEffect
.
Ultimately, I think we should take a path, mark it as experimental and evaluate it with real usage.
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.
In thinking about this more. It seems that the behavior of both approaches is the same if we omit passing any second argument and if we don't check for the callback refrence (always run the callback on render and rely on memoization), that suggests that we could potentially adopt a first version without any attempt to memoize (based on callback or based on dependencies) as a common ground and adapt using concrete measures.
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 functionality of useEffect
is completely different. It's not just about closures. There you might want to run the effect more or less times for other reasons. That's why they didn't rely on useCallback
.
This is really just about:
useSelect( mapSelect, [ dep ] )
vs.
useSelect( useCallback( mapSelect, [ dep ] ) )
The first one does have less code and I guess it's fine if we document it well enough. Something like:
This hook calls your callback every time it changes and when the store updates, and then returns its output. If your callback is expensive, you can pass a dependency array as the second parameter to memoize it, because we will only call it when it changes or when the store updates.
I updated the gist: https://gist.github.com/epiqueras/7eae39ba6b903286cf17a4907902a630.
Looks like the e2e tests all pass on the latest push to this branch 🎉 so as far as I can tell here's some remaining points to iron out:
Anything else? |
The current implementation of
|
See discussions and changes here: #15737 (comment).
It does suffer from it. It subscribes in constructors. These are called top-down parent to children, but not when children are rendered dynamically. I.e. lists, conditionals, switches, code-splitting, etc. The only way to solve this is for subscriptions to have a sense of location in the tree. This can be done by wrapping every subscribed component in a new context provider that overrides the subscription for its entire sub-tree. This is what I do think the @nerrad I've updated the gist to handle this: https://gist.github.com/epiqueras/7eae39ba6b903286cf17a4907902a630 |
That's a good point 👍 I guess the fact that we didn't notice might indicate that it's harder to trigger but you're right. |
@epiqueras Do you think the "try/catch" approach could work for us even if our selectors are not entirely pure? this was suggested as something that would break the approach in the document you shared. If that's the case, I'd be happy to move forward with that proposal. |
A selector's corresponding resolvers will either get triggered in the first or a second call to I think they were more worried about a side effect that depended on props running with stale props and then somehow affecting the next render. This is not the case for how resolvers work in |
Thanks again @epiqueras for the explanation and the code examples. I'll try and get another commit up sometime later today. |
In 585795c I've implemented the try/catch approach as outlined by @epiqueras which is also similar to what Redux is doing in their useSelect hook. Note, I still am unable to use
Which is found in the block api: gutenberg/packages/blocks/src/api/registration.js Lines 115 to 119 in 5c34f2f
This error happens for both core blocks (in this case For now I'm using |
Looks like all e2e tests pass on the latest build here as well so if this approach looks like what we want to go with in the initial iteration I'll get to work on updating unit tests and adding unit test coverage for this along with doing up the necessary CHANGELOG.md updates etc. |
585795c
to
c8c2ca8
Compare
woops looks like I missed pushing a commit (see c8c2ca8). @epiqueras this is a bit different than your gist because your gist had some bugs. |
I still don't think any of the last pushes are sufficient as I think there's still scenarios where the incoming select hasn't changed but the dependencies has and the previous mapOutput will still be returned. So it's unclear to me yet how the |
Alright with the latest commits this is ready for final review. Please note the following changes though: A
|
// catch any possible state changes during mount before the subscription | |
// could be set. | |
if ( latestIsAsync.current ) { | |
renderQueue.add( queueContext, onStoreChange ); | |
} else { | |
onStoreChange(); | |
} |
The changes effectively mean that the mapSelect
callback is run twice on initial mount. However, the extra call does not happen on re-renders unless the registry
changes or the component was unmounted and remounted.
I think we kinda need to keep these changes, otherwise any dispatches done in the componentDidMount
lifecycle method will not get picked up. However, it should be noted that this was primarily added as a potential back-compat issue with including this in withSelect
.
The performance overhead from the additional calls can be mitigated by memoized mapSelect
callbacks either from the passed in dependencies on useSelect
or external for those using withSelect
. Regardless, as noted via the performance measurements section in this comment, the impact is neglible and not any worse than current behaviour.
The test for parent subscriptions running before children is no longer true.
Basically, with the useSelect
implementation, child subscriptions do fire before the parent. I think this was to be expected based on previous conversations in this pull. It is accounted for with the early run of the mapSelect
callback on re-renders.
It should be pointed out that although there is an increase in the number of times mapSelect
callbacks are invoked (as demonstrated by the tests), the expected number of renders is not affected (matching expectations from the the tests before changes).
Performance
I used the performance tests available on #14506 for measuring impact and as @youknowriad already pointed out, there's not much difference:
Master
Average time to load: 21075ms
Average time to DOM content load: 20194ms
Average time to type character: 152.36ms
Slowest time to type character: 284ms
Fastest time to type character: 93ms
This branch
Average time to load: 20970ms
Average time to DOM content load: 20017ms
Average time to type character: 140.53ms
Slowest time to type character: 315ms
Fastest time to type character: 84ms
So as you can see there's a slight improvement but its negligible. I expect that once we implement useSelect
directly in components (as opposed to wrapping with withSelect
) there could be some more impact on performance (especially in cases where dependencies are correctly used).
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.
Awesome work on this PR @nerrad I think we can ship it and iterate on it as we expand usage. useDispatch
? :)
Yup I plan on working on that over the course of this week 👍 |
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.
Nice work on this!
Do you know if this would close #13879 ?
packages/data/src/index.js
Outdated
@@ -9,11 +9,14 @@ import combineReducers from 'turbo-combine-reducers'; | |||
import defaultRegistry from './default-registry'; | |||
import * as plugins from './plugins'; | |||
|
|||
export { default as withSelect } from './components/with-select'; | |||
export { withSelect } from './components/with-select'; |
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.
Was there a reason this was changed from a default to a named export?
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 think you're commenting on an early iteration of this file (I was temporarily leaving the old withSelect
in place for a reference point).
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 think you're commenting on an early iteration of this file (I was temporarily leaving the old
withSelect
in place for a reference point).
I must have had that stored from an earlier, unsubmitted review 🤔 I didn't write it today.
import useAsyncMode from '../async-mode-provider/use-async-mode'; | ||
|
||
/** | ||
* Favor useLayoutEffect to ensure the store subscription callback always has |
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 really admire the emphasis in documentation in the changes of this pull request. 👍
* In general, this custom React hook follows the | ||
* [rules of hooks](https://reactjs.org/docs/hooks-rules.html). | ||
* | ||
* @param {Function} _mapSelect Function called on every state change. The |
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.
Minor: Considering that this is enshrined in the public-facing documentation, I think we could have optimized for this to be the mapSelect
, either choosing _mapSelect
or (preferably, if one exists) a better name for the internal reference.
I guess it depends on your preference for or against "modifying" the argument, but since the arguments aren't const
, you could always re-define:
export default function useSelect( mapSelect, deps ) {
mapSelect = useCallback( mapSelect, deps );
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.
hmm true.
const registry = useRegistry(); | ||
const isAsync = useAsyncMode(); | ||
const queueContext = useMemo( () => ( { queue: true } ), [ registry ] ); | ||
const [ , forceRender ] = useReducer( ( s ) => s + 1, 0 ); |
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.
Who will be the first to breach 9007199254740991 (Number.MAX_SAFE_INTEGEER
) useSelect
renders in a page session? 😆
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.
lol if that happens, there will be other problems likely ;)
Ya it's possible it will because the subscription no longer gets set on construct (and is only set on effect - and unsubscribes on either a registry change or unmount). Worth testing to confirm but very likely will fix. |
} ); | ||
|
||
return () => { | ||
isMounted.current = false; |
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 don't know that it ends up being an issue, but this callback won't only be called for unmounting, it will also be called on a change in registry (and, if #19205 lands, in change in isAsync
). I guess it would depend on the order of how onStoreChange
or a subsequent call to the previous useIsomorphicLayoutEffect
which sets isMounted.current
back to true
. My worry could be that onStoreChange
wouldn't run the selector if it considers the component unmounted. I guess for the purpose of the selector running as unique to the registry (or isAsync
in #19205), we don't want the invalidated change callback, and we can feel confident that either renderQueue.add
or onStoreChange
would be called for the next registry? In which case, it's more an issue of a confusing name.
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 guess for the purpose of the selector running as unique to the registry (or isAsync in #19205), we don't want the invalidated change callback, and we can feel confident that either renderQueue.add or onStoreChange would be called for the next registry?
Exactly
In which case, it's more an issue of a confusing name.
I am not sure what else to call it. isCleaningUp
, isUnsubscribing
?
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.
In which case, it's more an issue of a confusing name.
I am not sure what else to call it.
isCleaningUp
,isUnsubscribing
?
Yeah, either of those would work pretty well I think (flipping the boolean value).
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.
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.
That boolean covers both scenarios^^
Hey folks, is there a recipe to use |
@zanona Hooks cannot be used in class components. This is a limitation imposed by React, and not something we have control over. In my experience, most any component can be ported to or expressed exclusively as a function component with hooks, so it's worth considering to migrate or write new components this way. Otherwise, |
A workaround for using react hooks with class components if they are super complex and hard to convert immediately to a function component is to implement the hook in a higher order component that wraps your class component. However, in this specific case, that's already handled for you because |
Note: For latest wrap up comments on this pull see also this comment
Description
See #15473 for background and #15512 for initial experimental approach to the new useSelect. The initial iteration of this approach used some code provided by @epiqueras (see comment, but did require modification. In this approach to useSelect:
The signature is
useSelect( mapSelect: function, deps: array )
mapSelect
receives theregistry.select
function as the first argument, andregistry
as the second. This, follows roughly the signature of themapSelectToProps
signature withSelect currently exposes (so this preserves that api). ThemapSelect
does not receive the "ownProps" because in most cases of just hook usage, the callback provided will internally be working with props. I'm on the fence with this because it does put more burden on implementors to account for possible stale prop usage in their callbacks but this is mitigated for most cases by the factuseSelect
always invokes the latest mapSelect provided on render (via usage ofuseRef
).deps
should an array of values used for memoizing the provided mapSelect (similar in behaviour to react hooks with dependencies). Internally, the newwithSelect
wraps the internal component usinguseSelect
withpure
.Other
useRegistry
hook is exported and use exported context objects ( internally only - no need to export the entire context with the exposure of the hook).withSelect
export which eliminates a lot of code and allows us to measure behaviour across all of GB in one swoop.withSelect
file for reference temporarily in this pull, but it will get removed if not used.How has this been tested?
Currently I've just tested playing around with blocks in the editor and generally looking for breakage. Things to watch for will be:
Types of changes
These are mostly internal changes so it's not expected to be a breaking change. The current signature for
mapSelectToProps
is preserved on implementations ofwithSelect
so no effects there.However, this does impact a lot of code (because of the
withSelect
implementation of the new hook). So the potential for regressions is significant here.Next Steps:
Checklist: