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

Executing useBreakpoints isormophically no longer triggers a Hydration mismatch error or rendering bugs. #10886

Merged
merged 1 commit into from
Oct 5, 2023
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/fair-walls-sparkle.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@shopify/polaris': patch
---

Executing `useBreakpoints` isormophically no longer triggers a Hydration mismatch error or rendering bugs.
23 changes: 20 additions & 3 deletions polaris-react/src/utilities/breakpoints.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,8 +58,15 @@ type BreakpointsMatches = {

const breakpointsQueryEntries = getBreakpointsQueryEntries(breakpoints);

function getMatches(defaults?: UseBreakpointsOptions['defaults']) {
if (!isServer) {
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,
Expand Down Expand Up @@ -115,7 +122,13 @@ export interface UseBreakpointsOptions {
* breakpoints //=> All values will be `true` during SSR
*/
export function useBreakpoints(options?: UseBreakpointsOptions) {
const [breakpoints, setBreakpoints] = useState(getMatches(options?.defaults));
// On SSR, and initial CSR, we force usage of the defaults to avoid a
// hydration mismatch error.
// 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),
);

useIsomorphicLayoutEffect(() => {
const mediaQueryLists = breakpointsQueryEntries.map(([_, query]) =>
Expand All @@ -132,6 +145,10 @@ export function useBreakpoints(options?: UseBreakpointsOptions) {
}
});

// Trigger the breakpoint recalculation at least once client-side to ensure
// we don't have stale default values from SSR.
handler();

return () => {
mediaQueryLists.forEach((mql) => {
if (mql.removeListener) {
Expand Down
127 changes: 88 additions & 39 deletions polaris-react/src/utilities/tests/use-breakpoints.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,89 +18,138 @@ describe('useBreakpoints', () => {
matchMedia.restore();
});

it('breakpoints-xs', () => {
it('initial render uses defaults', () => {
setMediaWidth('breakpoints-xs');
let breakpoints;
let renders = 0;
mount(<MockComponent />);

function MockComponent() {
const breakpoints = useBreakpoints();

expect(breakpoints).toMatchObject({
xsDown: true,
xsOnly: true,
xsUp: true,
renders++;
breakpoints = useBreakpoints({
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The useEffect inside of useBreakpoints will cause this component to render twice during mount, so we assert the initial and subsequent values are what we expect.

defaults: {
mdDown: true,
mdOnly: true,
mdUp: true,
},
});

expect(breakpoints).toMatchObject(
renders === 1
? {
xsDown: false,
xsOnly: false,
xsUp: false,
mdDown: true,
mdOnly: true,
mdUp: true,
}
: {
xsDown: true,
xsOnly: true,
xsUp: true,
mdDown: false,
mdOnly: false,
mdUp: false,
},
);

return null;
}

expect(breakpoints).toMatchObject({
xsDown: true,
xsOnly: true,
xsUp: true,
mdDown: false,
mdOnly: false,
mdUp: false,
});
});

it('breakpoints-sm', () => {
setMediaWidth('breakpoints-sm');
it('breakpoints-xs', () => {
setMediaWidth('breakpoints-xs');
let breakpoints;
mount(<MockComponent />);

function MockComponent() {
const breakpoints = useBreakpoints();
breakpoints = useBreakpoints();
return null;
}

expect(breakpoints).toMatchObject({
smDown: true,
smOnly: true,
smUp: true,
});
expect(breakpoints).toMatchObject({
xsDown: true,
xsOnly: true,
xsUp: true,
});
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Moving the assertions after the mount call (instead of inside it) gives useBreakpoint's useEffect a chance to run, so we're asserting against the final values, not the SSR defaults.

});

it('breakpoints-sm', () => {
setMediaWidth('breakpoints-sm');
let breakpoints;
mount(<MockComponent />);

function MockComponent() {
breakpoints = useBreakpoints();
return null;
}

expect(breakpoints).toMatchObject({
smDown: true,
smOnly: true,
smUp: true,
});
});

it('breakpoints-md', () => {
setMediaWidth('breakpoints-md');
let breakpoints;
mount(<MockComponent />);

function MockComponent() {
const breakpoints = useBreakpoints();

expect(breakpoints).toMatchObject({
mdDown: true,
mdOnly: true,
mdUp: true,
});

breakpoints = useBreakpoints();
return null;
}

expect(breakpoints).toMatchObject({
mdDown: true,
mdOnly: true,
mdUp: true,
});
});

it('breakpoints-lg', () => {
setMediaWidth('breakpoints-lg');
let breakpoints;
mount(<MockComponent />);

function MockComponent() {
const breakpoints = useBreakpoints();

expect(breakpoints).toMatchObject({
lgDown: true,
lgOnly: true,
lgUp: true,
});

breakpoints = useBreakpoints();
return null;
}

expect(breakpoints).toMatchObject({
lgDown: true,
lgOnly: true,
lgUp: true,
});
});

it('breakpoints-xl', () => {
setMediaWidth('breakpoints-xl');
let breakpoints;
mount(<MockComponent />);

function MockComponent() {
const breakpoints = useBreakpoints();

expect(breakpoints).toMatchObject({
xlDown: true,
xlOnly: true,
xlUp: true,
});

breakpoints = useBreakpoints();
return null;
}

expect(breakpoints).toMatchObject({
xlDown: true,
xlOnly: true,
xlUp: true,
});
});
});

Expand Down