import { useEffect, useLayoutEffect, useState } from "react"; import { flushSync } from "react-dom"; /** Whether the user asked for reduced motion (false outside a browser). */ export function reducedMotion(): boolean { return typeof window !== "undefined" && typeof window.matchMedia === "function" && window.matchMedia("(prefers-reduced-motion: reduce)").matches; } type VtDocument = { startViewTransition?: (update: () => void) => unknown; visibilityState?: string }; /** * Runs a React state update inside a view transition when the browser supports one and motion is allowed, and * directly otherwise. The update is synchronous (flushSync), so the new state is captured at once and nothing * waits on it. The look of the transition is in tokens.css. */ export function viewTransition(update: () => void): void { const doc = (typeof document === "undefined" ? null : document) as VtDocument | null; if (!doc || typeof doc.startViewTransition !== "function" || doc.visibilityState === "hidden" || reducedMotion()) { update(); return; } doc.startViewTransition(() => flushSync(update)); } export type HoldDecision = "show-next" | "hold" | "swap" | "replace"; /** * What `useHeldValue` does with the value on screen (`shown`) when the wanted one is `next` (null while loading): * show it, keep the old one while loading ("hold"), swap with a transition (a different key) or replace it * plainly (the same key reloaded, or an old value that may not be held, such as another course's lesson). */ export function holdDecision(shown: T | null, next: T | null, keyOf: (v: T) => string, holdable: (prev: T) => boolean = () => true): HoldDecision { if (shown === null || shown === next) return "show-next"; if (!holdable(shown)) return "replace"; if (next === null) return "hold"; return keyOf(shown) === keyOf(next) ? "replace" : "swap"; } const useIsoLayoutEffect = typeof window === "undefined" ? useEffect : useLayoutEffect; /** * Keeps the previous value on screen while the next one loads, then swaps to it inside a view transition, so a * page never blanks between two steps. `stale` is true while the old value is shown. */ export function useHeldValue(next: T | null, keyOf: (v: T) => string, holdable?: (prev: T) => boolean): { value: T | null; stale: boolean } { const [shown, setShown] = useState(next); const decision = holdDecision(shown, next, keyOf, holdable); useIsoLayoutEffect(() => { if (decision === "swap") viewTransition(() => setShown(next)); else if (shown !== next && decision !== "hold") setShown(next); }, [next, decision]); const value = decision === "hold" || decision === "swap" ? shown : next; return { value, stale: value !== next }; }