diff --git a/resources/js/hooks/use-media-query.ts b/resources/js/hooks/use-media-query.ts index 339b515dc..951634f55 100644 --- a/resources/js/hooks/use-media-query.ts +++ b/resources/js/hooks/use-media-query.ts @@ -1,14 +1,43 @@ import { useSyncExternalStore } from 'react'; -export function useMediaQuery(query: string): boolean { - return useSyncExternalStore( - (callback) => { - const mql = window.matchMedia(query); - mql.addEventListener('change', callback); +type MediaQueryStore = { + subscribe: (listener: () => void) => () => void; + snapshot: () => boolean; +}; + +const stores = new Map(); +const serverSnapshot = () => false; +const noSubscription = () => () => {}; - return () => mql.removeEventListener('change', callback); +function storeFor(query: string): MediaQueryStore { + if (typeof window === 'undefined') { + return { subscribe: noSubscription, snapshot: serverSnapshot }; + } + + const previous = stores.get(query); + if (previous) { + return previous; + } + + const media = window.matchMedia(query); + const store = { + subscribe: (listener: () => void) => { + media.addEventListener('change', listener); + return () => media.removeEventListener('change', listener); }, - () => window.matchMedia(query).matches, - () => false, + snapshot: () => media.matches, + }; + + stores.set(query, store); + return store; +} + +export function useMediaQuery(query: string): boolean { + const store = storeFor(query); + + return useSyncExternalStore( + store.subscribe, + store.snapshot, + serverSnapshot, ); } diff --git a/tests/Unit/MediaQuery.test.tsx b/tests/Unit/MediaQuery.test.tsx new file mode 100644 index 000000000..1ffbfb5c9 --- /dev/null +++ b/tests/Unit/MediaQuery.test.tsx @@ -0,0 +1,47 @@ +/// +import assert from 'node:assert/strict'; +import * as React from 'react'; +import { afterEach, test, vi } from 'vite-plus/test'; + +type Subscribe = (listener: () => void) => () => void; +const calls: { subscribe: Subscribe; snapshot: () => boolean }[] = []; + +vi.doMock('react', () => ({ + ...React, + useSyncExternalStore: (subscribe: Subscribe, snapshot: () => boolean) => { + calls.push({ subscribe, snapshot }); + return snapshot(); + }, +})); + +afterEach(() => { + vi.unstubAllGlobals(); + calls.length = 0; +}); + +test('uses one media query and stable subscriptions across renders', async () => { + const listeners = new Set<() => void>(); + const matchMedia = vi.fn(() => ({ + matches: false, + addEventListener: (_type: string, listener: () => void) => + listeners.add(listener), + removeEventListener: (_type: string, listener: () => void) => + listeners.delete(listener), + })); + vi.stubGlobal('window', { matchMedia }); + const { useMediaQuery } = await import( + '../../resources/js/hooks/use-media-query' + ); + + assert.equal(useMediaQuery('(min-width: 800px)'), false); + assert.equal(useMediaQuery('(min-width: 800px)'), false); + assert.equal(matchMedia.mock.calls.length, 1); + assert.equal(calls[0].subscribe, calls[1].subscribe); + assert.equal(calls[0].snapshot, calls[1].snapshot); + + const onChange = vi.fn(); + const unsubscribe = calls[0].subscribe(onChange); + assert.equal(listeners.has(onChange), true); + unsubscribe(); + assert.equal(listeners.has(onChange), false); +});