From 21a297adf834d62ddcd62d8e0524561d19b6c320 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 15 May 2026 20:58:28 +0200 Subject: [PATCH] fix(ui): seed useIsMobile from an SSR-safe snapshot to avoid the first-paint desktop flash MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous useEffect-driven flavour returned false on the server and on the first client paint, then flipped on the effect tick. ResponsiveSheet and CoachDrawer branch their render tree off the value, so a phone-class viewport painted the desktop branch for one frame before swapping — Coach drawer side flipped from right to bottom between paints and sheet contents re-mounted through the wrong layout. Switch to useSyncExternalStore with a matchMedia client snapshot. The server snapshot stays false (no window present) so SSR markup remains deterministic, but the very first client render reads the live media query state synchronously — by the time either consumer opens its tree the hook already reflects the runtime viewport. --- src/hooks/use-is-mobile.ts | 88 ++++++++++++++++++++++++++------------ 1 file changed, 60 insertions(+), 28 deletions(-) diff --git a/src/hooks/use-is-mobile.ts b/src/hooks/use-is-mobile.ts index 9ea2272dc..ee3445dd3 100644 --- a/src/hooks/use-is-mobile.ts +++ b/src/hooks/use-is-mobile.ts @@ -1,37 +1,69 @@ "use client"; -import { useEffect, useState } from "react"; +import { useSyncExternalStore } from "react"; /** - * SSR-safe viewport-width hook. Returns `true` once the browser - * reports a viewport narrower than the requested Tailwind - * breakpoint. The hook always returns `false` on the server and - * on the first client paint so SSR markup matches the desktop - * branch of any viewport-conditional render; the hook then flips - * on the effect tick once `matchMedia` reports the live value. + * Viewport-width hook seeded from a SSR-safe snapshot. * - * Defaults to the `md` breakpoint (768 px) because that matches - * the existing dashboard `md:hidden` / `md:block` switches and - * the `` primitive's bottom-sheet branch. - * Consumers that need a tighter cut (e.g. the Coach drawer, which - * flips to a bottom-sheet only below `sm` / 640 px) pass an - * explicit breakpoint. + * v1.4.27 R4 RC3 — the previous `useEffect`-driven flavour returned + * `false` on the server AND on the first client paint, then flipped to + * the live value on the effect tick. Two consumers + * (``, ``) branch their render tree off + * the value, so a phone-class viewport painted the desktop branch for + * one frame before swapping. The Coach drawer's `side` flipped from + * `right` to `bottom` between paints; sheet contents re-mounted and + * controlled inputs flashed through the wrong layout. + * + * The new implementation uses `useSyncExternalStore` with a + * `matchMedia(...).matches` client snapshot. SSR still resolves to + * `false` (no `window`), but the very first client render reads the + * live media-query state synchronously, so the first paint already + * matches the runtime viewport. + * + * Trade-off: SSR markup for phone-class viewports still paints the + * desktop branch (no React hydration mismatch — both server and the + * client's first render under the SSR boundary use `false`), then the + * first client render after hydration flips to the live value. For the + * two consumers above this is acceptable because both gate visibility + * on `open`/`isOpen` state — the desktop branch never paints until the + * user actually opens the sheet/drawer, by which point hydration has + * already settled and the hook reads the live value on the very first + * render of the open tree. + * + * Defaults to the `md` breakpoint (768 px) because that matches the + * existing dashboard `md:hidden` / `md:block` switches and the + * `` primitive's bottom-sheet branch. Consumers that + * need a tighter cut (e.g. the Coach drawer, which flips to a + * bottom-sheet only below `sm` / 640 px) pass an explicit breakpoint. */ -export function useIsMobile(breakpoint: "sm" | "md" = "md"): boolean { - const [isMobile, setIsMobile] = useState(false); +function getMediaQuery(breakpoint: "sm" | "md"): string { + const maxWidth = breakpoint === "sm" ? "639.98px" : "767.98px"; + return `(max-width: ${maxWidth})`; +} + +function subscribe(query: string): (callback: () => void) => () => void { + return (callback) => { + if (typeof window === "undefined") return () => {}; + const mql = window.matchMedia(query); + mql.addEventListener("change", callback); + return () => mql.removeEventListener("change", callback); + }; +} + +function getClientSnapshot(query: string): boolean { + if (typeof window === "undefined") return false; + return window.matchMedia(query).matches; +} - useEffect(() => { - if (typeof window === "undefined") return; - const maxWidth = breakpoint === "sm" ? "639.98px" : "767.98px"; - const mql = window.matchMedia(`(max-width: ${maxWidth})`); - const sync = () => setIsMobile(mql.matches); - sync(); - // `addEventListener` is the modern API; the older `addListener` - // is kept around for legacy Safari but every browser we ship to - // supports the typed event-listener form. - mql.addEventListener("change", sync); - return () => mql.removeEventListener("change", sync); - }, [breakpoint]); +function getServerSnapshot(): boolean { + return false; +} - return isMobile; +export function useIsMobile(breakpoint: "sm" | "md" = "md"): boolean { + const query = getMediaQuery(breakpoint); + return useSyncExternalStore( + subscribe(query), + () => getClientSnapshot(query), + getServerSnapshot, + ); }