Skip to content
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

Refactor withViewportMatch to use useViewportMatch #18950

Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 14 additions & 7 deletions packages/viewport/src/test/if-viewport-matches.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import TestRenderer, { act } from 'react-test-renderer';
/**
* WordPress dependencies
*/
import { dispatch } from '@wordpress/data';
import { useViewportMatch as useViewportMatchMock } from '@wordpress/compose';

/**
* Internal dependencies
Expand All @@ -17,27 +17,34 @@ import ifViewportMatches from '../if-viewport-matches';
describe( 'ifViewportMatches()', () => {
const Component = () => <div>Hello</div>;

afterEach( () => {
useViewportMatchMock.mockClear();
} );

it( 'should not render if query does not match', () => {
dispatch( 'core/viewport' ).setIsMatching( { '> wide': false } );
const EnhancedComponent = ifViewportMatches( '> wide' )( Component );
useViewportMatchMock.mockReturnValueOnce( false );
const EnhancedComponent = ifViewportMatches( '< wide' )( Component );

let testRenderer;
act( () => {
testRenderer = TestRenderer.create( <EnhancedComponent /> );
} );

expect( useViewportMatchMock.mock.calls ).toEqual( [ [ 'wide', '<' ] ] );

expect( testRenderer.root.findAllByType( Component ) ).toHaveLength( 0 );
} );

it( 'should render if query does match', () => {
act( () => {
dispatch( 'core/viewport' ).setIsMatching( { '> wide': true } );
} );
const EnhancedComponent = ifViewportMatches( '> wide' )( Component );
useViewportMatchMock.mockReturnValueOnce( true );
const EnhancedComponent = ifViewportMatches( '>= wide' )( Component );
let testRenderer;
act( () => {
testRenderer = TestRenderer.create( <EnhancedComponent /> );
} );

expect( useViewportMatchMock.mock.calls ).toEqual( [ [ 'wide', '>=' ] ] );

expect( testRenderer.root.findAllByType( Component ) ).toHaveLength( 1 );
} );
} );
35 changes: 29 additions & 6 deletions packages/viewport/src/test/with-viewport-match.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@ import renderer from 'react-test-renderer';
/**
* WordPress dependencies
*/
import { dispatch } from '@wordpress/data';
import { Component } from '@wordpress/element';
import { useViewportMatch as useViewportMatchMock } from '@wordpress/compose';

/**
* Internal dependencies
Expand All @@ -16,6 +16,10 @@ import '../store';
import withViewportMatch from '../with-viewport-match';

describe( 'withViewportMatch()', () => {
afterEach( () => {
useViewportMatchMock.mockClear();
} );

const ChildComponent = () => <div>Hello</div>;

// this is needed because TestUtils does not accept a stateless component.
Expand All @@ -30,14 +34,33 @@ describe( 'withViewportMatch()', () => {
};

it( 'should render with result of query as custom prop name', () => {
dispatch( 'core/viewport' ).setIsMatching( { '> wide': true } );
const EnhancedComponent = withViewportMatch(
{ isWide: '> wide' }
const EnhancedComponent = withViewportMatch( {
isWide: '>= wide',
isSmall: '>= small',
isLarge: 'large',
isLessThanSmall: '< small',
}
)( ChildComponent );

useViewportMatchMock.mockReturnValueOnce( false );
useViewportMatchMock.mockReturnValueOnce( true );
useViewportMatchMock.mockReturnValueOnce( true );
useViewportMatchMock.mockReturnValueOnce( false );

const wrapper = renderer.create( getTestComponent( EnhancedComponent ) );

expect( wrapper.root.findByType( ChildComponent ).props.isWide )
.toBe( true );
expect( useViewportMatchMock.mock.calls ).toEqual( [
[ 'wide', '>=' ],
[ 'small', '>=' ],
[ 'large', '>=' ],
[ 'small', '<' ],
] );

const { props } = wrapper.root.findByType( ChildComponent );
expect( props.isWide ).toBe( false );
expect( props.isSmall ).toBe( true );
expect( props.isLarge ).toBe( true );
expect( props.isLessThanSmall ).toBe( false );

wrapper.unmount();
} );
Expand Down
39 changes: 29 additions & 10 deletions packages/viewport/src/with-viewport-match.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,7 @@ import { mapValues } from 'lodash';
/**
* WordPress dependencies
*/
import { createHigherOrderComponent } from '@wordpress/compose';
import { withSelect } from '@wordpress/data';
import { createHigherOrderComponent, pure, useViewportMatch } from '@wordpress/compose';

