Skip to content
Open
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
34 changes: 34 additions & 0 deletions packages/core/src/hooks/useMediaQuery/useMediaQuery.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
86 changes: 71 additions & 15 deletions packages/core/src/hooks/useMediaQuery/useMediaQuery.ts
Original file line number Diff line number Diff line change
@@ -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<string, MediaQueryListExternalStore>();

/**
* 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
Expand All @@ -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);
};