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

[useMediaQuery][utils] Remove usage of React 18 APIs #29870

Merged
merged 4 commits into from
Nov 25, 2021
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
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