home loading bug and flashes fixes

This commit is contained in:
Vincent van der Wal
2026-08-06 21:10:15 +02:00
parent 1c5ea2b52e
commit c3bb6de49c
10 changed files with 256 additions and 115 deletions
+1
View File
@@ -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",
+1
View File
@@ -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",
+1
View File
@@ -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",
+1
View File
@@ -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",
+1
View File
@@ -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",
+10
View File
@@ -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).
+13 -15
View File
@@ -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<void> {
if (prefersReducedMotion() || !document.startViewTransition) {
if (!supportsViewTransitions() || prefersReducedMotion() || isViewTransitionActive()) {
update();
return;
}
@@ -24,20 +32,10 @@ export async function runDayTransition(update: () => void): Promise<void> {
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)'
+72
View File
@@ -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/<view>/` 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<void>;
let active: Promise<void> | 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<void> {
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;
}
+134 -76
View File
@@ -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<void> {
async function waitForContent(underTransition: boolean): Promise<void> {
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<string, string> | null } | null;
to: { route: { id: string | null }; params: Record<string, string> | 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<void>((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<void>((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<HTMLElement | null>(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 @@
<!-- Page-wide loading veil. Deliberately `pointer-events-none`: it is a status
indicator, not a modal, so the nav and the search stay usable while a slow
forecast is still on its way. -->
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. -->
<svelte:window onkeydown={onWindowKeydown} />
{#if loadingOverlay}
<div
class="pointer-events-none fixed inset-0 z-60 flex items-center justify-center bg-background/55 backdrop-blur-[2px]"
in:fade={{ duration: 120 }}
in:fade={{ duration: overlayFadesIn ? 120 : 0 }}
out:fade={{ duration: 280 }}
role="status"
aria-live="polite"
>
<button
type="button"
class="pointer-events-auto absolute top-3 right-3 flex h-9 w-9 cursor-pointer items-center justify-center rounded-full border border-border bg-card text-muted-foreground shadow-lg transition-colors hover:bg-muted hover:text-foreground md:top-4 md:right-4"
onclick={dismissOverlay}
aria-label={m.page_loading_dismiss()}
title={m.page_loading_dismiss()}
>
<svg
class="h-4 w-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
stroke-width="2"
aria-hidden="true"
>
<path stroke-linecap="round" d="M6 6l12 12M18 6L6 18" />
</svg>
</button>
<div
class="flex items-center gap-2.5 rounded-full border border-border bg-card px-4 py-2 shadow-lg"
>
@@ -297,10 +358,7 @@
{:else}
<!-- cap the content width on very large screens; the footer below
gives the page its ending, so only modest bottom room is needed -->
<div
class="page-fade mx-auto w-full max-w-[1536px] flex-1 pb-24"
class:page-fade-hidden={!contentVisible}
>
<div class="mx-auto w-full max-w-[1536px] flex-1 pb-24">
{@render children()}
</div>
<!-- full-bleed footer inside the scroll area (its own inner max-w),
+22 -24
View File
@@ -133,23 +133,6 @@
@apply bg-background text-foreground;
}
/* Page cross-fade: driven from routes/+layout.svelte, revealed when the new
page reports its data has arrived. */
.page-fade {
transition: opacity 320ms ease;
}
.page-fade-hidden {
opacity: 0;
}
@media (prefers-reduced-motion: reduce) {
.page-fade {
transition: none;
}
.page-fade-hidden {
opacity: 1;
}
}
/* ── Day switching ────────────────────────────────────────────────────────
Only the three regions whose content depends on the selected day take part
in the cross-fade. Everything else keeps its pixels: the `day-switch`
@@ -210,15 +193,30 @@
}
}
/* Theme changes cross-fade the whole document in one pass (see
routes/+layout.svelte). Doing it as a view transition instead of a blanket
`* { transition }` matters: that blanket rule also stretched every
/* Whole-document cross-fade, for both a page swap and a theme change (see
routes/+layout.svelte). Doing the theme as a view transition instead of a
blanket `* { transition }` matters: that blanket rule also stretched every
component's own hover and focus transitions to 400ms for the duration of
the switch, which read as lag on interactive controls. */
::view-transition-old(root),
the switch, which read as lag on interactive controls.
The default cross-fade is NOT opacity-neutral. Both snapshots are opaque
and the browser fades one out while fading the other in, so halfway
through, the two together cover only ~75% of the screen and the page
background shows through everything: the whole screen visibly dips, which
reads as a flash. Holding the outgoing snapshot at full opacity and fading
only the incoming one in on top of it (it is painted above) keeps every
frame fully covered. */
::view-transition-old(root) {
animation: none;
opacity: 1;
}
::view-transition-new(root) {
animation-duration: 400ms;
animation-timing-function: ease;
animation: root-fade-in 400ms ease;
}
@keyframes root-fade-in {
from {
opacity: 0;
}
}
/* Fallback for browsers without view transitions: fade the colours only. */