Skip to content

Commit c987cfb

Browse files
authored
[useMediaQuery] Ensure no tearing in React 18 (mui#30655)
1 parent fce6e59 commit c987cfb

File tree

2 files changed

+109
-36
lines changed

2 files changed

+109
-36
lines changed

Diff for: packages/mui-material/src/useMediaQuery/useMediaQuery.test.js

+10-7
Original file line numberDiff line numberDiff line change
@@ -13,13 +13,16 @@ import mediaQuery from 'css-mediaquery';
1313
import { expect } from 'chai';
1414
import { stub } from 'sinon';
1515

16+
const usesUseSyncExternalStore = React.useSyncExternalStore !== undefined;
17+
1618
function createMatchMedia(width, ref) {
1719
const listeners = [];
1820
return (query) => {
1921
const instance = {
2022
matches: mediaQuery.match(query, {
2123
width,
2224
}),
25+
// Mocking matchMedia in Safari < 14 where MediaQueryList doesn't inherit from EventTarget
2326
addListener: (listener) => {
2427
listeners.push(listener);
2528
},
@@ -117,7 +120,7 @@ describe('useMediaQuery', () => {
117120

118121
render(<Test />);
119122
expect(screen.getByTestId('matches').textContent).to.equal('false');
120-
expect(getRenderCountRef.current()).to.equal(2);
123+
expect(getRenderCountRef.current()).to.equal(usesUseSyncExternalStore ? 1 : 2);
121124
});
122125
});
123126

@@ -157,10 +160,10 @@ describe('useMediaQuery', () => {
157160

158161
render(<Test />);
159162
expect(screen.getByTestId('matches').textContent).to.equal('false');
160-
expect(getRenderCountRef.current()).to.equal(2);
163+
expect(getRenderCountRef.current()).to.equal(usesUseSyncExternalStore ? 1 : 2);
161164
});
162165

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

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

202205
unmount();
203206

204207
render(<Test />);
205208
expect(screen.getByTestId('matches').textContent).to.equal('false');
206-
expect(getRenderCountRef.current()).to.equal(2);
209+
expect(getRenderCountRef.current()).to.equal(usesUseSyncExternalStore ? 1 : 2);
207210
});
208211

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

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

234237
it('should observe the media query', () => {

Diff for: packages/mui-material/src/useMediaQuery/useMediaQuery.ts

+99-29
Original file line numberDiff line numberDiff line change
@@ -26,42 +26,24 @@ export type MuiMediaQueryListListener = (event: MuiMediaQueryListEvent) => void;
2626
export interface Options {
2727
defaultMatches?: boolean;
2828
matchMedia?: typeof window.matchMedia;
29+
/**
30+
* This option is kept for backwards compatibility and has no longer any effect.
31+
* It's previous behavior is now handled automatically.
32+
*/
33+
// TODO: Deprecate for v6
2934
noSsr?: boolean;
3035
ssrMatchMedia?: (query: string) => { matches: boolean };
3136
}
3237

33-
export default function useMediaQuery<Theme = unknown>(
34-
queryInput: string | ((theme: Theme) => string),
35-
options: Options = {},
38+
function useMediaQueryOld(
39+
query: string,
40+
defaultMatches: boolean,
41+
matchMedia: typeof window.matchMedia | null,
42+
ssrMatchMedia: ((query: string) => { matches: boolean }) | null,
43+
noSsr: boolean | undefined,
3644
): boolean {
37-
const theme = useTheme<Theme>();
38-
// Wait for jsdom to support the match media feature.
39-
// All the browsers MUI support have this built-in.
40-
// This defensive check is here for simplicity.
41-
// Most of the time, the match media logic isn't central to people tests.
4245
const supportMatchMedia =
4346
typeof window !== 'undefined' && typeof window.matchMedia !== 'undefined';
44-
const {
45-
defaultMatches = false,
46-
matchMedia = supportMatchMedia ? window.matchMedia : null,
47-
noSsr = false,
48-
ssrMatchMedia = null,
49-
} = getThemeProps({ name: 'MuiUseMediaQuery', props: options, theme });
50-
51-
if (process.env.NODE_ENV !== 'production') {
52-
if (typeof queryInput === 'function' && theme === null) {
53-
console.error(
54-
[
55-
'MUI: The `query` argument provided is invalid.',
56-
'You are providing a function without a theme in the context.',
57-
'One of the parent elements needs to use a ThemeProvider.',
58-
].join('\n'),
59-
);
60-
}
61-
}
62-
63-
let query = typeof queryInput === 'function' ? queryInput(theme) : queryInput;
64-
query = query.replace(/^@media( ?)/m, '');
6547

6648
const [match, setMatch] = React.useState(() => {
6749
if (noSsr && supportMatchMedia) {
@@ -93,13 +75,101 @@ export default function useMediaQuery<Theme = unknown>(
9375
}
9476
};
9577
updateMatch();
78+
// TODO: Use `addEventListener` once support for Safari < 14 is dropped
9679
queryList.addListener(updateMatch);
9780
return () => {
9881
active = false;
9982
queryList.removeListener(updateMatch);
10083
};
10184
}, [query, matchMedia, supportMatchMedia]);
10285

86+
return match;
87+
}
88+
89+
// eslint-disable-next-line no-useless-concat -- Workaround for https://github.com/webpack/webpack/issues/14814
90+
const maybeReactUseSyncExternalStore: undefined | any = (React as any)['useSyncExternalStore' + ''];
91+
92+
function useMediaQueryNew(
93+
query: string,
94+
defaultMatches: boolean,
95+
matchMedia: typeof window.matchMedia | null,
96+
ssrMatchMedia: ((query: string) => { matches: boolean }) | null,
97+
): boolean {
98+
const getDefaultSnapshot = React.useCallback(() => defaultMatches, [defaultMatches]);
99+
const getServerSnapshot = React.useMemo(() => {
100+
if (ssrMatchMedia !== null) {
101+
const { matches } = ssrMatchMedia(query);
102+
return () => matches;
103+
}
104+
return getDefaultSnapshot;
105+
}, [getDefaultSnapshot, query, ssrMatchMedia]);
106+
const [getSnapshot, subscribe] = React.useMemo(() => {
107+
if (matchMedia === null) {
108+
return [getDefaultSnapshot, () => () => {}];
109+
}
110+
111+
const mediaQueryList = matchMedia(query);
112+
113+
return [
114+
() => mediaQueryList.matches,
115+
(notify: () => void) => {
116+
// TODO: Use `addEventListener` once support for Safari < 14 is dropped
117+
mediaQueryList.addListener(notify);
118+
return () => {
119+
mediaQueryList.removeListener(notify);
120+
};
121+
},
122+
];
123+
}, [getDefaultSnapshot, matchMedia, query]);
124+
const match = maybeReactUseSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
125+
126+
return match;
127+
}
128+
129+
export default function useMediaQuery<Theme = unknown>(
130+
queryInput: string | ((theme: Theme) => string),
131+
options: Options = {},
132+
): boolean {
133+
const theme = useTheme<Theme>();
134+
// Wait for jsdom to support the match media feature.
135+
// All the browsers MUI support have this built-in.
136+
// This defensive check is here for simplicity.
137+
// Most of the time, the match media logic isn't central to people tests.
138+
const supportMatchMedia =
139+
typeof window !== 'undefined' && typeof window.matchMedia !== 'undefined';
140+
const {
141+
defaultMatches = false,
142+
matchMedia = supportMatchMedia ? window.matchMedia : null,
143+
ssrMatchMedia = null,
144+
noSsr,
145+
} = getThemeProps({ name: 'MuiUseMediaQuery', props: options, theme });
146+
147+
if (process.env.NODE_ENV !== 'production') {
148+
if (typeof queryInput === 'function' && theme === null) {
149+
console.error(
150+
[
151+
'MUI: The `query` argument provided is invalid.',
152+
'You are providing a function without a theme in the context.',
153+
'One of the parent elements needs to use a ThemeProvider.',
154+
].join('\n'),
155+
);
156+
}
157+
}
158+
159+
let query = typeof queryInput === 'function' ? queryInput(theme) : queryInput;
160+
query = query.replace(/^@media( ?)/m, '');
161+
162+
// TODO: Drop `useMediaQueryOld` and use `use-sync-external-store` shim in `useMediaQueryNew` once the package is stable
163+
const useMediaQueryImplementation =
164+
maybeReactUseSyncExternalStore !== undefined ? useMediaQueryNew : useMediaQueryOld;
165+
const match = useMediaQueryImplementation(
166+
query,
167+
defaultMatches,
168+
matchMedia,
169+
ssrMatchMedia,
170+
noSsr,
171+
);
172+
103173
if (process.env.NODE_ENV !== 'production') {
104174
// eslint-disable-next-line react-hooks/rules-of-hooks
105175
React.useDebugValue({ query, match });

0 commit comments

Comments
 (0)