-
Notifications
You must be signed in to change notification settings - Fork 4.3k
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
Data: Add persistence via data plugin interface #8341
Conversation
packages/data/src/registry.js
Outdated
@@ -377,6 +326,21 @@ export function createRegistry( storeConfigs = {} ) { | |||
subscribe, | |||
select, | |||
dispatch, | |||
setupPersistence, | |||
namespaces, | |||
use, |
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 basically, the API changes are:
- making the
namespaces
array a public API.
I think it's fine.
The issue we'll have is how to update the default registry since it's not mutable. The use
generates a new registry? Should we have a "special" use
function for the default 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.
It doesn't so much create a new registry as much as its own internal representation. withPlugins
effectively makes what is exposed behave as a proxy to the current internal representation. It was partly done this way so that plugins act as layers, where the registry
it receives when called is either the original implementation, or whatever was last overridden by a plugin.
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.
making the namespaces array a public API.
Yes, I wasn't so sure about this. I still think it could be possible to omit, or limit (to e.g. an array of the registered reducerKey
).
Since registerStore
returns the actual store instance, at the worst case the plugin overridden value could inject the triggerPersist
as part of the store's own dispatch
/ getState
.
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 missed withPlugins
, clever :)
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.
Sometimes I wonder if we should "deprecate" the use of the global registry entirely but that's a different subject.
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 it's a nice convenience to have. As made evidenced here, it does make some things more difficult, like the idea of a "global plugin", which must be injected before anything else creates a store of its own (the wp_add_inline_script
on "NUX" is not super obvious or easy to maintain).
packages/data/package.json
Outdated
@@ -25,7 +25,6 @@ | |||
"@wordpress/compose": "file:../compose", | |||
"@wordpress/deprecated": "file:../deprecated", | |||
"@wordpress/element": "file:../element", | |||
"@wordpress/is-shallow-equal": "file:../is-shallow-equal", |
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 it's still used in the module (withSelect
)
actions, | ||
selectors, | ||
persist: true, | ||
persist: [ 'preferences' ], |
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 like it 👍
@@ -0,0 +1,29 @@ | |||
{ |
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 probably reach the point where we should create a script which scaffolds new package 😛
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's quite prone to copypasta as-is 😄 🍝
lib/client-assets.php
Outdated
filemtime( gutenberg_dir_path() . 'build/nux/index.js' ), | ||
true | ||
); | ||
wp_add_inline_script( | ||
'wp-nux', | ||
'wp.data.use( wp.dataPluginPersistence.createPersistencePlugin( "WP_EDIT_POST_DATA_" + window.userSettings.uid ) );', |
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.
Should we do the same in the editor
module? or should we leave this for edit-post
only?
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.
After discussion, I guess we need to do this before the store registration so probably as an inline script for wp-data
and we should rename the key in that case 'WP_DATA_' + userId
f380a13
to
ba58f0a
Compare
Pushed a number of updates:
The one limitation I see in the current implementation is that since the default set of plugins are "global", while registries can choose to opt in or not, and the plugins could operate on a per-registry basis, there's no way to distinguish by registry for the purposes of persistent storage. Therefore, there's the potential for conflicts if two registries both enable persistence and have reducers with the same |
Call the `use` method on the default or your own registry to include the persistence plugin: | ||
|
||
```js | ||
wp.data.use( 'persistence' ); |
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 I ask why you preferred the "string" syntax over the "function" one (similar to redux).
I prefer personally wp.data.use( createPersistencePlugin( { storage: window.localStorage, key: 'myKey' } ) )
for these reasons:
- No global state which means no conflict between registries
- Ability to create custom plugins easily without a need for a
addPlugin
/registerPlugin
API.
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 doesn't strictly need to be a string. It's this way largely as a convenience. It could be a pointer to the function as well:
wp.data.use( wp.data.plugins.persistence );
Part of the thinking was consistency between plugin initialization. Considering others that we may explore to refactor as plugins (resolvers, controls), they don't require such "creation".
So it would be odd to have both wp.data.plugins.persistence.createPlugin
and then just wp.data.plugins.resolvers
(or a wp.data.plugins.resolvers.createPlugin
that accepted no arguments).
And there already is one layer of creation with the plugin called as a function with the registry as an argument. It's possible we could have options as an optional argument to the use
, so we have something like:
wp.data
.use( wp.data.plugins.persistence, { storageKey: '...' } )
.use( wp.data.plugins.resolvers );
No global state which means no conflict between registries
It's still not very clear to me the best way we want these "default" plugins to be brought in. If we want this to occur within data itself, it seems reasonable that there's some global context; how else would we customize the storage behavior without the storage key (with user ID) being known to the data module (bad for generalism)?
I'd also thought it might be suitably explicit if the modules which register stores would always make sure to call use
before calling to register their own store.
use( persistence );
registerStore( 'core/edit-post', { /* ... */ } );
This would require some level of deduplication of plugins, though it's easy enough to achieve.
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.
createPlugin
is the responsibility of the plugin author, I'm mostly concerned about reducing the API surface as much as we can. If we have just registry.use( pluginFunction )
I'm fine with that.
I can't see the downside of this, it just allows everything:
- configuration using
createPlugin
like function returning the plugin function - non-configurable plugins
It's still not very clear to me the best way we want these "default" plugins to be brought in. If we want this to occur within data itself, it seems reasonable that there's some global context;
I think we should limit the global context as much as we can.
If we do:
wp.data.use( wp.data.persistence.createPlugin( config ) )
in an inline script, we have both the defaut plugins built-in and also no globals and no leaking to other registries. If you want to customize the storage engine, you do it in that call above. Maybe I'm missing something here?
I'd also thought it might be suitably explicit if the modules which register stores would always make sure to call use before calling to register their own store.
For me, that's probably the most compelling reason for the globally available plugins use( 'persistence' )
but even though, I'm not certain we can make it work without having plugins declared by a store messing up with plugins declared by other stores.
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 main remaining piece is consistency between including multiple plugins. I would not like, for example:
wp.data
.use( wp.data.plugins.persistence.createPlugin( config ) );
.use( wp.data.plugins.resolvers );
...because this breaks expectations on how a developer works with a specific plugin (need to understand whether it has a createPlugin
or not).
That's where I'm thinking of either:
wp.data
.use( wp.data.plugins.persistence, { storageKey: '...' } )
.use( wp.data.plugins.resolvers );
Where the object effectively causes the plugin to be "created" in the same way you've mentioned. This would look like:
function plugin( registry, options ) {
// ...initialize storage from options, available to closure
}
Alternatively, we could assume that use
should be called with a created plugin, but have the developer pass the registry, in place of current implementation of use
calling with registry
as an argument automatically.
wp.data
.use( wp.data.plugins.persistence.create( registry, { storageKey: '...' } )
.use( wp.data.plugins.resolvers.create( registry ) );
// Or: With default registry?
// .use( wp.data.plugins.resolvers.create() );
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.
wp.data
.use( wp.data.plugins.persistence, { storageKey: '...' } )
.use( wp.data.plugins.resolvers );
This one has my vote then.
I'm thinking most of these make sense anyway, some small differences but in the end it's the same thing. So let's move forward with what you think is best.
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.
Shorter is better, second that 😃
packages/data/src/registry.js
Outdated
options.persist = [ options.reducer.__keyToPersist ]; | ||
} | ||
|
||
if ( options.middlewares ) { |
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'm personally not certain we'd like to expose middlewares
/enhancers
because these are very highly tied to Redux and I know there ware discussions about changing enhancers API in future versions of Redux.
Any particular reason for including this in the current PR?
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 know there ware discussions about changing enhancers API in future versions of Redux.
Do you have a link for more information?
Enhancers aren't too much more than what we already enable by returning the store from registerStore
. We're using this ourselves to apply middleware manually, so it's obviously a requirement already.
The main goal here was avoiding the need to expose namespaces
, which is an even deeper implementation detail that I'd worried to expose. Authoring as a middleware was a means to reimplementing persistence to avoid depending on it, as an alternative to checking whether state has changed after a dispatch. That said, it could also be done with subscribe
now that I think of it.
I don't feel too strongly either way, but I also don't think middlewares / enhancers are too "internal" or "Redux"-y to warrant hiding; that said, I'm curious about the discussion you refer to. 😄
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.
Found the discussion I was talking about reduxjs/redux#1702
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.
Skimming the discussion there, I can see how most everything could be implemented on a base of getState
and dispatch
. I even think in the pull request there that it could be taken further:
onChange
is an unnecessary part ofstoreBase
which can be applied as composed in overridingdispatch
.initialState
could be avoided, assigned in the reducer as the default value for the state argument.- This loses parallels to Array#reduce, but I assume an intention with
initialValue
there is in allowing intentional accumulation ofundefined
, which is forbidden in Redux.
- This loses parallels to Array#reduce, but I assume an intention with
There's an open question of convenience and that these patterns are already well-established in the Redux ecosystem.
For the purposes of this pull request, I can see whether it's easy enough to avoid the enhancers by just overriding dispatch on the registered store instance.
More updates:
To do:
|
I think this is ready for a final review. |
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 looks great. I like how it evolves and make API more general. I had only one comment about assumption we make about persistence storage which might be too strict.
@youknowriad, how do you feel about refactoring apiFetch
package to follow the pattern established in this PR in regards to use
interface?
I think an example in the description is outdated and might be misleading for some people trying to build their own plugins.
implode( "\n", array( | ||
// TODO: Transferring old storage should be removed at v3.7. | ||
'( function() {', | ||
' var userId = window.userSettings.uid;', |
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 simplifies a lot in terms of passing user id to the client 👍
@@ -0,0 +1,23 @@ | |||
let _objectStorage; |
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 have usage of _
as a prefix documented? I did a quick search and found only one place where we use let _variable
. With ES6 modules it doesn't seem to be so important to use this patter to indicate that variable is private.
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 agree. I'll remove the _
prefix.
data = {}; | ||
} else { | ||
try { | ||
data = JSON.parse( persisted ); |
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.
Should we make JSON handling optional? It might be very limiting for some type of storages like IndexedDB or WebSQL. What about async?
Definitely not a blocker. However, this immediately raised such questions.
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's an interesting point. Asynchronous storage is probably desirable anyways, or at least it could serve as a useful proof-of-concept for how a plugin might initially asynchronously. I'll explore it a bit.
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 a problem here is that we need this data available at the time of initializing the store, meaning that if it must be fetched asynchronously, we'd have to introduce some API for blocking the registration of a store until said async behaviors resolve. I don't think this is something we'd want to allow. Instead, I might imagine the better alternative would be for the consumer to do the asynchronous fetching themselves as a step before they call to register the store, then surface it to the persistence plugin by the storage
option. This doesn't need to be localStorage
specifically, but it does need to be a synchronous storage mechanism (at least for retrieval) that could interface on top of the previously-asynchronously-fetched indexedDB
data, for example.
setItem
for this plugin could be considered a "fire and forget" operation that could be implemented asynchronously if desired.
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.
Oh, I overlooked a main point here in the mention of JSON as the value storage mechanism. I think as long as we want this to behave as an implementation of the Web Storage API interface, we should similarly respect its mechanics, i.e. setItem
takes a value
which is a string (more specifically, DOMString
):
https://developer.mozilla.org/en-US/docs/Web/API/Storage/setItem
https://html.spec.whatwg.org/multipage/webstorage.html#the-storage-interface
|
||
registry.registerStore( 'test', { | ||
reducer, | ||
persist: 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.
So true
is still possible. Good.
We definitely should do a major release of the package when it lands. Can we also create I found this, it should work per package. I will set it up soon: https://michaljanaszek.com/blog/lerna-conventional-commits. Update: Opened PR #8415 so we could discuss it there. |
Should we? Here's the specific wording from the SemVer Specification (emphasis mine):
https://semver.org/#spec-item-8 This was never part of a public API. |
Depending on the interpretation, I could see this being considered part of a "public API", yet not documented. I've no issue with bumping the major. My main question would be how we deal with deprecations as they relate to breaking versions. In other words, do we need to bump the major again when we finally remove the compatibility shims? |
Another feature we probably want for plugins is the ability to add methods to the registry API. For example, if resolvers are extracted as a plugin, we might want its I'd be fine if this were addressed separately. |
I think there's an easy way to decide to bump the major version or not right now. When we remove deprecations, we know the affected packages need to have a major version bump. |
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'm approving, this is a great API that needs to be included and iterated on if needed.
Yeah, that was my initial thought. The worry for me was that we have two major versions we'd be bumping by this one change if we need to bump it for the fact that the deprecation shim is not 100% compatible, then again when the deprecated feature is removed. |
Should (a) return null if not set, (b) assign value as string
1. Wrong property accessed used 2. Reducer is already wrapped at point registerStore is called. Detect from plugin instead.
Not conventional
37d3cd0
to
e4a1c0e
Compare
Maybe not, actually. In my resolvers example, this is something we should be encouraging developers to opt-in as an option of That said, depending on how far we want to take this, something like Anyways, I'm going to give this one more pass and then merge. Thanks for the reviews! 👍 |
FYI as a result of the discussions here, I opted to close #7417 as "wontfix". Feel free to follow-up there if you have thoughts. |
In Progress: Functionally working. Lacks tests, inline code documentation, and deprecations.
This pull request seeks to explore a plugin interface for data registries, refactoring persistence as a separate module which can optionally be pulled into a data registry via the new
use
function.A plugin is defined as a function which receives the registry object, and is expected to return an object of function overrides. For persistence, the plugin is created with a storage key, and returns an object overriding the
registerStore
function of a registry to inject persistence behaviors.Prior art:
Example: