-
Notifications
You must be signed in to change notification settings - Fork 204
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
Add new API for accessing store in UI components #2800
Conversation
ce96327
to
92ce6ee
Compare
Codecov Report
@@ Coverage Diff @@
## master #2800 +/- ##
==========================================
+ Coverage 97.74% 97.75% +0.01%
==========================================
Files 202 202
Lines 7678 7723 +45
Branches 1700 1707 +7
==========================================
+ Hits 7505 7550 +45
Misses 173 173
Continue to review full report at Codecov.
|
d821c7e
to
870027d
Compare
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 approach makes sense and is a good use of Proxy
(IMHO). It's clean and generally straightforward, but there is some inherent complexity, so extra commenting is helpful, I think. Look forward to full reviewing when tests are in place (I looked at the implementation of useStoreProxy
but ignored the boilerplate changes across the various components; ran eyes over the other couple of commits and they looked fine so far, too).
wrapped = (...args) => { | ||
const cacheEntry = cache.find(entry => entry.matches(prop, args)); | ||
if (cacheEntry) { | ||
return cacheEntry.result; |
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 threw me at first, and we discussed it verbally. This cache entry's result is reliable because the cache entries are removed/invalidated if any of the cached methods-with-args return different results in the useEffect
hook (store.subscribe
callback) below. I'm not sure if this would throw a future dev; some commenting might help.
src/sidebar/store/use-store.js
Outdated
// to things which no longer exist (for example, an object ID for an object | ||
// which has been unloaded). It is assumed that store selector methods are | ||
// robust to this. | ||
entry => store[entry.method](...entry.args) !== entry.result |
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 line takes a little energy to unpack, but I can't think of how to do it better; it is self-documenting if you take the time to read it slowly...
src/sidebar/store/use-store.js
Outdated
// the store but returns a result, or an action that modifies the state | ||
// but does not return a result. | ||
const result = original.call(target, ...args); | ||
if (result !== 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.
A more direct comment here might be helpful: we're not adding cache entries for actions. You address this in the comment above but for some reason it didn't translate clearly for me...
src/sidebar/store/use-store.js
Outdated
// relevant store state changes. | ||
useEffect(() => { | ||
const cleanup = store.subscribe(() => { | ||
const isCacheInvalid = cache.some( |
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.
Gimme a hand here (running low on time and don't want to unravel useStore
internals right now)...does this cache-clearing of all "registered" store selectors for the calling component ultimately result in selectors getting recomputed more often than the previous implementation? The same? Apples and oranges?
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 may be slightly more efficient in the case where a re-render needs to happen. Previously we would always re-evaluate every selector used by a component when anything in the store changed (ie. we would re-run all the useStore
hook callbacks). We will now evaluate the selector calls in the order they occurred and bail out early if we detect that any are invalid.
870027d
to
923f698
Compare
Implement a new `useStoreProxy` hook that provides access to read and update the store in UI components. This will replace the existing `useStore` hook. The new hook has a more ergonomic API which should also prevent some of the common mistakes made when using the previous hook. `useStoreProxy` returns an ES proxy for the store. The UI component can use the proxy as if it were using the store directly. The proxy however tracks and caches the results of store selector calls. When the store state changes the proxy checks whether any of the cached calls would return different results and if so invalidates the cache and re-renders the component.
923f698
to
7e38cb9
Compare
const wrapper = mount(<TestComponent />); | ||
assert.equal(wrapper.text(), '10'); | ||
}); | ||
// Tests for deprecated `useStore` function. |
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 diff in the describe('useStore', ...
block are just whitespace changes. See the diff without whitespace changes.
}, | ||
}); | ||
}); | ||
// Store module for use with `createStore` in tests. |
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.
Since the useStoreProxy
hook is intended to be used with a store created by our createStore
function (as opposed to the one from the redux
package) I figured it was better to use such a store in the tests.
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 not the first time that naming "collision" has thrown me a tad.
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 will improve developer ergonomics and clarity, as well as feeling like a steadier solution overall. Good.
There is some necessary complexity here. Most of my comments point out opportunities to increase commenting clarity to help future devs.
src/sidebar/store/use-store.js
Outdated
const [, forceUpdate] = useReducer(x => x + 1, 0); | ||
|
||
// Cache of store method calls made by the current UI component and associated | ||
// results. This is currently just an array on the assumption that it will |
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.
When I first started looking at the draft changes the other day, I wasn't entirely clear on the cache entries on first read: I was worried there might be on entry per call (oy), or perhaps one entry per (method, args, result) set, when in fact there is one entry per (method, args) combination. It may be possible to tweak this comment to reflect that more clearly; especially, because CacheEntry
has a results
property.
const cacheRef = useRef(/** @type {CacheEntry[]} */ ([])); | ||
const cache = cacheRef.current; | ||
|
||
// Create the wrapper around the store. |
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.
Nit: Here and in a couple of other one-liner comments in this module: I'd nix the full stop.
src/sidebar/store/use-store.js
Outdated
* @param {typeof store} target | ||
* @param {string} prop | ||
*/ | ||
const get = (target, prop) => { |
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 find myself torn between:
- All of the implementation here perfectly follows what one is supposed to do with a
Proxy
, including using consistent reference naming, vs. - A few arguably-superfluous comments would help a future dev comprehend this more quickly;
What do you think?
src/sidebar/store/use-store.js
Outdated
* extract data from the store and call actions on it. | ||
* | ||
* Unlike using the `store` service directly, the wrapper tracks what data from | ||
* the store the current component uses and re-renders the component when 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 "it" in this sentence is a little bit ambiguous. Is it accurate to say "re-renders the component when the results of any selectors used changed" or something perhaps a little less clunky?
* the store the current component uses and re-renders the component when it | ||
* changes. | ||
* | ||
* The returned wrapper has the same API as the store itself. |
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 a handy comment: thank you.
assert.calledOnce(testStore.subscribe); | ||
wrapper.unmount(); | ||
assert.calledOnce(unsubscribe); | ||
assert.ok(proxy); |
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.
Can you elaborate on what this is testing?
The subsequent tests—I think—wouldn't pass if this didn't pass, 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.
This is just a sanity check that useStoreProxy
returned something and that the test helper returned that value. It isn't that important.
assert.calledOnce(unsubscribe); | ||
assert.ok(proxy); | ||
|
||
// Test proxied selector method. |
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.
Good commenting, thanks.
}); | ||
}); | ||
|
||
function renderTestComponent() { |
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.
Is the return value from this function structured in imitation, or is it just for the needs of these tests?
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 is just used as a way to return multiple values, of which most tests only need some components.
assert.calledWith(addThingSpy, 'baz'); | ||
}); | ||
|
||
it('proxies non-function properties of the store', () => { |
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.
Are there any known current use cases for this, or are you merely illustrating that such a thing would be possible? Should this be commented? Should it (access to non-function properties) be allowed?
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.
We don't currently have a specific need to be able to access non-function properties, so this is just for completeness.
getThingSpy.resetHistory(); | ||
|
||
proxy.getThing('bar'); | ||
proxy.getThing('bar'); |
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 feel like a test like this sufficiently exercises the path of "the component re-renders and re-invokes its various proxied store selector methods and we can see that those selectors are not re-invoked because their cache entries are still valid"?
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 does cover that functionality, but only in a minimal way. I think that's adequate for now but we might want to extend it in future.
Make several improvements following PR review.
This PR is a prototype of a new API for accessing the store in UI components which aims to fix several problems of the
useStore
hook:useStore
created a bunch of effects and other hooks and added a new subscriber to the store. This adds overhead if a component callsuseStore
many times.useStore
is being used to get an action, this overhead is completely unnecessaryuseStore
which have been come up several times. For example, returning a selector function from auseStore
hook instead of calling the selector and returning the result.The new API takes a different approach:
Here
useStoreProxy
(temporary name, to avoid confusion withEdit: Feedback has been that perhaps this name should stick) returns a proxy for the store which caches the result of selector method calls and checks whether the results of any of those cached calls have changed whenever the store state changes. If so, the cache is invalidated and the component is re-rendered.useStore
Compared to
useStore
there are several benefits:In order for this API to work, the
useStoreProxy
makes an assumption about the store API: All the methods that components will use either return something from the store (selectors) but don't change it, or modify the store in some way (actions). These can be distinguished by checking whether the store state changed after calling the method.This PR implements the
useStoreProxy
hook and associated tests. Subsequent PRs will switch UI components over to use the new hook. Finally once all components are converted, the olduseStore
hook and tests will be removed.