Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Update useBreakpoints to use a single set of event listeners #12287

Merged
merged 1 commit into from
Jun 21, 2024
Merged
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
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
Loading