From 52e3b38ec5d9a858275713c9734522a66f74c233 Mon Sep 17 00:00:00 2001 From: Jess Telford Date: Thu, 5 Oct 2023 12:28:44 +1100 Subject: [PATCH] Executing `useBreakpoints` isormophically no longer triggers a Hydration mismatch error or rendering bugs. --- .changeset/fair-walls-sparkle.md | 5 + polaris-react/src/utilities/breakpoints.ts | 23 +++- .../utilities/tests/use-breakpoints.test.tsx | 127 ++++++++++++------ 3 files changed, 113 insertions(+), 42 deletions(-) create mode 100644 .changeset/fair-walls-sparkle.md diff --git a/.changeset/fair-walls-sparkle.md b/.changeset/fair-walls-sparkle.md new file mode 100644 index 00000000000..c03aea0b53d --- /dev/null +++ b/.changeset/fair-walls-sparkle.md @@ -0,0 +1,5 @@ +--- +'@shopify/polaris': patch +--- + +Executing `useBreakpoints` isormophically no longer triggers a Hydration mismatch error or rendering bugs. diff --git a/polaris-react/src/utilities/breakpoints.ts b/polaris-react/src/utilities/breakpoints.ts index a13de8d365e..0816447ef0f 100644 --- a/polaris-react/src/utilities/breakpoints.ts +++ b/polaris-react/src/utilities/breakpoints.ts @@ -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, @@ -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]) => @@ -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) { diff --git a/polaris-react/src/utilities/tests/use-breakpoints.test.tsx b/polaris-react/src/utilities/tests/use-breakpoints.test.tsx index b0883efa6ad..8aead0ccfd4 100644 --- a/polaris-react/src/utilities/tests/use-breakpoints.test.tsx +++ b/polaris-react/src/utilities/tests/use-breakpoints.test.tsx @@ -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(); function MockComponent() { - const breakpoints = useBreakpoints(); - - expect(breakpoints).toMatchObject({ - xsDown: true, - xsOnly: true, - xsUp: true, + renders++; + breakpoints = useBreakpoints({ + 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(); 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, + }); + }); + it('breakpoints-sm', () => { + setMediaWidth('breakpoints-sm'); + let breakpoints; + mount(); + + function MockComponent() { + breakpoints = useBreakpoints(); return null; } + + expect(breakpoints).toMatchObject({ + smDown: true, + smOnly: true, + smUp: true, + }); }); it('breakpoints-md', () => { setMediaWidth('breakpoints-md'); + let breakpoints; mount(); 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(); 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(); 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, + }); }); });