Framework: Add viewport module (data module experiments, proof-of-concept)#5206
Framework: Add viewport module (data module experiments, proof-of-concept)#5206
Conversation
data/index.js
Outdated
There was a problem hiding this comment.
Yes, much nicer. I think this should be the only public API available. We could turn registerReducer, registerActions and registerSelectors into the implementation detail.
There was a problem hiding this comment.
Unless we want to allow other plugins to make modifications to not their own stores.
|
An example of where this could prove valuable outside of Yes, it's hidden by CSS, but it still incurs wasteful and unnecessary DOM reconciliation. |
data/index.js
Outdated
There was a problem hiding this comment.
I like it a bit less, but not a blocker :) I can see the advantage of factorizing this kind of logic we could need in several places.
There was a problem hiding this comment.
The alternative being:
let lastBlocks;
subscribe( () => {
const blocks = select( 'core/editor' ).getBlocks();
if ( blocks !== lastBlocks ) {
// ... Blocks are known to have changed.
}
lastBlocks = blocks;
} );Aside from being verbose and clumsy, there's another issue here: The selector is called on every single state change to every single registered store. For expensive queries (even memoized), this would be quite resource intensive.
There was a problem hiding this comment.
There was a problem hiding this comment.
Right, but in your example the subscribe logic is run when core/viewport state changes, which is not so great.
There was a problem hiding this comment.
Minor not a blocker just a thought: In redux subscribes always receives a listener and there is no "filtering" possibility. In my opinion, I think we should also do the same, subscribe always receives just a listener, so we are consistent with redux. I understand filtering a specific change will be very a common case, so to address we can use: compose( selectorListner(selector), reducerListner( reducer ), subscribe ). This makes clear that some code is always executed for each change, and our functions selectorListner and reducerListner are just helpers to filter out change. Other helpers can be created in the future.
There was a problem hiding this comment.
@jorgefilipecosta This is interesting. At its most generic, it really only requires a "has value changed" higher-order function to encapsulate the behavior from the original alternative. Something like:
subscribe( ifChanged( () => (
select( 'core/viewport' ).isViewportMatch( '< medium' )
) )( () => {
const isSmall = isViewportMatch( '< medium' );
// Collapse sidebar when viewport shrinks.
if ( isSmall ) {
dispatch( 'core/edit-post' ).closeGeneralSidebar();
}
} ) );Then it becomes a question of: Do we expect this to be such a common pattern that it's still worth building conveniences into the API itself? The above snippet is generic, and not terribly verbose or clumsy, but still more verbose than the changes that had been proposed here. Generalism can be an ideal, but it shouldn't deter us from creating conveniences for common patterns.
It's a discussion worth having though:
- What other subscribe helpers could we imagine?
- What sorts of things would we expect people to use selector subscriptions for?
To the latter question, I'm finding that this could satisfy any need for a side effect pattern in the data module. In fact, taken to its extreme, we might be able to replace all of our own internal side effects with this approach, representing the side effects as a result of a change in state. Can all of the current and foreseen side effects be stated this way, or is access to the raw actions, even those not resulting in a change, necessary? It'd require some investigation, but I'm inclined to say that we could and should try to model side effects on changes in state. This might help remedy some of the disorganization that has resulted in the various effects.js files. At a higher-level, it might even be seen as the data equivalent of React's visual declarative UI, where "props" in this case are the selector result(s).
There was a problem hiding this comment.
As a testament to the clumsiness of #5206 (comment), this example code snippet inadvertently introduces an infinite loop 😏 (Dispatch causes subscribe to re-invoke before lastBlocks is assigned)
There was a problem hiding this comment.
Thank you for thoughts and analysing this, it is good that we will be able to discuss this specifc part outside of the scope of the rest of the changes 👍
edit-post/store/index.js
Outdated
There was a problem hiding this comment.
I believe this is a new behavior, how was this handled before? The separation between the mobile sidebar and the desktop sidebar?
There was a problem hiding this comment.
It was previously effected in edit-post/store/reducer.js's handling of the UPDATE_MOBILE_STATE action triggered by enhanceWithBrowserSize.
lib/client-assets.php
Outdated
There was a problem hiding this comment.
missing element, data and components here ;)
youknowriad
left a comment
There was a problem hiding this comment.
Awesome work, didn't test yet but the code is great.
1d68420 to
be72f5b
Compare
| @@ -1,44 +0,0 @@ | |||
| /** | |||
There was a problem hiding this comment.
Yes, it should be removed with #4777, but my comment was missed.
| * | ||
| * @return {Function} Higher-order component. | ||
| */ | ||
| const ifCondition = ( predicate, propName ) => ( WrappedComponent ) => { |
data/index.js
Outdated
| * | ||
| * @return {Function} Higher-order function to call with ifTrueFn. | ||
| */ | ||
| const when = ( predicate ) => ( ifTrueFn ) => ( ...args ) => { |
There was a problem hiding this comment.
It seems like when and whenWith should be extracted to their own file so we could have only public APIs in this file. Can we also add simple unit tests for both functions?
There was a problem hiding this comment.
Create our own FP utility library? 😄
There was a problem hiding this comment.
I'm fine about pulling ramda and recompose in 😄
There was a problem hiding this comment.
Since 84c64c8 was removed in rebase, this is no longer relevant.
data/test/index.js
Outdated
| expect( oneCallback ).toHaveBeenCalled(); | ||
| expect( twoCallback ).not.toHaveBeenCalled(); | ||
|
|
||
| oneUnsubscribe(); |
There was a problem hiding this comment.
Minor: when expect fails unsubscribe methods won't be executed. It shouldn't have impact on other tests though. One way to solve it is to put all unsubscribe methods in an array and iterate over it using afterEach.
let unsubscribes = [];
afterEach( () => {
unsubscribes.forEach( ( unsubscribe ) => unsubscribe() );
unsubscribes = [];
} );
it( '...', () => {
unsubscribes.push( subscribe( 'one', oneCallback ) );
} );or something like this.
There was a problem hiding this comment.
Yeah, that's true. At the same time though, if a test fails, all bets are off. The only issue is if it impacted future failures that caused needless debugging. I'll see about adding lifecycle.
There was a problem hiding this comment.
Exactly, it might fail not related tests in the future. It’s a minor thing and really hard to implement in a clean way 🙁
There was a problem hiding this comment.
Since 84c64c8 was removed in rebase, this is no longer relevant. That said, before dropping, I did find it was pretty simple to implement a helper subscribe wrapper:
const unsubscribes = [];
afterEach( () => {
let unsubscribe;
while ( ( unsubscribe = unsubscribes.shift() ) ) {
unsubscribe();
}
} );
function subscribeWithUnsubscribe( ...args ) {
unsubscribes.push( subscribe( ...args ) );
}
viewport/README.md
Outdated
| ); | ||
| } | ||
|
|
||
| MyComponent = ifViewportMatches( '< small', 'isMobile' )( MyComponent ); |
There was a problem hiding this comment.
This one might be confusing because it behaves differently than when it contains only one param. Should we create 2nd HOC component instead? Example:
MyComponent = withViewportProps( { isMobile: '< small' } )( MyComponent );There was a problem hiding this comment.
This one might be confusing because it behaves differently than when it contains only one param. Should we create 2nd HOC component instead?
I'd totally agree from a consumer's perspective these make sense as two separate components. My only worry is that from the component author's perspective, it's a fair amount of work if we want all "if" HOC to also have a complementary "with".
I might imagine we could leverage some composition here though:
ifViewportMatches = compose( [
withViewportMatch( '< small' ),
ifPropsMatch( { isViewportMatch: true }, { omitProps: true } ),
] );There was a problem hiding this comment.
That’s interesting idea to compose two HOCs where both of them might be useful by their own, too.
There was a problem hiding this comment.
Split to separate higher-order components withViewportMatch and ifViewportMatches in fe100d4.
viewport/store/selectors.js
Outdated
| * @return {boolean} Whether viewport matches query. | ||
| */ | ||
| export function isViewportMatch( state, query ) { | ||
| const key = [ '>=', ...query.split( ' ' ) ].slice( -2 ).join( ' ' ); |
There was a problem hiding this comment.
Minor: I had to spend some time to understand what's happening here. It might be only me, so feel free to ignore. It might be more code to express it differently, but maybe it would pay off. Example:
const key = query.split( ' ' ).length === 1 ? `>= ${ query }` : query;I think it will work with the existing tests.
There was a problem hiding this comment.
Minor: I had to spend some time to understand what's happening here.
Yeah, it's one of my favorite "clever" codes that I still find myself leaning on. 😄 I might argue it's a valuable pattern that we can introduce developers to. For example, I've also found value in using it to format a date as MM-DD-YYYY (where the source value of MM and DD can be one or two digits):
[ d.getMonth() + 1, d.getDate() ].map( ( n ) => ( '0' + n ).slice( -2 ) ).concat( d.getFullYear() ).join( '-' )
// "02-23-2018"Maybe some clarifying comments explaining the behavior?
There was a problem hiding this comment.
const lastTwo = items => items.slice( -2 );
cons key = lastTwo( [ '>=', ...query.split( ' ' ) ] ).join( ' ' );For me personally, this would help :)
There was a problem hiding this comment.
For me personally, this would help :)
There was a problem hiding this comment.
Added an inline comment and used Lodash's _.takeRight in rebased 217021a.
|
I like this proposal. 👍 |
| return <WrappedComponent { ...props } />; | ||
| }; | ||
|
|
||
| EnhancedComponent.displayName = getWrapperDisplayName( WrappedComponent, 'condition' ); |
There was a problem hiding this comment.
Should it be getWrapperDisplayName( WrappedComponent, 'ifCondition' );?
There was a problem hiding this comment.
We omit prefixes in other places, but we might change this if this is confusing.
There was a problem hiding this comment.
We omit prefixes in other places, but we might change this if this is confusing.
I'm thinking particularly with #5206 (comment) in mind, we might want to do this, since we could have two higher-order components with similar names distinguished on prefix, e.g. withViewportMatch, ifViewportMatches.
Also to the point of #5206 (comment), I'm starting to think maybe we could use a new higher-order component utility in place of the current getWrapperDisplayName to allow for easier HOC composition.
Example:
export default createHigherOrderComponent(
'ifViewportMatches',
( query ) => compose( [
withViewportMatch( { isViewportMatch: query } ),
ifCondition( ( props ) => props.isViewportMatch ),
] )
);There was a problem hiding this comment.
@aduth, this is actually a great idea even for the regular HOCs. See:
const ifCondition = ( predicate ) => ( WrappedComponent ) => {
const EnhancedComponent = ( props ) => {
if ( ! predicate( props ) ) {
return null;
}
return <WrappedComponent { ...props } />;
};
EnhancedComponent.displayName = getWrapperDisplayName( WrappedComponent, 'ifCondition' );
return EnhancedComponent;
};vs.
const ifCondition = createHigherOrderComponent(
'ifCondition',
( predicate ) => ( WrappedComponent ) => {
return ( props ) => {
if ( ! predicate( props ) ) {
return null;
}
return <WrappedComponent { ...props } />;
};
},
);I'm voting to refactor all HOCs to use the helper you proposed and update all prefixes to match what has been discussed here.
There was a problem hiding this comment.
Implementation wise it might be non-trivial task because some HOCs take additional params and require a higher-order function to be involved :)
There was a problem hiding this comment.
Implementation wise it might be non-trivial task because some HOCs take additional params and require a higher-order function to be involved :)
Yeah, I was thinking about this too. One option is to test whether the argument passed is a class extending Component, but this would mean that all higher-order components would have to be implemented as the full-on class form. Maybe not a big deal to impose this?
| * | ||
| * @type {Object} | ||
| */ | ||
| const BREAKPOINTS = { |
There was a problem hiding this comment.
can we do something similar to what gutenberg/edit-post/store/constants.js does? Use the scss variable loader '!!sass-variables-loader!../assets/stylesheets/_breakpoints.scss';
There was a problem hiding this comment.
can we do something similar to what gutenberg/edit-post/store/constants.js does? Use the scss variable loader '!!sass-variables-loader!../assets/stylesheets/_breakpoints.scss';
My main issue with this is that the breakpoints are defined in an edit-post stylesheet. Ideally those common SCSS variables don't remain there. The main issue we solve with the variable loader is avoiding repetition, but one of the goals with viewport module is at least to consolidate all viewport-related logic. So hopefully at most we'd have at most two sets to maintain: one in SCSS and one in JS.
There was a problem hiding this comment.
Yes it's trade that makes sense to make, and as it is just this cases, it is not hard to maintain 👍
|
Awesome work here 👍This seems to work well, in my smoke tests I did not find regressions. |
|
Tracking reference to reducer / selector subscription implementation, as I'm planning to drop it in rebase (and potentially explore separately): 84c64c8 |
316850e to
cc2fb9f
Compare
|
I haven't yet reviewed all of this, but since #5083 was closed in favor of this one, this is the feedback I had pending (reviewed on the plane):
|
My intuition is: Yes, we have the tools necessary to do so, often have cross-cutting state needs (such as the Related discussion: https://wordpress.slack.com/archives/C5UNMSU4R/p1519137939000106
My intuition is: Yes, its the simplest policy to adopt. Can you elaborate on what you mean by malleability? If you mean state can change shape, this is one of the main advantages of selectors in abstracting over changes to underlying data shape. There are consequences to opening up all selectors (backwards-compatibility, access to "internal" values), but it's my opinion that any other policy has worse compromises. Related discussion: https://wordpress.slack.com/archives/C5UNMSU4R/p1518533180000510
Depending on how you frame it. The dropped commit 84c64c8 was interesting in that by having reducer/selector-level subscriptions, there could be a net positive performance impact in that changes to stores don't impact unconcerned subscribers. I've even been toying with the idea of integrating this into the export default withSelect( 'core/edit-post', ( selectors ) => ( {
isActive: selectors.isFeatureActive( 'fixedToolbar' ),
} ) )( VisualEditor );...meaning that the component wouldn't bother rerunning its selectors if, for example, |
| { storeKey: 'edit-post' } | ||
| )( withInstanceId( FeatureToggle ) ); | ||
| } ) ), | ||
| ifViewportMatches( 'medium' ), |
There was a problem hiding this comment.
Would it make any difference in case ifViewportMatches( 'medium' ) would be composed as the first item here? All HOCs would have to be applied when the source file loads, but it seems like withSelect and withDispatch wouldn't have to be rendered in case the viewport doesn't match. It might only make any difference on the initial render if I process the application flow properly.
There was a problem hiding this comment.
Would it make any difference in case
ifViewportMatches( 'medium' )would be composed as the first item here?
I'll test it, but yes, this seems like it could potentially serve as a (small) optimization.
| withSelect( ( select ) => ( { | ||
| hasFixedToolbar: select( 'core/edit-post' ).isFeatureActive( 'fixedToolbar' ), | ||
| } ) ), | ||
| withViewportMatch( { isLargeViewport: 'medium' } ), |
There was a problem hiding this comment.
It looks really nice. Thanks for taking my feedback into account 👍
| const store = applyMiddlewares( | ||
| registerReducer( MODULE_KEY, withRehydratation( reducer, 'preferences', STORAGE_KEY ) ) | ||
| ); | ||
| const store = registerStore( 'core/edit-post', { |
| wp_register_script( | ||
| 'wp-viewport', | ||
| gutenberg_url( 'viewport/build/index.js' ), | ||
| array( 'wp-element', 'wp-data', 'wp-components' ), |
There was a problem hiding this comment.
Just noting that it has too many dependencies to move it to @wordpress/packages.
| ); | ||
| } | ||
|
|
||
| MyComponent = withViewportMatch( { isMobile: '< small' } )( MyComponent ); |
There was a problem hiding this comment.
I'm still not sure whether we shoud pick the signature closer to what you proposed before:
MyComponent = withViewportMatch( 'isMobile', '< small' )( MyComponent );or
MyComponent = withViewportMatch( { isMobile: '< small' } )( MyComponent );The latter seems to be more flexible, but we don't have any use case in the code that validates we need that capability.
There was a problem hiding this comment.
I'm still not sure whether we shoud pick the signature closer to what you proposed before:
In my initial attempt at splitting the components, I'd gone with the first of the two, but upon some reflection I think the object form is better:
- More familiar in other usage of higher-order components which map to props as an object (e.g.
withSelect) - Allows for multiple viewport props to be provided (handling multiple cases of viewport size)
There was a problem hiding this comment.
Yes, probably is the best to keep it as it is.
| */ | ||
| const ifViewportMatches = ( query ) => ( WrappedComponent ) => { | ||
| const EnhancedComponent = compose( [ | ||
| withViewportMatch( { |
There was a problem hiding this comment.
This is amazing, how you composed 2 existing HOCs to create another one 🚀
viewport/if-viewport-matches.js
Outdated
|
|
||
| /** | ||
| * Higher-order component creator, creating a new component which renders if | ||
| * the viewport query is satisfied or with the given optional prop name. |
There was a problem hiding this comment.
or with the given optional prop name - this part is no longer applicable.
| * | ||
| * @type {Object<string,MediaQueryList>} | ||
| */ | ||
| const queries = reduce( BREAKPOINTS, ( result, width, name ) => { |
There was a problem hiding this comment.
Nit: setIsMatching might be declared after queries to signal that it depends on it.
There was a problem hiding this comment.
Nit:
setIsMatchingmight be declared afterqueriesto signal that it depends on it.
Actually, this was tricky. I'd originally had it as you suggest, but the problem was that when adding the list.addListener( setIsMatching ) below, it would work in that setIsMatching was already declared via variable hoisting, but undefined at the time. addListener happily accepted the undefined value, but would never trigger the callback when it changed.
| export function isViewportMatch( state, query ) { | ||
| // Pad to _at least_ two elements to take from the right, effectively | ||
| // defaulting the left-most value. | ||
| const key = takeRight( [ '>=', ...query.split( ' ' ) ], 2 ).join( ' ' ); |
viewport/with-viewport-match.js
Outdated
| * @return {Function} Higher-order component. | ||
| */ | ||
| const withViewportMatch = ( queries ) => ( WrappedComponent ) => { | ||
| const EnhancedComponent = compose( [ |
| onMobile: isMobile( state ), | ||
| } ), | ||
| ( dispatch, ownProps ) => ( { | ||
| export default compose( [ |
There was a problem hiding this comment.
See how compose is implemented in Ramda: http://ramdajs.com/docs/#compose. Lodash allows to pass an array, but in the FP world it is a function that takes any number of functions:
((y → z), (x → y), …, (o → p), ((a, b, …, n) → o)) → ((a, b, …, n) → z)
There was a problem hiding this comment.
Lodash allows to pass an array, but in the FP world it is a function that takes any number of functions:
Yeah, I don't feel strongly one way or the other, but knowing how arguments works with Function#length and various V8 deoptimizations, I am (apathetically) cautious to avoid it for fear of encouraging its use.
Tangentially related: See classcat vs. classnames where the difference is passing as array vs. arguments respectively, with the former benefitting as such both in performance and bundle size.
There was a problem hiding this comment.
Feel free to measure the impact in here and add Eslint rule so we could fly with one style 👍
Speaking myself, it’s also tempting to teach Babel to optimize our code, in cases like this. I’m fine with the surrounding array syntax but I’d love to see one style everywhere.
cc2fb9f to
3db32df
Compare
e9af8be to
6e20425
Compare
6e20425 to
798fd32
Compare

This pull request seeks to implement a new generic
@wordpress/viewportmodule to replace and generalize the currentisMobilestate maintained inedit-post. There are many changes here, some of which tentatively proposed, which could be split out into smaller pull request, but included here as part of the larger story of demonstrating a full-featured proof-of-concept standalone data-exposing module.Specifically, ideas explored here include:
isViewportMatchhigher-order component, or by state directlyselect( 'core/viewport' ).isViewportMatch( 'medium' );withAPIDataas well to allow for API selectors and generalized availability (related discussion)registerStoreshorthand to simultaneously register reducer and zero or more of actions and selectorssubscribe( 'core/editor', 'getBlocks', ( blocks ) => /* blocks changed */ );subscribe( 'core/viewport', [ 'isViewportMatch', 'huge' ], () => /* huge changed */ )A "side effects" pattern via selector subscriptions (and reducer-level subscriptions)(Edit: Deferred to future exploration, see Framework: Add viewport module (data module experiments, proof-of-concept) #5206 (comment) )There's a dramatic simplification of post editor with these changes, as it no longer needs to maintain state about viewport.
Testing instructions:
Verify that there's no regressions in viewport behavior.
Examples:
Remaining tasks: