Skip to content

Commit

Permalink
[useMediaQuery][utils] Remove usage of React 18 APIs (#29870)
Browse files Browse the repository at this point in the history
  • Loading branch information
eps1lon authored Nov 25, 2021
1 parent 8c0f63e commit 1f3f078
Show file tree
Hide file tree
Showing 6 changed files with 61 additions and 210 deletions.
5 changes: 5 additions & 0 deletions packages/mui-material/src/Autocomplete/Autocomplete.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -1384,6 +1384,11 @@ describe('<Autocomplete />', () => {
}).toWarnDev([
'returns duplicated headers',
!strictModeDoubleLoggingSupressed && 'returns duplicated headers',
// React 18 Strict Effects run mount effects twice which lead to a cascading update
React.version.startsWith('18') && 'returns duplicated headers',
React.version.startsWith('18') &&
!strictModeDoubleLoggingSupressed &&
'returns duplicated headers',
]);
const options = screen.getAllByRole('option').map((el) => el.textContent);
expect(options).to.have.length(7);
Expand Down
17 changes: 7 additions & 10 deletions packages/mui-material/src/useMediaQuery/useMediaQuery.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,16 +13,13 @@ 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 @@ -120,7 +117,7 @@ describe('useMediaQuery', () => {

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

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

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

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

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

unmount();

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

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

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

it('should observe the media query', () => {
Expand Down
125 changes: 29 additions & 96 deletions packages/mui-material/src/useMediaQuery/useMediaQuery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,24 +26,42 @@ 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 };
}

function useMediaQueryOld(
query: string,
defaultMatches: boolean,
matchMedia: typeof window.matchMedia | null,
ssrMatchMedia: ((query: string) => { matches: boolean }) | null,
noSsr: boolean | undefined,
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,
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 @@ -75,98 +93,13 @@ function useMediaQueryOld(
}
};
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;
}

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 = (React as any).useSyncExternalStore(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 =
(React as any).useSyncExternalStore !== 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
97 changes: 18 additions & 79 deletions packages/mui-utils/src/useId.test.js
Original file line number Diff line number Diff line change
@@ -1,97 +1,36 @@
import * as React from 'react';
import PropTypes from 'prop-types';
import { expect } from 'chai';
import { createRenderer, screen } from 'test/utils';
import { createRenderer } from 'test/utils';
import useId from './useId';

const TestComponent = ({ id: idProp }) => {
const id = useId(idProp);
return <span>{id}</span>;
};

TestComponent.propTypes = {
id: PropTypes.string,
};

describe('useId', () => {
const { render, renderToString } = createRenderer();
const { render } = createRenderer();

it('returns the provided ID', () => {
const TestComponent = ({ id: idProp }) => {
const id = useId(idProp);
return <span data-testid="target" id={id} />;
};
const { hydrate } = renderToString(<TestComponent id="some-id" />);
const { setProps } = hydrate();
const { getByText, setProps } = render(<TestComponent id="some-id" />);

expect(screen.getByTestId('target')).to.have.property('id', 'some-id');
expect(getByText('some-id')).not.to.equal(null);

setProps({ id: 'another-id' });

expect(screen.getByTestId('target')).to.have.property('id', 'another-id');
expect(getByText('another-id')).not.to.equal(null);
});

it("generates an ID if one isn't provided", () => {
const TestComponent = ({ id: idProp }) => {
const id = useId(idProp);
return <span data-testid="target" id={id} />;
};
const { hydrate } = renderToString(<TestComponent />);
const { setProps } = hydrate();
const { getByText, setProps } = render(<TestComponent />);

expect(screen.getByTestId('target').id).not.to.equal('');
expect(getByText(/^mui-[0-9]+$/)).not.to.equal(null);

setProps({ id: 'another-id' });
expect(screen.getByTestId('target')).to.have.property('id', 'another-id');
});

it('can be suffixed', () => {
function Widget() {
const id = useId();
const labelId = `${id}-label`;

return (
<React.Fragment>
<span data-testid="labelable" aria-labelledby={labelId} />
<span data-testid="label" id={labelId}>
Label
</span>
</React.Fragment>
);
}
render(<Widget />);

expect(screen.getByTestId('labelable')).to.have.attr(
'aria-labelledby',
screen.getByTestId('label').id,
);
});

it('can be used in in IDREF attributes', () => {
function Widget() {
const labelPartA = useId();
const labelPartB = useId();

return (
<React.Fragment>
<span data-testid="labelable" aria-labelledby={`${labelPartA} ${labelPartB}`} />
<span data-testid="labelA" id={labelPartA}>
A
</span>
<span data-testid="labelB" id={labelPartB}>
B
</span>
</React.Fragment>
);
}
render(<Widget />);

expect(screen.getByTestId('labelable')).to.have.attr(
'aria-labelledby',
`${screen.getByTestId('labelA').id} ${screen.getByTestId('labelB').id}`,
);
});

it('provides an ID on server in React 18', function test() {
if (React.useId === undefined) {
this.skip();
}
const TestComponent = () => {
const id = useId();
return <span data-testid="target" id={id} />;
};
renderToString(<TestComponent />);

expect(screen.getByTestId('target').id).not.to.equal('');
expect(getByText('another-id')).not.to.equal(null);
});
});
18 changes: 1 addition & 17 deletions packages/mui-utils/src/useId.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import * as React from 'react';

function useRandomId(idOverride?: string): string | undefined {
export default function useId(idOverride?: string): string | undefined {
const [defaultId, setDefaultId] = React.useState(idOverride);
const id = idOverride || defaultId;
React.useEffect(() => {
Expand All @@ -13,19 +13,3 @@ function useRandomId(idOverride?: string): string | undefined {
}, [defaultId]);
return id;
}

/**
*
* @example <div id={useId()} />
* @param idOverride
* @returns {string}
*/
export default function useReactId(idOverride?: string): string | undefined {
// TODO: Remove `React as any` once `useId` is part of stable types.
if ((React as any).useId !== undefined) {
const reactId = (React as any).useId();
return idOverride ?? reactId;
}
// eslint-disable-next-line react-hooks/rules-of-hooks -- `React.useId` is invariant at runtime.
return useRandomId(idOverride);
}
9 changes: 1 addition & 8 deletions scripts/use-react-dist-tag.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,14 +16,7 @@ const { promisify } = require('util');
const exec = promisify(childProcess.exec);

// packages published from the react monorepo using the same version
const reactPackageNames = [
'react',
'react-dom',
'react-is',
'react-test-renderer',
'scheduler',
'use-sync-external-store',
];
const reactPackageNames = ['react', 'react-dom', 'react-is', 'react-test-renderer', 'scheduler'];

async function main(options) {
const { distTag } = options;
Expand Down

0 comments on commit 1f3f078

Please sign in to comment.