From c3bb6de49c6f0d58a94cf00b746c6e3f0278ad96 Mon Sep 17 00:00:00 2001 From: Vincent van der Wal Date: Thu, 6 Aug 2026 21:10:15 +0200 Subject: [PATCH] home loading bug and flashes fixes --- messages/de.json | 1 + messages/en.json | 1 + messages/es.json | 1 + messages/fr.json | 1 + messages/it.json | 1 + src/lib/stores/page-transition.svelte.ts | 10 ++ src/lib/utils/day-swap.ts | 28 ++- src/lib/utils/view-transition.ts | 72 ++++++++ src/routes/+layout.svelte | 210 +++++++++++++++-------- src/routes/layout.css | 46 +++-- 10 files changed, 256 insertions(+), 115 deletions(-) create mode 100644 src/lib/utils/view-transition.ts diff --git a/messages/de.json b/messages/de.json index ac30e78..f33792c 100644 --- a/messages/de.json +++ b/messages/de.json @@ -261,6 +261,7 @@ "table_now": "Jetzt", "interval_toggle": "Zwischen 1- und 3-Stunden-Intervall wechseln", "page_loading": "Wird geladen…", + "page_loading_dismiss": "Schließen", "charts_loading": "Diagramme werden geladen…", "chart_download": "Meteogramm als PNG-Bild herunterladen", "chart_credit_viz": "Visualisierung von", diff --git a/messages/en.json b/messages/en.json index 1475299..86c2c41 100644 --- a/messages/en.json +++ b/messages/en.json @@ -261,6 +261,7 @@ "table_now": "Now", "interval_toggle": "Toggle between 1-hour and 3-hour intervals", "page_loading": "Loading…", + "page_loading_dismiss": "Dismiss", "charts_loading": "Loading charts...", "chart_download": "Download meteogram as PNG image", "chart_credit_viz": "visualisation by", diff --git a/messages/es.json b/messages/es.json index 5186393..d6e6270 100644 --- a/messages/es.json +++ b/messages/es.json @@ -261,6 +261,7 @@ "table_now": "Ahora", "interval_toggle": "Alternar entre intervalos de 1 y 3 horas", "page_loading": "Cargando…", + "page_loading_dismiss": "Descartar", "charts_loading": "Cargando gráficos…", "chart_download": "Descargar el meteograma como imagen PNG", "chart_credit_viz": "visualización de", diff --git a/messages/fr.json b/messages/fr.json index 830dd9d..2414181 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -261,6 +261,7 @@ "table_now": "Maintenant", "interval_toggle": "Basculer entre les intervalles de 1 h et 3 h", "page_loading": "Chargement…", + "page_loading_dismiss": "Fermer", "charts_loading": "Chargement des graphiques…", "chart_download": "Télécharger le météogramme en PNG", "chart_credit_viz": "visualisation par", diff --git a/messages/it.json b/messages/it.json index 3990888..b25c22b 100644 --- a/messages/it.json +++ b/messages/it.json @@ -261,6 +261,7 @@ "table_now": "Ora", "interval_toggle": "Alterna tra intervalli di 1 e 3 ore", "page_loading": "Caricamento…", + "page_loading_dismiss": "Chiudi", "charts_loading": "Caricamento dei grafici…", "chart_download": "Scarica il meteogramma come immagine PNG", "chart_credit_viz": "visualizzazione di", diff --git a/src/lib/stores/page-transition.svelte.ts b/src/lib/stores/page-transition.svelte.ts index b2f89ae..8563a8e 100644 --- a/src/lib/stores/page-transition.svelte.ts +++ b/src/lib/stores/page-transition.svelte.ts @@ -22,6 +22,16 @@ export function markPageLoading(): void { pageContentReady.set(false); } +/** + * The counterpart, for a route that has nothing to wait for (the maps page, the + * legal pages). Without it a navigation away from a page that never resolved + * would leave the flag stuck on "loading", and the layout's overlay would sit on + * top of a page that is perfectly finished. + */ +export function markPageReady(): void { + pageContentReady.set(true); +} + /** * Declare a page's readiness. Pass a getter for "my data has arrived". Only * ever sets the flag - clearing it is the layout's job (see above). diff --git a/src/lib/utils/day-swap.ts b/src/lib/utils/day-swap.ts index 625c8d9..587a705 100644 --- a/src/lib/utils/day-swap.ts +++ b/src/lib/utils/day-swap.ts @@ -1,7 +1,11 @@ import { tick } from 'svelte'; -const prefersReducedMotion = () => - typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches; +import { + isViewTransitionActive, + prefersReducedMotion, + startViewTransition, + supportsViewTransitions +} from './view-transition'; /** * Runs a day change inside a view transition, so the outgoing day is still on @@ -12,9 +16,13 @@ const prefersReducedMotion = () => * `.day-region-*` in routes/layout.css); everything else - the strip, the * header, the page chrome - is pinned by the `day-switch` class so it stays * completely still. + * + * A day switch that lands while a navigation transition is still on screen just + * applies: starting a rival transition would skip the running one and flash the + * page (see view-transition.ts). */ export async function runDayTransition(update: () => void): Promise { - if (prefersReducedMotion() || !document.startViewTransition) { + if (!supportsViewTransitions() || prefersReducedMotion() || isViewTransitionActive()) { update(); return; } @@ -24,20 +32,10 @@ export async function runDayTransition(update: () => void): Promise { try { // Svelte applies the change on the next tick; the transition has to wait // for that before it snapshots the new state. - const transition = document.startViewTransition(async () => { + await startViewTransition(async () => { update(); await tick(); }); - // A throw inside the callback rejects `updateCallbackDone` as well as - // `finished`. Nothing awaits the former, and that unhandled rejection is - // what turns one bad render into an "Uncaught" error on the page - so - // report it here instead and let `finished` drive the flow. - transition.updateCallbackDone.catch((error: unknown) => { - console.error('day transition failed to apply', error); - }); - await transition.finished; - } catch { - /* a superseded transition is fine - the DOM is already up to date */ } finally { root.classList.remove('day-switch'); } @@ -53,7 +51,7 @@ export function daySwap(node: HTMLElement, key: unknown) { const play = () => { // view transitions handle it properly where they exist - if (typeof document.startViewTransition === 'function' || prefersReducedMotion()) return; + if (supportsViewTransitions() || prefersReducedMotion()) return; node.animate([{ opacity: 0.1 }, { opacity: 1 }], { duration: 460, easing: 'cubic-bezier(0.22, 0.61, 0.36, 1)' diff --git a/src/lib/utils/view-transition.ts b/src/lib/utils/view-transition.ts new file mode 100644 index 0000000..b65e32d --- /dev/null +++ b/src/lib/utils/view-transition.ts @@ -0,0 +1,72 @@ +/** + * One view transition at a time. + * + * 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: + * + * - `/` 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. + * + * Letting a second update ride along inside the transition that is already + * running keeps one continuous cross-fade across the whole chain. + */ +type UpdateCallback = () => void | Promise; + +let active: Promise | null = null; + +export const supportsViewTransitions = (): boolean => + typeof document !== 'undefined' && typeof document.startViewTransition === 'function'; + +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; + +/** + * 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) { + return Promise.resolve(update()).then( + () => {}, + (error: unknown) => { + console.error('view transition update failed', error); + } + ); + } + + 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); + }); + + // 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; +} diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index 7f6334b..bfcbc1f 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -6,9 +6,20 @@ import { afterNavigate, onNavigate } from '$app/navigation'; import { page } from '$app/stores'; - import { markPageLoading, pageContentReady } from '$lib/stores/page-transition.svelte'; + import { + markPageLoading, + markPageReady, + pageContentReady + } from '$lib/stores/page-transition.svelte'; import { storedTheme } from '$lib/stores/settings'; + import { + isViewTransitionActive, + prefersReducedMotion, + startViewTransition, + supportsViewTransitions + } from '$lib/utils/view-transition'; + import Footer from '$lib/components/navigation/footer.svelte'; import Header from '$lib/components/navigation/header.svelte'; import WeatherNav from '$lib/components/navigation/weather-nav.svelte'; @@ -30,7 +41,9 @@ const apply = () => { const root = document.documentElement; const dark = theme === 'dark' || (theme === 'system' && mq.matches); - const paint = () => root.classList.toggle('dark', dark); + const paint = () => { + root.classList.toggle('dark', dark); + }; // The very first application is just painting the stored theme - only // an actual switch afterwards is worth cross-fading. @@ -41,9 +54,17 @@ return; } - if (document.startViewTransition) { + if (supportsViewTransitions() && !isViewTransitionActive()) { // one cross-fade of the whole document; component transitions untouched - document.startViewTransition(paint); + void startViewTransition(paint); + return; + } + + // A navigation transition already owns the screen: repaint under it + // rather than skipping it (which would flash), and fall back to the + // colour transition below when the browser has none at all. + if (supportsViewTransitions()) { + paint(); return; } @@ -72,43 +93,67 @@ // yet. Note the order: the overlay has to be in the DOM *before* the // transition captures the incoming state, because a running view transition // freezes the page and nothing painted after that point can appear. + // + // There is deliberately no fade for browsers without view transitions. The + // old fallback dimmed the outgoing page to nothing and brought the new one + // back up, which on a single layer is a flash of the bare background rather + // than a cross-fade. Swapping outright and letting the overlay carry the + // "loading" message is quieter and honest. const READY_HOLD_MS = 350; - const FADE_OUT_MS = 170; // Nothing reports ready when a fetch fails outright, so the overlay needs its // own way out rather than sitting on top of an error message forever. const OVERLAY_CEILING_MS = 15000; - let contentVisible = $state(true); - let revealTimer = 0; - let loadingOverlay = $state(false); - let overlayHoldTimer = 0; let overlayCeilingTimer = 0; + // A view transition freezes the page, so a Svelte in-transition started under + // one cannot play - the overlay would be captured at opacity 0 and pop in + // afterwards. Inside a transition the cross-fade does the fading instead. + let overlayFadesIn = $state(true); - function showOverlay(): void { + function showOverlay(animate: boolean): void { + overlayFadesIn = animate; loadingOverlay = true; clearTimeout(overlayCeilingTimer); overlayCeilingTimer = window.setTimeout(() => (loadingOverlay = false), OVERLAY_CEILING_MS); } function hideOverlay(): void { - clearTimeout(overlayHoldTimer); clearTimeout(overlayCeilingTimer); loadingOverlay = false; } + /** + * Manual dismissal. The overlay reports on a fetch it does not control, so + * "stuck" is always a possibility (a page that never reports ready, a request + * that neither resolves nor rejects) - and the page behind it still works. + * Whatever was loading carries on; only the veil goes. + */ + function dismissOverlay(): void { + hideOverlay(); + } + + function onWindowKeydown(event: KeyboardEvent): void { + if (event.key === 'Escape' && loadingOverlay) dismissOverlay(); + } + /** The page that just mounted has its data: whatever we were waiting for is in. */ $effect(() => { if ($pageContentReady) hideOverlay(); }); - const reducedMotion = () => - typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches; - - // Routes that fetch their own forecast after mounting. Knowing this up front - // is what makes the wait reliable: the layout clears the ready flag before - // the swap rather than trusting the incoming page to have done it. - const DATA_ROUTES = new Set([ + // Routes that are not finished on arrival: the five that fetch a forecast + // after mounting, and the redirect stubs, which render nothing at all and + // bounce to a located URL from `onMount`. Knowing this up front is what makes + // the wait reliable - the layout clears the ready flag before the swap rather + // than trusting the incoming page to have done it. + const PENDING_ROUTES = new Set([ + '/', + '/weather/week', + '/weather/14-day', + '/weather/compare', + '/weather/seasonal', + '/weather/historical', '/weather/week/[location]', '/weather/14-day/[location]', '/weather/compare/[location]', @@ -121,50 +166,63 @@ * period is up - in which case the overlay goes up first, so it is part of * the state the transition is about to snapshot. */ - async function waitForContent(): Promise { + async function waitForContent(underTransition: boolean): Promise { const deadline = Date.now() + READY_HOLD_MS; while (!get(pageContentReady) && Date.now() < deadline) { await new Promise((resolve) => setTimeout(resolve, 20)); } - if (!get(pageContentReady)) showOverlay(); + if (!get(pageContentReady)) showOverlay(!underTransition); // one more frame so the page paints its data before the snapshot is taken await tick(); } + /** + * True when the navigation lands on the route and params the page is already + * showing - the sidebar's home link from the 7-day page it points at, or a + * link that differs only in the query string. + * + * SvelteKit keeps the page component mounted for those, and nothing it holds + * changes: its forecast is already fetched, so the readiness effect it + * registered never re-runs and never re-announces. Clearing the flag for such + * a navigation strands it cleared, and the overlay sits there until its + * ceiling. There is genuinely nothing to wait for, so don't clear it. + */ + function landsOnCurrentPage(navigation: { + from: { route: { id: string | null }; params: Record | null } | null; + to: { route: { id: string | null }; params: Record | null } | null; + }): boolean { + const from = navigation.from; + const to = navigation.to; + if (!from?.route.id || from.route.id !== to?.route.id) return false; + return JSON.stringify(from.params ?? {}) === JSON.stringify(to.params ?? {}); + } + onNavigate((navigation) => { - const loadsData = DATA_ROUTES.has(navigation.to?.route?.id ?? ''); - if (loadsData) markPageLoading(); + const pending = + PENDING_ROUTES.has(navigation.to?.route?.id ?? '') && !landsOnCurrentPage(navigation); + // Either way the flag is set explicitly: leaving a page that never resolved + // for one that has nothing to load would otherwise strand the overlay. + if (pending) markPageLoading(); + else markPageReady(); - if (reducedMotion()) { - // No transition to hang the wait on, so the overlay gets its own timer. - if (loadsData) { - clearTimeout(overlayHoldTimer); - overlayHoldTimer = window.setTimeout(() => { - if (!get(pageContentReady)) showOverlay(); - }, READY_HOLD_MS); - } - return; - } + // `startViewTransition` decides whether a transition is possible at all + // (support, reduced motion, one already on screen) and runs the update + // inline when it is not - so there is exactly one path from here down. + const underTransition = + supportsViewTransitions() && !prefersReducedMotion() && !isViewTransitionActive(); - if (typeof document.startViewTransition === 'function') { - return new Promise((swap) => { - document.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 (loadsData) await waitForContent(); - }); + 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); }); - } - - // No view transitions: fall back to fading out, then back in on arrival. - clearTimeout(revealTimer); - contentVisible = false; - return new Promise((resolve) => setTimeout(resolve, FADE_OUT_MS)); + }); }); let mainEl = $state(null); @@ -176,26 +234,6 @@ if (navigation.type !== 'popstate' && !navigation.to?.url.hash) { mainEl?.scrollTo({ top: 0 }); } - - if (typeof document.startViewTransition === 'function' || reducedMotion()) { - contentVisible = true; - return; - } - const startedAt = Date.now(); - const reveal = () => { - if (get(pageContentReady)) { - contentVisible = true; - return; - } - if (Date.now() - startedAt > READY_HOLD_MS) { - // same trade as above: show the skeleton, and say why it is empty - contentVisible = true; - showOverlay(); - return; - } - revealTimer = window.setTimeout(reveal, 20); - }; - reveal(); }); // the maps page embeds a full-bleed map: no padding, no scrolling @@ -225,15 +263,38 @@ + forecast is still on its way. It is also always dismissable - Escape or the + close button - because a veil nobody can get rid of is worse than no veil, + and the page underneath is perfectly usable either way. --> + + {#if loadingOverlay}
+ +
@@ -297,10 +358,7 @@ {:else} -
+
{@render children()}