56 lines
2.7 KiB
TypeScript
56 lines
2.7 KiB
TypeScript
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<T>(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<T>(next: T | null, keyOf: (v: T) => string, holdable?: (prev: T) => boolean): { value: T | null; stale: boolean } {
|
|
const [shown, setShown] = useState<T | null>(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 };
|
|
}
|