Skip to content

Commit

Permalink
Refactor useMediaQuery with useSyncExternalStore (#48973)
Browse files Browse the repository at this point in the history
* Compose: Refactor useMediaQuery with useSyncExternalStore

* Update tests

* Return false for the server

* Don't create MediaQueryList from scratch to get value

* Use preferred syntax

* Use the 'mock-match-media' package

* Feedback
  • Loading branch information
Mamaduka authored Mar 16, 2023
1 parent ced5222 commit 8ac3b00
Show file tree
Hide file tree
Showing 4 changed files with 78 additions and 107 deletions.
9 changes: 9 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,7 @@
"metro-react-native-babel-preset": "0.70.3",
"metro-react-native-babel-transformer": "0.70.3",
"mkdirp": "0.5.1",
"mock-match-media": "0.4.2",
"nock": "12.0.3",
"node-fetch": "2.6.1",
"node-watch": "0.7.0",
Expand Down
65 changes: 44 additions & 21 deletions packages/compose/src/hooks/use-media-query/index.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,25 @@
/**
* WordPress dependencies
*/
import { useState, useEffect } from '@wordpress/element';
import { useMemo, useSyncExternalStore } from '@wordpress/element';

/**
* A new MediaQueryList object for the media query
*
* @param {string} [query] Media Query.
* @return {MediaQueryList|null} A new object for the media query
*/
function getMediaQueryList( query ) {
if (
query &&
typeof window !== 'undefined' &&
typeof window.matchMedia === 'function'
) {
return window.matchMedia( query );
}

return null;
}

/**
* Runs a media query and returns its value when it changes.
Expand All @@ -10,28 +28,33 @@ import { useState, useEffect } from '@wordpress/element';
* @return {boolean} return value of the media query.
*/
export default function useMediaQuery( query ) {
const [ match, setMatch ] = useState(
() =>
!! (
query &&
typeof window !== 'undefined' &&
window.matchMedia( query ).matches
)
);
const source = useMemo( () => {
const mediaQueryList = getMediaQueryList( query );

useEffect( () => {
if ( ! query ) {
return;
}
const updateMatch = () =>
setMatch( window.matchMedia( query ).matches );
updateMatch();
const list = window.matchMedia( query );
list.addListener( updateMatch );
return () => {
list.removeListener( updateMatch );
return {
/** @type {(onStoreChange: () => void) => () => void} */
subscribe( onStoreChange ) {
if ( ! mediaQueryList ) {
return () => {};
}

mediaQueryList.addEventListener( 'change', onStoreChange );
return () => {
mediaQueryList.removeEventListener(
'change',
onStoreChange
);
};
},
getValue() {
return mediaQueryList?.matches ?? false;
},
};
}, [ query ] );

return !! query && match;
return useSyncExternalStore(
source.subscribe,
source.getValue,
() => false
);
}
110 changes: 24 additions & 86 deletions packages/compose/src/hooks/use-media-query/test/index.js
Original file line number Diff line number Diff line change
@@ -1,136 +1,74 @@
/**
* External dependencies
*/
import { render, act } from '@testing-library/react';
import { act, render } from '@testing-library/react';
import { matchMedia, setMedia, cleanup } from 'mock-match-media';

/**
* Internal dependencies
*/
import useMediaQuery from '../';

describe( 'useMediaQuery', () => {
let addListener, removeListener;
const TestComponent = ( { query } ) => {
const queryResult = useMediaQuery( query );
return `useMediaQuery: ${ queryResult }`;
};

describe( 'useMediaQuery', () => {
beforeAll( () => {
jest.spyOn( global, 'matchMedia' );

addListener = jest.fn();
removeListener = jest.fn();
window.matchMedia = matchMedia;
} );

afterEach( () => {
global.matchMedia.mockClear();
addListener.mockClear();
removeListener.mockClear();
beforeEach( () => {
setMedia( {
width: '960px',
} );
} );

afterAll( () => {
global.matchMedia.mockRestore();
afterEach( () => {
cleanup();
} );

const TestComponent = ( { query } ) => {
const queryResult = useMediaQuery( query );
return `useMediaQuery: ${ queryResult }`;
};

it( 'should return true when query matches', async () => {
global.matchMedia.mockReturnValue( {
addListener,
removeListener,
matches: true,
} );

const { container, unmount } = render(
const { container } = render(
<TestComponent query="(min-width: 782px)" />
);

expect( container ).toHaveTextContent( 'useMediaQuery: true' );

unmount();

expect( removeListener ).toHaveBeenCalled();
} );

it( 'should correctly update the value when the query evaluation matches', async () => {
// First render.
global.matchMedia.mockReturnValueOnce( {
addListener,
removeListener,
matches: true,
} );
// The query within useEffect.
global.matchMedia.mockReturnValueOnce( {
addListener,
removeListener,
matches: true,
} );
global.matchMedia.mockReturnValueOnce( {
addListener,
removeListener,
matches: true,
} );
global.matchMedia.mockReturnValueOnce( {
addListener,
removeListener,
matches: false,
} );

const { container, unmount } = render(
const { container } = render(
<TestComponent query="(min-width: 782px)" />
);

expect( container ).toHaveTextContent( 'useMediaQuery: true' );

let updateMatchFunction;
await act( async () => {
updateMatchFunction = addListener.mock.calls[ 0 ][ 0 ];
updateMatchFunction();
act( () => {
setMedia( {
width: '600px',
} );
} );

expect( container ).toHaveTextContent( 'useMediaQuery: false' );

unmount();

expect( removeListener ).toHaveBeenCalledWith( updateMatchFunction );
} );

it( 'should return false when the query does not matches', async () => {
global.matchMedia.mockReturnValue( {
addListener,
removeListener,
matches: false,
} );

const { container, unmount } = render(
<TestComponent query="(min-width: 782px)" />
const { container } = render(
<TestComponent query="(max-width: 782px)" />
);

expect( container ).toHaveTextContent( 'useMediaQuery: false' );

unmount();

expect( removeListener ).toHaveBeenCalled();
} );

it( 'should not call matchMedia if a query is not passed', async () => {
global.matchMedia.mockReturnValue( {
addListener,
removeListener,
matches: false,
} );

const { container, rerender, unmount } = render( <TestComponent /> );
it( 'should return false when a query is not passed', async () => {
const { container, rerender } = render( <TestComponent /> );

// Query will be case to a boolean to simplify the return type.
expect( container ).toHaveTextContent( 'useMediaQuery: false' );

rerender( <TestComponent query={ false } /> );

expect( container ).toHaveTextContent( 'useMediaQuery: false' );

unmount();
expect( global.matchMedia ).not.toHaveBeenCalled();
expect( addListener ).not.toHaveBeenCalled();
expect( removeListener ).not.toHaveBeenCalled();
} );
} );

1 comment on commit 8ac3b00

@github-actions
Copy link

Choose a reason for hiding this comment

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

Flaky tests detected in 8ac3b00.
Some tests passed with failed attempts. The failures may not be related to this commit but are still reported for visibility. See the documentation for more information.

🔍 Workflow run URL: https://github.com/WordPress/gutenberg/actions/runs/4434495818
📝 Reported issues:

Please sign in to comment.