perf : replace loading flash with background preloader, parallel sub-page fetch
All checks were successful
Deploy Production / Deploy to Production (push) Successful in 25s

- Add #preloader div with scrollable-page-background.png as CSS background,
  matching the Svelte bg-fixed image so the transition is imperceptible
  (image is cached by the time Svelte requests it)
- Revert #ssr-content to visually-hidden (clip) for Google Wave 1 crawl
- Prevent scrollbar flash by adding overflow:hidden to html,body inline
  before main.css loads
- Parallel-fetch sub-page data (/blog/slug.json) alongside parent in initRouter
  so Blog/WhitePapers components receive prefetched data on mount — eliminates
  the list→article flash on direct navigation

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
isUnknown 2026-05-25 18:05:06 +02:00
parent 56e4e55791
commit 7ba18dcd9d
6 changed files with 101 additions and 4 deletions

View file

@ -7,7 +7,10 @@
<?php snippet('seo/head') ?>
<style>
html, body { overflow: hidden; }
#app { min-height: 100vh; }
/* Hidden SSR content — in DOM for Google Wave 1 crawl, invisible to users */
#ssr-content {
position: absolute;
width: 1px; height: 1px;
@ -17,6 +20,21 @@
white-space: nowrap;
border: 0;
}
/* Preloader covers the blank #app while JS loads.
Uses the same background image as the Svelte app so the transition
is seamless (image is already cached when Svelte requests it). */
#preloader {
position: fixed;
inset: 0;
z-index: 9999;
background: #000 url('/assets/img/scrollable-page-background.png') center / cover no-repeat;
transition: opacity 0.4s ease;
pointer-events: none;
}
#preloader.preloader-hiding {
opacity: 0;
}
</style>
<!-- Favicon -->
@ -46,3 +64,4 @@
</head>
<body data-template="<?= $page->intendedTemplate() ?>">
<div id="app"></div>
<div id="preloader" aria-hidden="true"></div>

View file

@ -12,6 +12,15 @@
<?php foreach ($page->body()->toBlocks() as $block): ?>
<?php if ($block->type() === 'text'): ?>
<?= $block->content()->text()->value() ?>
<?php elseif ($block->type() === 'heading'): ?>
<<?= $block->content()->level() ?>><?= $block->content()->text() ?></<?= $block->content()->level() ?>>
<?php elseif ($block->type() === 'quote'): ?>
<blockquote><p><?= $block->content()->text() ?></p></blockquote>
<?php elseif ($block->type() === 'image'): ?>
<?php $imgFile = $block->content()->image()->toFile() ?>
<?php if ($imgFile): ?>
<figure><img src="<?= $imgFile->url() ?>" alt="<?= $block->content()->alt() ?>" /></figure>
<?php endif ?>
<?php endif ?>
<?php endforeach ?>
<?php

View file

@ -1,7 +1,7 @@
<script>
import { onMount, untrack } from 'svelte'
import { slides } from '@state/slides.svelte'
import { slideTo } from '@router'
import { slideTo, hidePreloader } from '@router'
import Header from '@components/layout/Header.svelte'
import Cursor from '@components/layout/Cursor.svelte'
@ -95,6 +95,20 @@
}
})
// Hide preloader for direct (non-sub-page) routes once the slide is ready.
// Sub-page routes (blog/slug, livres-blancs/slug) handle it themselves.
$effect(() => {
if (!isReady) return
const pathParts = window.location.pathname.replace(/^\/en/, '').split('/').filter(Boolean)
const isSubPage = pathParts.length >= 2
if (!isSubPage) hidePreloader()
})
// Standalone pages (policy, privacy, etc.)
$effect(() => {
if (slides.standalone) hidePreloader()
})
// Détecte quand les médias critiques de la slide active sont prêts,
// puis déclenche le rendu progressif des autres slides.
$effect(() => {

View file

@ -3,6 +3,20 @@ import { site } from "@state/site.svelte";
import { locale } from "@state/locale.svelte";
let siteInitialized = false;
let prefetchedSubPageData = null;
export function consumePrefetchedSubPage() {
const data = prefetchedSubPageData;
prefetchedSubPageData = null;
return data;
}
export function hidePreloader() {
const el = document.getElementById("preloader");
if (!el) return;
el.classList.add("preloader-hiding");
setTimeout(() => el.remove(), 450);
}
function normalizePath(path) {
const stripped = path.replace(/^\/en(\/|$)/, "$1") || "/";
@ -148,8 +162,23 @@ export async function initRouter() {
const initialPath = normalizePath(window.location.pathname);
// Detect sub-page and start parallel fetch before awaiting the parent
const parentPath = initialPath.replace(/\/[^/]+$/, "");
const isSubPage = !!parentPath && parentPath !== initialPath;
let subPageFetch = null;
if (isSubPage) {
subPageFetch = fetch(`${apiPrefix()}${initialPath}.json`)
.then((r) => (r.ok ? r.json() : null))
.catch(() => null);
}
await loadSlide(initialPath);
if (subPageFetch) {
prefetchedSubPageData = await subPageFetch;
}
const idx = findSlideIndex(initialPath);
if (idx !== -1) {
slides.setActiveIndex(idx);

View file

@ -2,6 +2,7 @@
import { onMount } from 'svelte'
import { slides } from '@state/slides.svelte'
import { locale } from '@state/locale.svelte'
import { consumePrefetchedSubPage, hidePreloader } from '@router'
import Article from '@views/Article.svelte'
import { t } from '@i18n'
@ -83,10 +84,22 @@
}
}
// On mount: check if URL is /blog/slug (direct navigation)
$effect(() => {
if (articleData) hidePreloader()
})
// On mount: use prefetched article data if available, otherwise fetch
onMount(() => {
const slug = getSlugFromUrl()
if (slug) openArticle(slug)
if (slug) {
const prefetched = consumePrefetchedSubPage()
if (prefetched) {
articleData = prefetched
scrollToTop()
} else {
openArticle(slug)
}
}
window.addEventListener('popstate', handlePopState)
return () => {

View file

@ -2,6 +2,7 @@
import { onMount } from 'svelte'
import { slides } from '@state/slides.svelte'
import { locale } from '@state/locale.svelte'
import { consumePrefetchedSubPage, hidePreloader } from '@router'
import WhitePaper from '@views/WhitePaper.svelte'
import { t } from '@i18n'
@ -74,9 +75,21 @@
}
}
$effect(() => {
if (itemData) hidePreloader()
})
onMount(() => {
const slug = getSlugFromUrl() ?? (data?.singleSlug ?? null)
if (slug) openItem(slug)
if (slug) {
const prefetched = consumePrefetchedSubPage()
if (prefetched) {
itemData = prefetched
sectionEl?.scrollTo(0, 0)
} else {
openItem(slug)
}
}
window.addEventListener('popstate', handlePopState)
return () => window.removeEventListener('popstate', handlePopState)