home loading bug and flashes fixes
This commit is contained in:
+134
-76
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user