/**
* Higher-order component creator, creating a new component which renders with
Expand All @@ -32,13 +31,33 @@ import { withSelect } from '@wordpress/data';
*
* @return {Function} Higher-order component.
*/
const withViewportMatch = ( queries ) => createHigherOrderComponent(
withSelect( ( select ) => {
return mapValues( queries, ( query ) => {
return select( 'core/viewport' ).isViewportMatch( query );
} );
} ),
'withViewportMatch'
);
const withViewportMatch = ( queries ) => {
const useViewPortQueriesResult = () => mapValues( queries, ( query ) => {
let [ operator, breakpointName ] = query.split( ' ' );
if ( breakpointName === undefined ) {
breakpointName = operator;
operator = '>=';
}
// Hooks should unconditionally execute in the same order,
// we are respecting that as from the static query of the HOC we generate
// a hook that calls other hooks always in the same order (because the query never changes).
// eslint-disable-next-line react-hooks/rules-of-hooks
return useViewportMatch( breakpointName, operator );
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Interesting, I wonder if there are similar cases/discussions in the React community.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hi @youknowriad,
I did some research on this. It seems it is possible to do this. The samples people referred to were things like defining an array of states const useInputs = [...Array(n)].map((_, i) => useState('name' + i)); and they should be avoided because normally there is a better alternative (in that case defined a single state with an array). In this specific case, I don't think we have an alternative (unless we change the hook API), and we are sure that the order the hooks are called will not change during the component life cycle, so it seems like something we could do.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Normally, this is symptomatic of a cluttered component tree and means that you should break down the caller into multiple child components, each with their own hook call, but here we are trying to maintain backwards compatibility.

An alternative would be to introduce a useViewportMatches hook, but it's not a big deal. We should just clearly document that the query has to be static.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, we use this mechanism just to maintain backward compatibility. Given the way an HOC works the query will always be static for a component instance, there is no way of doing the opposite. Changing the query will require a call to the HOC and a new component is created.
I don't think we should change the hook API so I guess we can keep this solution.

} );
return createHigherOrderComponent(
( WrappedComponent ) => {
return pure( ( props ) => {
const queriesResult = useViewPortQueriesResult();
return (
<WrappedComponent
{ ...props }
{ ...queriesResult }
Comment on lines +50 to +54
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

withSelect served as a memoization layer which this code doesn't replicate.

queriesResult will change on every render here. Could it be a performance concern?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hi @epiqueras, I don't think it is a concern, we are spreading queries result and passing each prop individually as a boolean e.g: isLarge: false, isSmall: true etc... Even if withViewportMatch is used in a pure component as we are just passing boolean values if useViewportMatch did not change we will not trigger unnecessary rerenders.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right, but withSelect wraps using a pure component, this HOC does not.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point, I kept the previous behavior and also used pure HOC as the withSelect does.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice.

/>
);
} );
},
'withViewportMatch'
);
};

export default withViewportMatch;
6 changes: 6 additions & 0 deletions test/unit/config/global-mocks.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
jest.mock( '@wordpress/compose', () => {
return {
...jest.requireActual( '@wordpress/compose' ),
useViewportMatch: jest.fn(),
};
} );
1 change: 1 addition & 0 deletions test/unit/jest.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ module.exports = {
},
preset: '@wordpress/jest-preset-default',
setupFiles: [
'<rootDir>/test/unit/config/global-mocks.js',
'<rootDir>/test/unit/config/gutenberg-phase.js',
'<rootDir>/test/unit/config/register-context.js',
],
Expand Down