diff --git a/packages/core/src/hooks/useMediaQuery/useMediaQuery.test.ts b/packages/core/src/hooks/useMediaQuery/useMediaQuery.test.ts index 1c46b593..502a08dc 100644 --- a/packages/core/src/hooks/useMediaQuery/useMediaQuery.test.ts +++ b/packages/core/src/hooks/useMediaQuery/useMediaQuery.test.ts @@ -86,3 +86,37 @@ it('Should cleanup up on unmount', () => { expect(mockRemoveEventListener).toHaveBeenCalledWith('change', expect.any(Function)); }); + +it('Should call addEventListener/removeEventListener once for same query', () => { + const hook1 = renderHook(() => useMediaQuery('(max-width: 768px)')); + const hook2 = renderHook(() => useMediaQuery('(max-width: 768px)')); + const hook3 = renderHook(() => useMediaQuery('(max-width: 768px)')); + + expect(mockAddEventListener).toHaveBeenCalledTimes(1); + + hook1.unmount(); + hook2.unmount(); + hook3.unmount(); + + expect(mockRemoveEventListener).toHaveBeenCalledTimes(1); +}); + +it('Should call addEventListener/removeEventListener for each unique query', () => { + const hook1 = renderHook(() => useMediaQuery('(max-width: 768px)')); + const hook2 = renderHook(() => useMediaQuery('(max-width: 768px)')); + const hook3 = renderHook(() => useMediaQuery('(max-width: 992px)')); + const hook4 = renderHook(() => useMediaQuery('(max-width: 992px)')); + const hook5 = renderHook(() => useMediaQuery('(max-width: 1024px)')); + const hook6 = renderHook(() => useMediaQuery('(max-width: 1024px)')); + + expect(mockAddEventListener).toHaveBeenCalledTimes(3); + + hook1.unmount(); + hook2.unmount(); + hook3.unmount(); + hook4.unmount(); + hook5.unmount(); + hook6.unmount(); + + expect(mockRemoveEventListener).toHaveBeenCalledTimes(3); +}); diff --git a/packages/core/src/hooks/useMediaQuery/useMediaQuery.ts b/packages/core/src/hooks/useMediaQuery/useMediaQuery.ts index 28ae186f..5e75829d 100644 --- a/packages/core/src/hooks/useMediaQuery/useMediaQuery.ts +++ b/packages/core/src/hooks/useMediaQuery/useMediaQuery.ts @@ -1,7 +1,75 @@ -import { useCallback, useSyncExternalStore } from 'react'; +import { useSyncExternalStore } from 'react'; const getServerSnapshot = () => false; +interface MediaQueryListExternalStore { + getSnapshot: () => boolean; + subscribe: (onStoreChange: () => void) => () => void; +} + +/** + * Module-level cache mapping media query strings to their external stores. + * Ensures a single store instance (and a single `matchMedia` listener) per unique query. + */ +const mediaQueryListExternalStore = new Map(); + +/** + * Creates a new `MediaQueryListStore` for the given query, registers it in the cache, + * and wires up a single `change` listener on the underlying `MediaQueryList`. + * + * The `change` listener is added lazily (on first subscriber) and removed eagerly + * (when the last subscriber unsubscribes), avoiding unnecessary background work. + * + * @param {string} query - The media query string (e.g. `'(max-width: 768px)'`) + * @returns {MediaQueryListStore} A newly created store registered in `mediaQueryListExternalStore` + */ +const createMediaQueryExternalStore = (query: string): MediaQueryListExternalStore => { + const mediaQueryList = window.matchMedia(query); + const listeners = new Set<() => void>(); + + const onChange = () => { + listeners.forEach((listener) => listener()); + }; + + const store: MediaQueryListExternalStore = { + subscribe: (onStoreChange) => { + listeners.add(onStoreChange); + + // Attach the native listener only when the first subscriber arrives, + // so queries with no active consumers never run background work + if (listeners.size === 1) { + mediaQueryList.addEventListener('change', onChange); + } + + return () => { + listeners.delete(onStoreChange); + + // Detach the native listener as soon as the last subscriber leaves, + // allowing the MediaQueryList to be garbage collected if needed + if (listeners.size === 0) { + mediaQueryList.removeEventListener('change', onChange); + } + }; + }, + // Read directly from the retained instance rather than calling + // `window.matchMedia(query)` again, which would create a throwaway object + getSnapshot: () => mediaQueryList.matches + }; + + mediaQueryListExternalStore.set(query, store); + + return store; +}; + +/** + * Returns an existing store for the given query from the cache, or creates and caches a new one. + * Guarantees that `subscribe` and `getSnapshot` always have stable references across re-renders, + * since the store object is created once and reused for the lifetime of the query. + */ +const getMediaQueryExternalStore = (query: string) => { + return mediaQueryListExternalStore.get(query) ?? createMediaQueryExternalStore(query); +}; + /** * @name useMediaQuery * @description - Hook that manages a media query @@ -17,19 +85,7 @@ const getServerSnapshot = () => false; * const matches = useMediaQuery('(max-width: 768px)'); */ export const useMediaQuery = (query: string) => { - const subscribe = useCallback( - (callback: () => void) => { - const matchMedia = window.matchMedia(query); - - matchMedia.addEventListener('change', callback); - return () => { - matchMedia.removeEventListener('change', callback); - }; - }, - [query] - ); - - const getSnapshot = () => window.matchMedia(query).matches; + const store = getMediaQueryExternalStore(query); - return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot); + return useSyncExternalStore(store.subscribe, store.getSnapshot, getServerSnapshot); };