From ea207e103b1a2595a50b2be2b768f5c9bba27ecd Mon Sep 17 00:00:00 2001 From: yuanzonghao Date: Thu, 4 Jun 2026 16:57:34 +0800 Subject: [PATCH] fix(play): lock orientation pre-paint to avoid portrait loading flash Set the session orientation in an isomorphic layout effect so portrait phones don't flash the landscape loading chrome for a frame before the bootstrap effect runs. State still inits to "landscape" for SSR-safety; the correction now lands before first paint (no-op on landscape devices). Addresses Copilot review on PR #31. Co-Authored-By: Claude Opus 4.7 --- app/play/page.tsx | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/app/play/page.tsx b/app/play/page.tsx index 53665df..3bc9578 100644 --- a/app/play/page.tsx +++ b/app/play/page.tsx @@ -6,6 +6,7 @@ import { Suspense, useCallback, useEffect, + useLayoutEffect, useMemo, useRef, useState, @@ -70,6 +71,13 @@ function detectOrientation(): Orientation { return portrait && coarse ? "portrait" : "landscape"; } +// Runs before the browser paints (so it can correct first-frame state without a +// visible flash), but useLayoutEffect warns when called during SSR. PlayInner +// only ever renders on the client (/play prerenders the Suspense fallback), yet +// fall back to useEffect on the server anyway to keep the warning out. +const useIsomorphicLayoutEffect = + typeof window !== "undefined" ? useLayoutEffect : useEffect; + // Cap how long we wait for the browser to download + decode a scene image // before giving up and rendering anyway. Runware's CDN is usually <2s for a // 1792×1024 PNG, but over slow links / VPN / strict corp networks the same @@ -797,6 +805,16 @@ function PlayInner() { }; }, [togglePresentation, presentation]); + // Lock the visible orientation BEFORE the first paint, so portrait phones + // never flash the landscape loading chrome. The state inits to "landscape" + // for SSR-safety; this corrects it pre-paint (no-op re-render on landscape + // devices). Prebaked cards (decision C) stay landscape-baked regardless of + // device. The bootstrap effect below re-derives the same value for the + // /api/start payload. + useIsomorphicLayoutEffect(() => { + setOrientation(params.get("card") ? "landscape" : detectOrientation()); + }, [params]); + // ── Bootstrap: start session ───────────────────────────────────────── useEffect(() => { if (startedRef.current) return; @@ -848,10 +866,11 @@ function PlayInner() { // Lock orientation for the whole session. Prebaked cards (decision C) are // landscape-baked, so they stay landscape regardless of device; only the // live /api/start path requests a portrait paint when the phone is upright. + // The visible state is already set pre-paint by the layout effect above; + // here we only need the value for the /api/start payload. const sessionOrientation: Orientation = cardName ? "landscape" : detectOrientation(); - setOrientation(sessionOrientation); if (livePayload) livePayload.orientation = sessionOrientation; if (!cardName && !livePayload) {