mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 05:42:19 +00:00
fix(ui): seed useIsMobile from an SSR-safe snapshot to avoid the first-paint desktop flash
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.
This commit is contained in:
1 parent
7850976b99
commit
21a297adf8
1 file changed
+60
-28
+60
-28
@@ -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 `<ResponsiveSheet>` 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
|
||||
* (`<ResponsiveSheet>`, `<CoachDrawer>`) 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
|
||||
* `<ResponsiveSheet>` 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<boolean>(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,
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user