Skip to content
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 .changeset/usemedia-sync-external-store.md
Original file line number Diff line number Diff line change
@@ -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).
28 changes: 27 additions & 1 deletion packages/react/src/hooks/__tests__/useMedia.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,14 @@ type MediaQueryEventListener = (event: {matches: boolean}) => void

function mockMatchMedia({defaultMatch = false} = {}) {
const listeners = new Set<MediaQueryEventListener>()
// 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
Expand All @@ -29,6 +32,7 @@ function mockMatchMedia({defaultMatch = false} = {}) {

return {
change({matches = false}) {
currentMatches = matches
for (const listener of listeners) {
listener({
matches,
Expand Down Expand Up @@ -72,10 +76,32 @@ describe('useMedia', () => {
return null
}

// `renderToString` uses the server snapshot, which defaults to `false` when
// no `defaultState` is provided.
ReactDOM.renderToString(<TestComponent />)
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(<TestComponent />)

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()

Expand Down
105 changes: 51 additions & 54 deletions packages/react/src/hooks/useMedia.ts
Original file line number Diff line number Diff line change
@@ -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'

Expand All @@ -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(
Comment thread
mattcosta7 marked this conversation as resolved.
typeof window === 'undefined',
'`useMedia` When server side rendering, defaultState should be defined to prevent a hydration mismatches.',
)
Comment thread
mattcosta7 marked this conversation as resolved.

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'
Loading