Skip to content

Commit

Permalink
[useMediaQuery] Ensure no tearing in React 18
Browse files Browse the repository at this point in the history
  • Loading branch information
eps1lon authored and hbjORbj committed Jan 25, 2022
1 parent 18de7b8 commit 07ef237
Show file tree
Hide file tree
Showing 2 changed files with 109 additions and 36 deletions.
17 changes: 10 additions & 7 deletions packages/mui-material/src/useMediaQuery/useMediaQuery.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,16 @@ import mediaQuery from 'css-mediaquery';
import { expect } from 'chai';
import { stub } from 'sinon';

const usesUseSyncExternalStore = React.useSyncExternalStore !== undefined;

function createMatchMedia(width, ref) {
const listeners = [];
return (query) => {
const instance = {
matches: mediaQuery.match(query, {
width,
}),
// Mocking matchMedia in Safari < 14 where MediaQueryList doesn't inherit from EventTarget
addListener: (listener) => {
listeners.push(listener);
},
Expand Down Expand Up @@ -117,7 +120,7 @@ describe('useMediaQuery', () => {

render(<Test />);
expect(screen.getByTestId('matches').textContent).to.equal('false');
expect(getRenderCountRef.current()).to.equal(2);
expect(getRenderCountRef.current()).to.equal(usesUseSyncExternalStore ? 1 : 2);
});
});

Expand Down Expand Up @@ -157,10 +160,10 @@ describe('useMediaQuery', () => {

render(<Test />);
expect(screen.getByTestId('matches').textContent).to.equal('false');
expect(getRenderCountRef.current()).to.equal(2);
expect(getRenderCountRef.current()).to.equal(usesUseSyncExternalStore ? 1 : 2);
});

it('should render once if the default value does not match the expectation', () => {
it('should render once if the default value does not match the expectation but `noSsr` is enabled', () => {
const getRenderCountRef = React.createRef();
const Test = () => {
const matches = useMediaQuery('(min-width:2000px)', {
Expand Down Expand Up @@ -197,13 +200,13 @@ describe('useMediaQuery', () => {

const { unmount } = render(<Test />);
expect(screen.getByTestId('matches').textContent).to.equal('false');
expect(getRenderCountRef.current()).to.equal(2);
expect(getRenderCountRef.current()).to.equal(usesUseSyncExternalStore ? 1 : 2);

unmount();

render(<Test />);
expect(screen.getByTestId('matches').textContent).to.equal('false');
expect(getRenderCountRef.current()).to.equal(2);
expect(getRenderCountRef.current()).to.equal(usesUseSyncExternalStore ? 1 : 2);
});

it('should be able to change the query dynamically', () => {
Expand All @@ -225,10 +228,10 @@ describe('useMediaQuery', () => {

const { setProps } = render(<Test query="(min-width:2000px)" />);
expect(screen.getByTestId('matches').textContent).to.equal('false');
expect(getRenderCountRef.current()).to.equal(2);
expect(getRenderCountRef.current()).to.equal(usesUseSyncExternalStore ? 1 : 2);
setProps({ query: '(min-width:100px)' });
expect(screen.getByTestId('matches').textContent).to.equal('true');
expect(getRenderCountRef.current()).to.equal(4);
expect(getRenderCountRef.current()).to.equal(usesUseSyncExternalStore ? 2 : 4);
});

it('should observe the media query', () => {
Expand Down
128 changes: 99 additions & 29 deletions packages/mui-material/src/useMediaQuery/useMediaQuery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,42 +26,24 @@ export type MuiMediaQueryListListener = (event: MuiMediaQueryListEvent) => void;
export interface Options {
defaultMatches?: boolean;
matchMedia?: typeof window.matchMedia;
/**
* This option is kept for backwards compatibility and has no longer any effect.
* It's previous behavior is now handled automatically.
*/
// TODO: Deprecate for v6
noSsr?: boolean;
ssrMatchMedia?: (query: string) => { matches: boolean };
}

export default function useMediaQuery<Theme = unknown>(
queryInput: string | ((theme: Theme) => string),
options: Options = {},
function useMediaQueryOld(
query: string,
defaultMatches: boolean,
matchMedia: typeof window.matchMedia | null,
ssrMatchMedia: ((query: string) => { matches: boolean }) | null,
noSsr: boolean | undefined,
): boolean {
const theme = useTheme<Theme>();
// Wait for jsdom to support the match media feature.
// All the browsers MUI support have this built-in.
// This defensive check is here for simplicity.
// Most of the time, the match media logic isn't central to people tests.
const supportMatchMedia =
typeof window !== 'undefined' && typeof window.matchMedia !== 'undefined';
const {
defaultMatches = false,
matchMedia = supportMatchMedia ? window.matchMedia : null,
noSsr = false,
ssrMatchMedia = null,
} = getThemeProps({ name: 'MuiUseMediaQuery', props: options, theme });

if (process.env.NODE_ENV !== 'production') {
if (typeof queryInput === 'function' && theme === null) {
console.error(
[
'MUI: The `query` argument provided is invalid.',
'You are providing a function without a theme in the context.',
'One of the parent elements needs to use a ThemeProvider.',
].join('\n'),
);
}
}

let query = typeof queryInput === 'function' ? queryInput(theme) : queryInput;
query = query.replace(/^@media( ?)/m, '');

const [match, setMatch] = React.useState(() => {
if (noSsr && supportMatchMedia) {
Expand Down Expand Up @@ -93,13 +75,101 @@ export default function useMediaQuery<Theme = unknown>(
}
};
updateMatch();
// TODO: Use `addEventListener` once support for Safari < 14 is dropped
queryList.addListener(updateMatch);
return () => {
active = false;
queryList.removeListener(updateMatch);
};
}, [query, matchMedia, supportMatchMedia]);

return match;
}

// eslint-disable-next-line no-useless-concat -- Workaround for https://github.com/webpack/webpack/issues/14814
const maybeReactUseSyncExternalStore: undefined | any = (React as any)['useSyncExternalStore' + ''];

function useMediaQueryNew(
query: string,
defaultMatches: boolean,
matchMedia: typeof window.matchMedia | null,
ssrMatchMedia: ((query: string) => { matches: boolean }) | null,
): boolean {
const getDefaultSnapshot = React.useCallback(() => defaultMatches, [defaultMatches]);
const getServerSnapshot = React.useMemo(() => {
if (ssrMatchMedia !== null) {
const { matches } = ssrMatchMedia(query);
return () => matches;
}
return getDefaultSnapshot;
}, [getDefaultSnapshot, query, ssrMatchMedia]);
const [getSnapshot, subscribe] = React.useMemo(() => {
if (matchMedia === null) {
return [getDefaultSnapshot, () => () => {}];
}

const mediaQueryList = matchMedia(query);

return [
() => mediaQueryList.matches,
(notify: () => void) => {
// TODO: Use `addEventListener` once support for Safari < 14 is dropped
mediaQueryList.addListener(notify);
return () => {
mediaQueryList.removeListener(notify);
};
},
];
}, [getDefaultSnapshot, matchMedia, query]);
const match = maybeReactUseSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);

return match;
}

export default function useMediaQuery<Theme = unknown>(
queryInput: string | ((theme: Theme) => string),
options: Options = {},
): boolean {
const theme = useTheme<Theme>();
// Wait for jsdom to support the match media feature.
// All the browsers MUI support have this built-in.
// This defensive check is here for simplicity.
// Most of the time, the match media logic isn't central to people tests.
const supportMatchMedia =
typeof window !== 'undefined' && typeof window.matchMedia !== 'undefined';
const {
defaultMatches = false,
matchMedia = supportMatchMedia ? window.matchMedia : null,
ssrMatchMedia = null,
noSsr,
} = getThemeProps({ name: 'MuiUseMediaQuery', props: options, theme });

if (process.env.NODE_ENV !== 'production') {
if (typeof queryInput === 'function' && theme === null) {
console.error(
[
'MUI: The `query` argument provided is invalid.',
'You are providing a function without a theme in the context.',
'One of the parent elements needs to use a ThemeProvider.',
].join('\n'),
);
}
}

let query = typeof queryInput === 'function' ? queryInput(theme) : queryInput;
query = query.replace(/^@media( ?)/m, '');

// TODO: Drop `useMediaQueryOld` and use `use-sync-external-store` shim in `useMediaQueryNew` once the package is stable
const useMediaQueryImplementation =
maybeReactUseSyncExternalStore !== undefined ? useMediaQueryNew : useMediaQueryOld;
const match = useMediaQueryImplementation(
query,
defaultMatches,
matchMedia,
ssrMatchMedia,
noSsr,
);

if (process.env.NODE_ENV !== 'production') {
// eslint-disable-next-line react-hooks/rules-of-hooks
React.useDebugValue({ query, match });
Expand Down

0 comments on commit 07ef237

Please sign in to comment.