Skip to content
This repository has been archived by the owner on Jan 10, 2025. It is now read-only.

Commit

Permalink
Reduce number of media query event listeners attached to window
Browse files Browse the repository at this point in the history
  • Loading branch information
jesstelford committed Jun 20, 2024
1 parent a098a21 commit ec1c360
Show file tree
Hide file tree
Showing 3 changed files with 115 additions and 8 deletions.
5 changes: 5 additions & 0 deletions .changeset/spicy-poets-relax.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@shopify/react-hooks': patch
---

Perf: Reduce number of media query event listeners attached to window.
65 changes: 58 additions & 7 deletions packages/react-hooks/src/hooks/media.ts
Original file line number Diff line number Diff line change
@@ -1,25 +1,76 @@
import {useState, useEffect, useLayoutEffect} from 'react';

type EffectHook = typeof useEffect | typeof useLayoutEffect;
type Query = string;
type EventListener = (event: {matches: boolean}) => void;
type Callback = (matches: boolean) => void;

const hookCallbacks: {
[x: Query]: {
callbacks: Set<Callback>;
eventListener: EventListener;
matchMedia: MediaQueryList;
};
} = {};

function createUseMediaFactory(useEffectHook: EffectHook) {
return (query: string) => {
return (query: Query) => {
const [match, setMatch] = useState(false);

useEffectHook(() => {
if (!window || !window.matchMedia) {
return;
}

const matchMedia = window.matchMedia(query);
const updateMatch = (event: MediaQueryListEvent) =>
setMatch(event.matches);
// First time we've seen this media query
if (!hookCallbacks[query]) {
hookCallbacks[query] = {
// Each of these callbacks will be executed in order when the event
// fires
callbacks: new Set<Callback>(),
// Will use .matches for subsequent hook calls referencing the same
// query so their initial (client) state is updated correctly.
matchMedia: window.matchMedia(query),

setMatch(matchMedia.matches);
// Setup the event listener for this query
eventListener: (event: {matches: boolean}) => {
for (const hookCallback of hookCallbacks[query].callbacks) {
// Don't allow earlier handlers to block later handlers if they
// throw an error
try {
hookCallback(event.matches);
} catch (error) {
// eslint-disable-next-line no-console
console.error(error);
}
}
},
};

// Connect event listener to window events
hookCallbacks[query].matchMedia.addListener(
hookCallbacks[query].eventListener,
);
}

// Update state when the media query changes
hookCallbacks[query].callbacks.add(setMatch);

// Set the state once when useEffect is called
setMatch(hookCallbacks[query].matchMedia.matches);

matchMedia.addListener(updateMatch);
return () => {
matchMedia.removeListener(updateMatch);
// Don't listen to this query anymore
hookCallbacks[query].callbacks.delete(setMatch);

// Clean up: If there's no one interested in this query anymore, remove
// the event listener from the window.
if (hookCallbacks[query].callbacks.size === 0) {
hookCallbacks[query].matchMedia.removeListener(
hookCallbacks[query].eventListener,
);
delete hookCallbacks[query];
}
};
}, [query]);

Expand Down
53 changes: 52 additions & 1 deletion packages/react-hooks/src/hooks/tests/media.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,26 @@ describe('useMedia and useMediaLayout', () => {
expect(mediaRemoveSpy).toHaveBeenCalled();
});

it('only installs a single listener, shared between hooks', () => {
const media = mediaQueryList({
matches: true,
});
const mediaAddSpy = jest.spyOn(media, 'addListener');
const mediaRemoveSpy = jest.spyOn(media, 'removeListener');

matchMedia.setMedia(() => media);

const mockComponent1 = mount(<MockComponent mediaQuery="print" />);
const mockComponent2 = mount(<MockComponent mediaQuery="print" />);
expect(mediaAddSpy).toHaveBeenCalledTimes(1);

mockComponent1.unmount();
expect(mediaRemoveSpy).not.toHaveBeenCalled();

mockComponent2.unmount();
expect(mediaRemoveSpy).toHaveBeenCalled();
});

it('installs new listeners when mediaQuery used by hook changes', () => {
const media = mediaQueryList({
matches: true,
Expand Down Expand Up @@ -80,7 +100,7 @@ describe('useMedia and useMediaLayout', () => {
expect(mockComponent.text()).toContain('did not match');
});

it('rerenders when the media changes from !match=>match', () => {
it('rerenders single when the media changes from !match=>match', () => {
const media = mediaQueryList({
matches: false,
});
Expand All @@ -105,5 +125,36 @@ describe('useMedia and useMediaLayout', () => {

expect(mockComponent.text()).toContain('matched');
});

it('rerenders multiple components when the media changes from !match=>match', () => {
const media = mediaQueryList({
matches: false,
});
const addListenerSpy = jest.spyOn(media, 'addListener');
matchMedia.setMedia(() => media);

const mockComponent = mount(
<div>

Check failure on line 137 in packages/react-hooks/src/hooks/tests/media.test.tsx

View workflow job for this annotation

GitHub Actions / Test (Node 18, React 18)

replace wrapping div with fragment shorthand

Check failure on line 137 in packages/react-hooks/src/hooks/tests/media.test.tsx

View workflow job for this annotation

GitHub Actions / Test (Node 20, React 18)

replace wrapping div with fragment shorthand
<MockComponent mediaQuery="print" />
<MockComponent mediaQuery="print" />
</div>,
);
expect(mockComponent.text()).toContain('did not match');

expect(addListenerSpy).toHaveBeenCalledTimes(1);
mockComponent.act(() => {
matchMedia.setMedia(() =>
mediaQueryList({
matches: true,
}),
);

// setMedia API does not actually invoke the listeners registered by the hook, so we must invoke manually
const [listener] = addListenerSpy.mock.calls[0];
(listener as any)({...media, matches: true});
});

expect(mockComponent.text()).toMatch(/matched.*matched/);
});
});
});

0 comments on commit ec1c360

Please sign in to comment.