Skip to content

Commit

Permalink
Update useBreakpoints to use a single set of event listeners (#12287)
Browse files Browse the repository at this point in the history
Co-authored-by: Aaron Casanova <[email protected]>
  • Loading branch information
jesstelford and aaronccasanova committed Jun 21, 2024
1 parent 902db58 commit 65d7a35
Show file tree
Hide file tree
Showing 3 changed files with 63 additions and 55 deletions.
5 changes: 5 additions & 0 deletions .changeset/nice-lizards-dream.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@shopify/polaris": minor
---

Updated `useBreakpoints` to use a single set of event listeners
103 changes: 50 additions & 53 deletions polaris-react/src/utilities/breakpoints.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,13 +30,13 @@ const noWindowMatches: MediaQueryList = {
function noop() {}

export function navigationBarCollapsed() {
return typeof window === 'undefined'
return isServer
? noWindowMatches
: window.matchMedia(`(max-width: ${Breakpoints.navigationBarCollapsed})`);
}

export function stackedContent() {
return typeof window === 'undefined'
return isServer
? noWindowMatches
: window.matchMedia(`(max-width: ${Breakpoints.stackedContent})`);
}
Expand All @@ -56,40 +56,45 @@ type BreakpointsMatches = {
[DirectionAlias in BreakpointsDirectionAlias]: boolean;
};

const hookCallbacks = new Set<
(breakpointAlias: BreakpointsDirectionAlias, matches: boolean) => void
>();
const breakpointsQueryEntries = getBreakpointsQueryEntries(
themeDefault.breakpoints,
);

function getMatches(
defaults?: UseBreakpointsOptions['defaults'],
/**
* Used to force defaults on initial client side render so they match SSR
* values and hence avoid a Hydration error.
*/
forceDefaults?: boolean,
) {
if (!isServer && !forceDefaults) {
return Object.fromEntries(
breakpointsQueryEntries.map(([directionAlias, query]) => [
directionAlias,
window.matchMedia(query).matches,
]),
) as BreakpointsMatches;
}

if (typeof defaults === 'object' && defaults !== null) {
return Object.fromEntries(
breakpointsQueryEntries.map(([directionAlias]) => [
directionAlias,
defaults[directionAlias] ?? false,
]),
) as BreakpointsMatches;
}
if (!isServer) {
breakpointsQueryEntries.forEach(([breakpointAlias, query]) => {
const eventListener = (event: {matches: boolean}) => {
for (const hookCallback of hookCallbacks) {
hookCallback(breakpointAlias, event.matches);
}
};
const mql = window.matchMedia(query);
if (mql.addListener) {
mql.addListener(eventListener);
} else {
mql.addEventListener('change', eventListener);
}
});
}

function getDefaultMatches(defaults?: UseBreakpointsOptions['defaults']) {
return Object.fromEntries(
breakpointsQueryEntries.map(([directionAlias]) => [
directionAlias,
defaults ?? false,
typeof defaults === 'boolean'
? defaults
: defaults?.[directionAlias] ?? false,
]),
) as BreakpointsMatches;
}

function getLiveMatches() {
return Object.fromEntries(
breakpointsQueryEntries.map(([directionAlias, query]) => [
directionAlias,
window.matchMedia(query).matches,
]),
) as BreakpointsMatches;
}
Expand Down Expand Up @@ -129,36 +134,28 @@ export function useBreakpoints(options?: UseBreakpointsOptions) {
// Later, in the effect, we will call this again on the client side without
// any defaults to trigger a more accurate client side evaluation.
const [breakpoints, setBreakpoints] = useState(
getMatches(options?.defaults, true),
getDefaultMatches(options?.defaults),
);

useIsomorphicLayoutEffect(() => {
const mediaQueryLists = breakpointsQueryEntries.map(([_, query]) =>
window.matchMedia(query),
);

const handler = () => setBreakpoints(getMatches());

mediaQueryLists.forEach((mql) => {
if (mql.addListener) {
mql.addListener(handler);
} else {
mql.addEventListener('change', handler);
}
});

// Trigger the breakpoint recalculation at least once client-side to ensure
// we don't have stale default values from SSR.
handler();
// Now that we're client side, get the real values
setBreakpoints(getLiveMatches());

// Register a callback to set the breakpoints object whenever there's a
// change in the future
const callback = (
breakpointAlias: BreakpointsDirectionAlias,
matches: boolean,
) => {
setBreakpoints((prevBreakpoints) => ({
...prevBreakpoints,
[breakpointAlias]: matches,
}));
};
hookCallbacks.add(callback);

return () => {
mediaQueryLists.forEach((mql) => {
if (mql.removeListener) {
mql.removeListener(handler);
} else {
mql.removeEventListener('change', handler);
}
});
hookCallbacks.delete(callback);
};
}, []);

Expand Down
10 changes: 8 additions & 2 deletions polaris-react/tests/setup/tests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,22 @@ import {matchMedia} from '@shopify/jest-dom-mocks';
import '@shopify/react-testing/matchers';
import './matchers';

// Mock once before test files are imported so uses of `window.matchMedia`
// outside of components still works.
if (typeof window !== 'undefined' && !matchMedia.isMocked()) {
matchMedia.mock();
}

// eslint-disable-next-line jest/require-top-level-describe
beforeEach(() => {
if (typeof window !== 'undefined') {
if (typeof window !== 'undefined' && !matchMedia.isMocked()) {
matchMedia.mock();
}
});

// eslint-disable-next-line jest/require-top-level-describe
afterEach(() => {
if (typeof window !== 'undefined') {
if (typeof window !== 'undefined' && matchMedia.isMocked()) {
matchMedia.restore();
}
destroyAll();
Expand Down

0 comments on commit 65d7a35

Please sign in to comment.