-
Notifications
You must be signed in to change notification settings - Fork 4.1k
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
Framework: Introduce the data module #3832
Conversation
cc @atimmer |
@gziolo I don't think it's a good idea, because it could impact other modules. I think we should use a middleware globally only if it's something useful for all modules. Also, I'm hesitant to introduce such a concept to the |
e18f97b
to
a18aac3
Compare
editor/store/browser.js
Outdated
* @param {Object} store Redux Store | ||
*/ | ||
function enhanceWithBrowserSize( store ) { | ||
const updateSize = throttle( () => { |
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: maybe in this use case lodash debounce is a better option https://lodash.com/docs/4.17.4#debounce. Because we are interested in minimizing the number of actions dispatched and we are just interested in the final window size after resize events stop happening.
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 that case, the Gutenberg UI would only show a resize until after you stop completely with resizing. With throttle, you see UI feedback before you've ended your resize.
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.
Even with debounce, we get some feedback from the browser, e.g: CSS breakpoints. We don't get the feedback of breakpoints implemented in javascript like hiding things or change components. It is a tradeoff, as long as our redraws are efficient and take much less than the throttle time I think the throttle is ok. So I'm fine with both versions.
I did some tests and it looks like this works well. Nice work 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.
- For global/local middlewares: I think it should be dependant on the middleware. For example, a redux logger should be global, because you want to log everything. I assume the current middlewares are pretty specific for the editor, so no global middlewares, for now, makes sense.
- It is really nice that persist can be an option. That is a very clean API and makes persisting to local storage a breeze for plugin authors.
- I am in favor of exploring a GraphQL API. However, we have to see it to compare both options and make a good decision.
- I really like that you are dispatching an action when a reducer is registered.
This is a good step towards getting a great API to get to the state. This also means that in theory plugins could to wp.data.getState()
already, is this an issue or do you think because Gutenberg is in beta we can break this API anytime we want?
We could consider making this 'private' and only expose the function with wp.data._getState()
which should reflect to people that you should not use it yet.
data/index.js
Outdated
if ( window.__REDUX_DEVTOOLS_EXTENSION__ ) { | ||
enhancers.push( window.__REDUX_DEVTOOLS_EXTENSION__() ); | ||
} | ||
const store = createStore( dynamicReducer, {}, flowRight( enhancers ) ); |
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 is cleaner for someone reading the code to put this line under the dynamicReducer
declaration.
data/index.js
Outdated
const store = createStore( dynamicReducer, {}, flowRight( enhancers ) ); | ||
|
||
/** | ||
* Reducer function combining the dynamic "reducers" array |
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.
As per documentation standards, I think this should be Combines the dynamic "reducers" array to create one reducer
.
data/index.js
Outdated
} | ||
|
||
/** | ||
* Register a new sub reducer to the global sate |
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 probably be: Registers a new sub reducer to the global state
.
editor/store/browser.js
Outdated
* @param {Object} store Redux Store | ||
*/ | ||
function enhanceWithBrowserSize( store ) { | ||
const updateSize = throttle( () => { |
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 that case, the Gutenberg UI would only show a resize until after you stop completely with resizing. With throttle, you see UI feedback before you've ended your resize.
editor/store/persist.js
Outdated
} | ||
|
||
/** | ||
* Load the initial state and persist on 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 think for a separation between title and body there should be a newline between those lines. In a different pull request, @aduth and I discussed not using @summary
. This should also start with Loads
.
editor/store/reducer.js
Outdated
@@ -696,14 +687,13 @@ export function metaBoxes( state = defaultMetaBoxState, action ) { | |||
} | |||
|
|||
// Create responsive reducer with the breakpoints imported from the scss variables file. |
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 this comment is now incorrect given that you remove the breakpoints from the code.
editor/store/store.js
Outdated
import effects from './effects'; | ||
|
||
/** | ||
* This function applies the custom middlewares used specifically in the editor moodule |
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.
Applies the custom middlewares used specifically in the editor module
. (Language and typo in module)
Thanks for the review @atimmer
Yep, I think this should be a private API only accessible to the module defining this state, but not sure how to make this clear. I wonder if there's a way to enforce it by making it more difficult to use :) |
data/index.js
Outdated
function dynamicReducer( state = {}, action ) { | ||
let hasChanges = false; | ||
const newState = {}; | ||
reducers.forEach( ( { key, reducer } ) => { |
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 imagine this would be a hot path, so might be worth exploring micro-optimizations, e.g. a standard for
loop instead of Array#forEach
:
https://jsperf.com/obj-accumulate-looping/1
(Aside: The original implementation looks better served by an Array#reduce
than forEach
).
data/index.js
Outdated
let hasChanges = false; | ||
const newState = {}; | ||
reducers.forEach( ( { key, reducer } ) => { | ||
const newSubState = reducer( state[ key ] || {}, action ); |
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 fallback of an object correct here? Wouldn't this mean the sub-reducer's own default value for state would never take effect? I would think we'd want to pass undefined
if it's not yet in state
.
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.
(Not sure if you already pulled some from this implementation, since it's otherwise very similar)
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.
No, I didn't pull from this implement. Indeed, it's a good inspiration. I'll make some improvements once we settle on the bigger questions.
data/index.js
Outdated
*/ | ||
export function registerReducer( key, reducer ) { | ||
reducers.push( { key, reducer } ); | ||
store.dispatch( { type: 'NEW_REDUCER', key, reducer } ); |
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.
Looking at this makes me wonder if dynamicReducer
could be implemented to maintain reducers as its own state, rather than assigning reducers to the top-level variable. Maybe needless complexity.
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 actually thought about it and opted for a global for simplicity.
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.
Have you explored replaceReducer method offered by Redux? This could be combined with combineReducers. I don't know what limitation it has though.
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! thanks for sharing that. I think I read about it before but forgot it completely. I don't expect it to change the performance or the API but maybe it can save us some lines of code.
multi, | ||
]; | ||
|
||
const enhancedGetState = () => get( getState(), 'core/editor' ); |
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.
Instead of restricting by overriding getState
, do you think in this model of data management we might want to create our own variant of connect
which accepts the sub-tree to select from?
connect( 'core/editor' )( mapStateToProps )( MyComponent )
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, no real preference here on my side, I do think your option is more flexible especially when we'll start to query other modules' state. This was just a convenience for now to avoid changing all the components.
@@ -20,7 +20,7 @@ import { | |||
* Internal Dependencies | |||
*/ | |||
import { setupEditor, undo } from '../../actions'; | |||
import createReduxStore from '../../store'; | |||
import store from '../../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.
It's a bit unnerving to me that we treat store as a singleton instance, since there's nothing stopping someone from importing from store to get state or dispatch from anywhere in the codebase. This makes it a little less predictable how state is being interacted with. In this way, it's more similar to the old Flux dispatcher approach. One of the reasons we moved away from Flux to Redux in Calypso was that sharing the store as a singleton could lead to session data leaking between server-side requests. Obviously this doesn't apply to the purely client-side editor, so it may not be as big a concern.
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 idea here is that a WP module requiring state should register itself as soon as the script is loaded (other plugins might depend on this module, thus, on its state).
A module = 1 state tree (or 0).
We shouldn't wait for the provider to be mounted to load initialize the state of the module.
So this PR is changing the meaning of the "editor" state from editor instance's state to editor module's state which I think is the way to go to "modularize" the state in a centralized "data" module.
Later, we can decide to add a root level to the "editor" module's reducer containting the "id" of the instanciated editor (which would mean we could hold multiple editor instances)
* @param {String} key Reducer key | ||
* @param {Object} reducer Reducer function | ||
*/ | ||
export function registerReducer( key, reducer ) { |
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.
To address the challenges around module-specific middlewares and enhancers, could the data module serve as a registry of stores, rather than of reducers? So each module could have its own middlewares, etc. Main difference would be subscribe
, dispatch
, and getState
would need to operate separately on each store, rather than assuming there's a single store object to work with.
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 we'd have use-cases requiring cross-module selectors. For example a post-editor module declares a reducer and is dependent on the editor module. In which case, post-editor could provide a cross-module selector? It also seem helpful for plugins.
Granted, you can recreate this with three selectors, one for each store and a third function to merge both results.
Also, the other question I have is whether the data
module should assume a redux store (with middlewares, enhancers) where a reducer
is not really redux-related. Thinking the data
module should not be framework-dependent in its external 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.
I have a few concerns with the registry-of-stores model, as that would break the premise of a single aggregator and pipeline of state for the entire application. If the goal were to provide a common framework for independent plugins to run on, I think it'd make more sense, but here we expect plugins to build on top of one another, and we expect there to be communication to and from Gutenberg, correct?
That said, I think I understand wanting to separate things a little, but to me that would be achieved by keeping enhancements and middlewares domain-specific.
Also, the other question I have is whether the
data
module should assume a redux store (with middlewares, enhancers) where areducer
is not really redux-related. Thinking thedata
module should not be framework-dependent in its external API.
I share this idea, and the broader idea that learning what a reducer is is easier than wrapping one's head around a 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.
I'm wondering if we could still implement as separate stores, but keep the same top-level interface. i.e.
wp.data.getState = () => stores.reduce( ( result, { store, key } ) => {
return { ...result, [ key ]: store.getState() };
}, {} );
Registering still looks like you're registering a reducer, but you'd have the option to specific middlewares or enhancers if needed.
Might be a nightmare for performance.
a18aac3
to
6c3dcc3
Compare
I think this in a state we can merge and iterate on:
|
data/index.js
Outdated
*/ | ||
const reducers = []; | ||
const enhancers = []; | ||
if ( window.__REDUX_DEVTOOLS_EXTENSION__ ) { |
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 add checks for window existance in other places?
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.
Haven't seen any. Do we expect our modules to work in Node Context?
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, I guess not. Old habbit from Calypso days 😎
I need to think a bit more about this PR. It’s a great start. I would be happy to see it merged very soon and mark as experimental. It worked quite well with other extensibility changes. The sooner we get some community trying to integrate it the better shape it can take. My only concern at the moment is that it’s difficult to predict what happens when a plugin creates their own customized store interface that uses different middlewares. I need to figure out if that can cause some troubles when you dispatch an action that would be enhanced in editor. Let’s say preferences get dispatched from plugin and they wouldn’t trigger enhancers from editor. Need to think a bit more about 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.
This looks reasonable to me but, yes, we definitely want to seek answers to patterns around middlewares, enhancers, and side effects.
data/index.js
Outdated
* @param {Object} reducer Reducer function | ||
*/ | ||
export function registerReducer( key, reducer ) { | ||
reducers.push( { key, reducer } ); |
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.
Would we want any validation here to check whether key
is already registered? Right now it seems it would work and whichever is registered most recently would "win".
To this point, should reducers
be structured as an object of key: reducer
? Then it'd be a matter of checking reducers.hasOwnProperty( key )
.
de80016
to
170ca68
Compare
Glad to see it. Looks quite clean. So the idea would be to register reducer per module/plugin, right? I was wondering how this I will test this PR later today, need to wrap up a few things first. |
Ok! I'm moving forward with this PR. Expect some follow-up PRs/issues on state exposition, effects. |
Even better, I will test it together with coediting with simple rebase :D |
Was it deferred to handle moving |
@aduth Yes, mostly deferred because it's too impactful (too many changes) and I didn't want to show it as part of this PR's changes. I'd be happy to do it and merge it as quickly as possible to avoid infinite rebases :) |
refs #3805
Still in progress but Feedback appreciated.
This PR introduces a
data
module centralizing the state of separate redux modules (and later allowing access to state for plugins)For now, the API mimics the redux API:
It also uses Redux internally.
Challenges:
effects
, do we assume all modules userefx
? Should we register the effects as well?My proposed solution is that the
data
module should only care about thedata
right now, not the side effects. (This decision is subject to discussion and change later). To still allow using therefx
middleware for theeditor
module, I was able to enhance the "dispatch" function.What about
getState
? For now it's up to the module author, you can access the whole state and I've used a technique in theeditor
module to only return the state from the current module when usingconnect
. This avoids refactoring all the components/selectors.What about the
custom middlewares
likemulti
. Should they be "global". I assumed thedata
module should be as small as possible and each module can apply these custom middlewares to its owndispatch
calls if needed.What about the store enhancers:
persist
,redux-responsive
. We can't use store enhancers in an already instantiated store. So if these enhancers are not "global" (like I assumed), you have to find alternatives. I came up with small alternatives for those in the current editor module. (cc @jorgefilipecosta not sure I've everything set up properly for the mobile enhancer, help welcome here)Store persist can be offered as an "option" when registering the reducer, It's simple enought to do, just left this decision for later, maybe it's not needed after all.
This PR doesn't address the "expose state to plugins and other modules" issue but it get us closer to a solution to this problem. We need to answer the question of "how to expose this state?" I think the use of the global
getState
should be discouraged to allow changing the shape of the state in the future without BC. The options here are:Anyway exposing the state should be resolved as a follow-up PR.
Todo