diff --git a/packages/mui-material/src/Autocomplete/Autocomplete.test.js b/packages/mui-material/src/Autocomplete/Autocomplete.test.js index 6b857e753a9e5a..26b9f892d58016 100644 --- a/packages/mui-material/src/Autocomplete/Autocomplete.test.js +++ b/packages/mui-material/src/Autocomplete/Autocomplete.test.js @@ -1384,6 +1384,11 @@ describe('', () => { }).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); diff --git a/packages/mui-material/src/useMediaQuery/useMediaQuery.test.js b/packages/mui-material/src/useMediaQuery/useMediaQuery.test.js index 082f4ea33dc847..0e9e12a5776639 100644 --- a/packages/mui-material/src/useMediaQuery/useMediaQuery.test.js +++ b/packages/mui-material/src/useMediaQuery/useMediaQuery.test.js @@ -13,8 +13,6 @@ 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) => { @@ -22,7 +20,6 @@ function createMatchMedia(width, ref) { matches: mediaQuery.match(query, { width, }), - // Mocking matchMedia in Safari < 14 where MediaQueryList doesn't inherit from EventTarget addListener: (listener) => { listeners.push(listener); }, @@ -120,7 +117,7 @@ describe('useMediaQuery', () => { render(); expect(screen.getByTestId('matches').textContent).to.equal('false'); - expect(getRenderCountRef.current()).to.equal(usesUseSyncExternalStore ? 1 : 2); + expect(getRenderCountRef.current()).to.equal(2); }); }); @@ -160,10 +157,10 @@ describe('useMediaQuery', () => { render(); 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)', { @@ -200,13 +197,13 @@ describe('useMediaQuery', () => { const { unmount } = render(); expect(screen.getByTestId('matches').textContent).to.equal('false'); - expect(getRenderCountRef.current()).to.equal(usesUseSyncExternalStore ? 1 : 2); + expect(getRenderCountRef.current()).to.equal(2); unmount(); render(); 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', () => { @@ -228,10 +225,10 @@ describe('useMediaQuery', () => { const { setProps } = render(); 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', () => { diff --git a/packages/mui-material/src/useMediaQuery/useMediaQuery.ts b/packages/mui-material/src/useMediaQuery/useMediaQuery.ts index bd77ff672f70cb..60038013f960a7 100644 --- a/packages/mui-material/src/useMediaQuery/useMediaQuery.ts +++ b/packages/mui-material/src/useMediaQuery/useMediaQuery.ts @@ -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( + queryInput: string | ((theme: Theme) => string), + options: Options = {}, ): boolean { + const theme = useTheme(); + // 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) { @@ -75,7 +93,6 @@ function useMediaQueryOld( } }; updateMatch(); - // TODO: Use `addEventListener` once support for Safari < 14 is dropped queryList.addListener(updateMatch); return () => { active = false; @@ -83,90 +100,6 @@ function useMediaQueryOld( }; }, [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( - queryInput: string | ((theme: Theme) => string), - options: Options = {}, -): boolean { - const theme = useTheme(); - // 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 }); diff --git a/packages/mui-utils/src/useId.test.js b/packages/mui-utils/src/useId.test.js index a69bd9b5228f75..66f22745cb5ee3 100644 --- a/packages/mui-utils/src/useId.test.js +++ b/packages/mui-utils/src/useId.test.js @@ -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 {id}; +}; + +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 ; - }; - const { hydrate } = renderToString(); - const { setProps } = hydrate(); + const { getByText, setProps } = render(); - 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 ; - }; - const { hydrate } = renderToString(); - const { setProps } = hydrate(); + const { getByText, setProps } = render(); - 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 ( - - - - Label - - - ); - } - render(); - - 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 ( - - - - A - - - B - - - ); - } - render(); - - 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 ; - }; - renderToString(); - - expect(screen.getByTestId('target').id).not.to.equal(''); + expect(getByText('another-id')).not.to.equal(null); }); }); diff --git a/packages/mui-utils/src/useId.ts b/packages/mui-utils/src/useId.ts index 95ca13a8604fc4..a756b4df1fb5b5 100644 --- a/packages/mui-utils/src/useId.ts +++ b/packages/mui-utils/src/useId.ts @@ -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(() => { @@ -13,19 +13,3 @@ function useRandomId(idOverride?: string): string | undefined { }, [defaultId]); return id; } - -/** - * - * @example
- * @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); -} diff --git a/scripts/use-react-dist-tag.js b/scripts/use-react-dist-tag.js index 0a5c0d94cda847..405d4de4831de0 100644 --- a/scripts/use-react-dist-tag.js +++ b/scripts/use-react-dist-tag.js @@ -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;