Files
TREK/client/src/mobile/routeTransition.ts
T

103 lines
4.1 KiB
TypeScript

import { useLayoutEffect, useRef, useState } from 'react';
import { flushSync } from 'react-dom';
import type { Location, NavigationType } from 'react-router';
/**
* Page transitions on the phone, the way a native navigation stack moves: a
* page opened from another slides in from the trailing edge over the one it
* leaves, going back slides it away again, and switching between the main
* sections (the dock's destinations) only cross-fades. Built on the View
* Transitions API, so the browser animates snapshots and the pages themselves
* render as before; mobile.css holds the motion.
*
* The router is a declarative BrowserRouter, whose `viewTransition` option does
* nothing, so App renders <Routes> with the location this hook hands out and
* the hook swaps it inside the transition. Routes load lazily: the swap waits
* (at most SETTLE_MS) until the new page replaced the route fallback, while the
* browser keeps showing the old snapshot, so the skeleton never flashes.
*/
export type RouteTransitionKind = 'forward' | 'back' | 'tab';
// Short on purpose: while it waits the browser shows a frozen snapshot of the old
// page, so on a slow connection a longer wait reads as the app hanging.
const SETTLE_MS = 350;
let skipNext = false;
let current: ViewTransition | null = null;
/** A newer navigation skipped this transition: its promises reject, and that is no error. */
function skippedByNewer(): void {}
/** The edge-swipe back gesture already slid the page away: the next change goes without a transition. */
export function skipNextRouteTransition(): void {
skipNext = true;
}
function depth(pathname: string): number {
return pathname.split('/').filter(Boolean).length;
}
export function transitionKind(from: Location, to: Location, navigationType: NavigationType): RouteTransitionKind {
if (depth(from.pathname) <= 1 && depth(to.pathname) <= 1) return 'tab';
return navigationType === 'POP' ? 'back' : 'forward';
}
function canAnimate(): boolean {
return (
typeof document !== 'undefined' &&
typeof document.startViewTransition === 'function' &&
!window.matchMedia?.('(prefers-reduced-motion: reduce)').matches
);
}
/**
* Resolves once no route fallback is on screen, or after SETTLE_MS. Polled with
* a timer, never requestAnimationFrame: while a view transition waits for its
* update, the browser renders no frames, so a frame callback never came and the
* transition hung until the browser gave up after four seconds, on every
* navigation.
*/
function settled(): Promise<void> {
const began = performance.now();
return new Promise((resolve) => {
const check = () => {
if (!document.querySelector('[data-route-fallback]') || performance.now() - began > SETTLE_MS) resolve();
else window.setTimeout(check, 16);
};
check();
});
}
export function useRouteTransition(location: Location, navigationType: NavigationType, enabled: boolean): Location {
const [shown, setShown] = useState(location);
const shownRef = useRef(location);
useLayoutEffect(() => {
const from = shownRef.current;
if (from.key === location.key) return;
shownRef.current = location;
const skip = skipNext;
skipNext = false;
// A changed query or hash on the same page (a tab inside the trip, a filter) is not a navigation.
if (!enabled || skip || from.pathname === location.pathname || !canAnimate()) {
setShown(location);
return;
}
const root = document.documentElement;
root.dataset.routeTransition = transitionKind(from, location, navigationType);
const transition = document.startViewTransition(async () => {
flushSync(() => setShown(location));
await settled();
});
current = transition;
// A newer navigation skips this one: both promises then reject, which is no error.
transition.ready.catch(skippedByNewer);
void transition.finished.catch(skippedByNewer).finally(() => {
// Only the newest transition clears the direction, or a skipped one would
// take it away from the one that replaced it.
if (current === transition) delete root.dataset.routeTransition;
});
}, [location, navigationType, enabled]);
return enabled ? shown : location;
}