diff --git a/.changeset/usemedia-sync-external-store.md b/.changeset/usemedia-sync-external-store.md new file mode 100644 index 00000000000..d63dd379fd6 --- /dev/null +++ b/.changeset/usemedia-sync-external-store.md @@ -0,0 +1,5 @@ +--- +'@primer/react': patch +--- + +`useMedia` now reads the live `matchMedia` value on the first client render instead of after an effect, removing a redundant render pass. `defaultState` is only used for the SSR/hydration snapshot (no public API changes). diff --git a/packages/react/src/hooks/__tests__/useMedia.test.tsx b/packages/react/src/hooks/__tests__/useMedia.test.tsx index 2884c18f2da..a2579704eb2 100644 --- a/packages/react/src/hooks/__tests__/useMedia.test.tsx +++ b/packages/react/src/hooks/__tests__/useMedia.test.tsx @@ -8,11 +8,14 @@ type MediaQueryEventListener = (event: {matches: boolean}) => void function mockMatchMedia({defaultMatch = false} = {}) { const listeners = new Set() + // Track the current match state so that reading `matchMedia(query).matches` + // reflects the latest value, mirroring real `MediaQueryList` behavior. + let currentMatches = defaultMatch Object.defineProperty(window, 'matchMedia', { writable: true, value: vi.fn().mockImplementation(query => ({ - matches: defaultMatch, + matches: currentMatches, media: query, onchange: null, addListener: vi.fn(), // deprecated @@ -29,6 +32,7 @@ function mockMatchMedia({defaultMatch = false} = {}) { return { change({matches = false}) { + currentMatches = matches for (const listener of listeners) { listener({ matches, @@ -72,10 +76,32 @@ describe('useMedia', () => { return null } + // `renderToString` uses the server snapshot, which defaults to `false` when + // no `defaultState` is provided. ReactDOM.renderToString() expect(match[0]).toBe(false) }) + it('does not warn about a missing defaultState on the client', () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + + function TestComponent() { + useMedia('(pointer: coarse)') + return null + } + + // On the client (where `window` is defined) the hook reads `matchMedia` + // directly, so the SSR guidance warning must not appear in the browser + // console — including during hydration, when `getServerSnapshot` runs. + render() + + expect(warnSpy).not.toHaveBeenCalledWith( + 'Warning:', + expect.stringContaining('`useMedia` When server side rendering'), + ) + warnSpy.mockRestore() + }) + it('should respond to change in matchMedia values', () => { const {change} = mockMatchMedia() diff --git a/packages/react/src/hooks/useMedia.ts b/packages/react/src/hooks/useMedia.ts index 418949a1e7f..abe36d44ced 100644 --- a/packages/react/src/hooks/useMedia.ts +++ b/packages/react/src/hooks/useMedia.ts @@ -1,5 +1,4 @@ -import React, {useContext, useEffect} from 'react' -import {canUseDOM} from '../utils/environment' +import {useCallback, useContext, useSyncExternalStore} from 'react' import {warning} from '../utils/warning' import {MatchMediaContext} from './MatchMediaContext' @@ -18,71 +17,69 @@ import {MatchMediaContext} from './MatchMediaContext' */ export function useMedia(mediaQueryString: string, defaultState?: boolean) { const features = useContext(MatchMediaContext) - const [matches, setMatches] = React.useState(() => { - if (features[mediaQueryString] !== undefined) { - return features[mediaQueryString] as boolean - } + // When the query is provided through `MatchMedia` context, that value always + // wins and there is nothing external to subscribe to. + const contextValue = features[mediaQueryString] as boolean | undefined - // Prevent a React hydration mismatch when a default value is provided by not defaulting to window.matchMedia(query).matches. - if (defaultState !== undefined) { - return defaultState - } - - if (canUseDOM) { - return window.matchMedia(mediaQueryString).matches - } + const subscribe = useCallback( + (onStoreChange: () => void) => { + if (contextValue !== undefined) { + return () => {} + } - // A default value has not been provided, and you are rendering on the server, warn of a possible hydration mismatch when defaulting to false. - warning( - true, - '`useMedia` When server side rendering, defaultState should be defined to prevent a hydration mismatches.', - ) + const mediaQueryList = window.matchMedia(mediaQueryString) - return false - }) + // Support fallback to `addListener` for broader browser support + // @ts-ignore this is not present in Safari <14 + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + if (mediaQueryList.addEventListener) { + mediaQueryList.addEventListener('change', onStoreChange) + return () => { + mediaQueryList.removeEventListener('change', onStoreChange) + } + } - if (features[mediaQueryString] !== undefined && matches !== features[mediaQueryString]) { - setMatches(features[mediaQueryString] as boolean) - } + mediaQueryList.addListener(onStoreChange) + return () => { + mediaQueryList.removeListener(onStoreChange) + } + }, + [contextValue, mediaQueryString], + ) - useEffect(() => { - // If `mediaQueryString` is present in features through `context` defer to - // the value present instead of checking with matchMedia - if (features[mediaQueryString] !== undefined) { - return + const getSnapshot = useCallback(() => { + if (contextValue !== undefined) { + return contextValue } + return window.matchMedia(mediaQueryString).matches + }, [contextValue, mediaQueryString]) - function listener(event: MediaQueryListEvent) { - setMatches(event.matches) + const getServerSnapshot = useCallback(() => { + if (contextValue !== undefined) { + return contextValue } - const mediaQueryList = window.matchMedia(mediaQueryString) - - // Support fallback to `addListener` for broader browser support - // @ts-ignore this is not present in Safari <14 - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition - if (mediaQueryList.addEventListener) { - mediaQueryList.addEventListener('change', listener) - } else { - mediaQueryList.addListener(listener) + // Prevent a React hydration mismatch when a default value is provided by not + // defaulting to `window.matchMedia(query).matches`. + if (defaultState !== undefined) { + return defaultState } - // Make sure the media query list is in sync with the matches state - // eslint-disable-next-line react-hooks/set-state-in-effect, react-you-might-not-need-an-effect/no-external-store-subscription - setMatches(mediaQueryList.matches) + // A default value has not been provided, and you are rendering on the + // server, warn of a possible hydration mismatch when defaulting to false. + // `getServerSnapshot` also runs on the client during hydration; only warn on + // the actual server so we don't surface this message in the browser console + // (matching the previous behavior, where the client read `matchMedia` and + // stayed quiet). + warning( + typeof window === 'undefined', + '`useMedia` When server side rendering, defaultState should be defined to prevent a hydration mismatches.', + ) - return () => { - // @ts-ignore this is not present in Safari <14 - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition - if (mediaQueryList.addEventListener) { - mediaQueryList.removeEventListener('change', listener) - } else { - mediaQueryList.removeListener(listener) - } - } - }, [features, mediaQueryString]) + return false + }, [contextValue, defaultState]) - return matches + return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot) } export {MatchMedia} from './MatchMedia'