diff --git a/src/lib/utils/day-swap.ts b/src/lib/utils/day-swap.ts index 587a705..ea6aad1 100644 --- a/src/lib/utils/day-swap.ts +++ b/src/lib/utils/day-swap.ts @@ -1,7 +1,7 @@ import { tick } from 'svelte'; import { - isViewTransitionActive, + canStartViewTransition, prefersReducedMotion, startViewTransition, supportsViewTransitions @@ -22,23 +22,21 @@ import { * page (see view-transition.ts). */ export async function runDayTransition(update: () => void): Promise { - if (!supportsViewTransitions() || prefersReducedMotion() || isViewTransitionActive()) { + if (!canStartViewTransition()) { update(); return; } - const root = document.documentElement; - root.classList.add('day-switch'); - try { - // Svelte applies the change on the next tick; the transition has to wait - // for that before it snapshots the new state. - await startViewTransition(async () => { + // Svelte applies the change on the next tick; the transition has to wait for + // that before it snapshots the new state. `day-switch` scopes which regions + // take part (see routes/layout.css) and is cleared for us when it ends. + await startViewTransition( + async () => { update(); await tick(); - }); - } finally { - root.classList.remove('day-switch'); - } + }, + { rootClass: 'day-switch' } + ); } /** diff --git a/src/lib/utils/view-transition.ts b/src/lib/utils/view-transition.ts index b65e32d..fef3238 100644 --- a/src/lib/utils/view-transition.ts +++ b/src/lib/utils/view-transition.ts @@ -1,24 +1,40 @@ /** - * One view transition at a time. + * Central entry point for every view transition in the app. * - * The browser runs exactly one: starting a second while the first is still on - * screen *skips* the first, which drops its frozen snapshot on the spot and - * pops the live DOM into view. That is the whole-screen flash, and overlapping - * transitions are the normal case here rather than an edge case: + * Two things it exists to get right. * - * - `/` and every bare `/weather//` page is a redirect stub that calls - * `goto` from `onMount`, so one click on a nav entry produces two (from the - * root, three) navigations back to back - and the stub renders nothing, so - * the frame it exposes is an empty page; - * - a theme toggle or a day switch can land while a navigation is still - * waiting for its forecast. + * **One transition at a time, but only where it matters.** Starting a + * transition while another is running makes the browser skip the first, and + * what that looks like depends entirely on which phase the first is in: * - * Letting a second update ride along inside the transition that is already - * running keeps one continuous cross-fade across the whole chain. + * - *capturing* - its update callback has not resolved yet, so the screen is + * frozen on the outgoing snapshot. Skipping drops that snapshot on the spot + * and pops the half-updated live DOM into view: the whole-screen flash. + * This phase is common here, because navigation deliberately holds the + * callback open while the new page fetches, and `/` plus every bare + * `/weather//` page is a redirect stub that navigates again from + * `onMount` - one click, two or three navigations. + * - *animating* - the DOM is already in its final state and the pseudo + * elements are playing out. Skipping just finishes them early, landing on + * exactly the state they were heading for. + * + * So a new transition rides along inside the running one only while it is + * capturing; once it is animating, superseding it is the better answer (waiting + * would strand the new update under a stale snapshot until the animation ends). + * + * **A scoping class.** `rootClass` is set on `` for the life of the + * transition, so the stylesheet can tell a page swap from a day switch and pin + * the parts that are identical on both sides (see routes/layout.css). */ type UpdateCallback = () => void | Promise; -let active: Promise | null = null; +interface Options { + /** Class set on `` while the transition runs, for scoping CSS. */ + rootClass?: string; +} + +/** Set while a transition holds the screen frozen on the outgoing snapshot. */ +let capturing: Promise | null = null; export const supportsViewTransitions = (): boolean => typeof document !== 'undefined' && typeof document.startViewTransition === 'function'; @@ -26,20 +42,25 @@ export const supportsViewTransitions = (): boolean => export const prefersReducedMotion = (): boolean => typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches; -/** True while a transition is on screen, from capture until its animation ends. */ -export const isViewTransitionActive = (): boolean => active !== null; +/** + * True while a transition is frozen on its outgoing snapshot - the phase in + * which starting a rival transition would flash the page. + */ +export const isViewTransitionCapturing = (): boolean => capturing !== null; + +/** Whether `startViewTransition` would actually open one right now. */ +export const canStartViewTransition = (): boolean => + supportsViewTransitions() && !prefersReducedMotion() && !isViewTransitionCapturing(); /** * Runs `update` inside a view transition, or straight away when one cannot (or * must not) be started. Resolves once the transition has finished animating - * or as soon as the update is done, when it ran on its own. - * - * Callers that need to know which of the two happened should check - * `isViewTransitionActive()` first; the point of this function is that they - * mostly should not care. */ -export function startViewTransition(update: UpdateCallback): Promise { - if (!supportsViewTransitions() || prefersReducedMotion() || active) { +export function startViewTransition(update: UpdateCallback, options: Options = {}): Promise { + const { rootClass } = options; + + if (!canStartViewTransition()) { return Promise.resolve(update()).then( () => {}, (error: unknown) => { @@ -48,25 +69,33 @@ export function startViewTransition(update: UpdateCallback): Promise { ); } + const root = document.documentElement; + if (rootClass) root.classList.add(rootClass); + const transition = document.startViewTransition(update); // A throw inside the callback rejects `updateCallbackDone` as well as // `finished`. Nothing awaits the former, and an unhandled rejection there is // what turns one bad render into an "Uncaught" error on the page. - transition.updateCallbackDone.catch((error: unknown) => { - console.error('view transition update failed', error); + const captured = transition.updateCallbackDone.then( + () => {}, + (error: unknown) => { + console.error('view transition update failed', error); + } + ); + capturing = captured; + void captured.then(() => { + if (capturing === captured) capturing = null; }); // A superseded transition rejects `finished`; the DOM is already up to date, - // so that is not an error worth surfacing - but the slot has to be freed. - const done = transition.finished.then( - () => {}, - () => {} - ); - active = done; - void done.then(() => { - if (active === done) active = null; - }); - - return done; + // so that is not an error worth surfacing. + return transition.finished + .then( + () => {}, + () => {} + ) + .finally(() => { + if (rootClass) root.classList.remove(rootClass); + }); } diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index bfcbc1f..220e936 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -14,8 +14,7 @@ import { storedTheme } from '$lib/stores/settings'; import { - isViewTransitionActive, - prefersReducedMotion, + canStartViewTransition, startViewTransition, supportsViewTransitions } from '$lib/utils/view-transition'; @@ -54,7 +53,7 @@ return; } - if (supportsViewTransitions() && !isViewTransitionActive()) { + if (canStartViewTransition()) { // one cross-fade of the whole document; component transitions untouched void startViewTransition(paint); return; @@ -206,22 +205,25 @@ else markPageReady(); // `startViewTransition` decides whether a transition is possible at all - // (support, reduced motion, one already on screen) and runs the update + // (support, reduced motion, one already capturing) and runs the update // inline when it is not - so there is exactly one path from here down. - const underTransition = - supportsViewTransitions() && !prefersReducedMotion() && !isViewTransitionActive(); + const underTransition = canStartViewTransition(); return new Promise((swap) => { - void startViewTransition(async () => { - // hand control back so SvelteKit swaps the DOM underneath the - // frozen snapshot of the old page - swap(); - // A superseded navigation (a redirect, or a fast second click) - // rejects this promise; that is not an error worth surfacing, - // and leaving it unhandled shows up as "navigation aborted". - await navigation.complete.catch(() => {}); - if (pending) await waitForContent(underTransition); - }); + void startViewTransition( + async () => { + // hand control back so SvelteKit swaps the DOM underneath the + // frozen snapshot of the old page + swap(); + // A superseded navigation (a redirect, or a fast second click) + // rejects this promise; that is not an error worth surfacing, + // and leaving it unhandled shows up as "navigation aborted". + await navigation.complete.catch(() => {}); + if (pending) await waitForContent(underTransition); + }, + // pins the chrome that is the same on both sides (routes/layout.css) + { rootClass: 'page-switch' } + ); }); }); @@ -315,7 +317,7 @@
-