perf : deferred slide rendering + sequential loading by proximity. related to #55
All checks were successful
Deploy / Deploy to Production (push) Successful in 5m26s

Only render the active slide initially. After its critical media (videos)
fires canplaythrough, progressively render remaining slides by distance.
JSON loading is now sequential by proximity instead of all-parallel.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
isUnknown 2026-04-03 11:31:40 +02:00
parent f3ce36b99c
commit 947275544d
3 changed files with 114 additions and 5 deletions

View file

@ -53,6 +53,7 @@
let isReady = $state(false)
let isResizing = $state(false)
let resizeTimer = null
let mediaCheckCleanup = null
// Active la transition seulement après le premier paint à la bonne position.
// Double rAF : le premier laisse passer un paint avec le bon translateX,
@ -63,6 +64,58 @@
}
})
// Détecte quand les médias critiques de la slide active sont prêts,
// puis déclenche le rendu progressif des autres slides.
$effect(() => {
const idx = slides.activeIndex
const all = slides.all
// Cleanup du check précédent
if (mediaCheckCleanup) { mediaCheckCleanup(); mediaCheckCleanup = null }
if (all.length === 0 || !all[idx]?.loaded) return
// Attendre un tick pour que le composant soit monté dans le DOM
const rafId = requestAnimationFrame(() => {
const section = document.querySelector(`[data-slide="${all[idx].id}"]`)
if (!section) { slides.setActiveMediaReady(true); return }
const videos = section.querySelectorAll('video')
if (videos.length === 0) { slides.setActiveMediaReady(true); return }
let settled = false
const settle = () => {
if (settled) return
settled = true
slides.setActiveMediaReady(true)
}
// Fallback timeout
const timeout = setTimeout(settle, 5000)
// Attendre canplaythrough sur toutes les vidéos visibles
let remaining = 0
const onReady = () => { if (--remaining <= 0) settle() }
for (const v of videos) {
// Ignorer les vidéos cachées (display: none)
if (v.offsetParent === null && getComputedStyle(v).display === 'none') continue
if (v.readyState >= 4) continue
remaining++
v.addEventListener('canplaythrough', onReady, { once: true })
}
if (remaining === 0) settle()
mediaCheckCleanup = () => {
clearTimeout(timeout)
for (const v of videos) v.removeEventListener('canplaythrough', onReady)
}
})
mediaCheckCleanup = () => cancelAnimationFrame(rafId)
})
onMount(() => {
const handleResize = () => {
isResizing = true
@ -161,7 +214,7 @@
>
{#each slides.all as slide, i}
<section class="slide" class:active={i === slides.activeIndex} data-slide={slide.id} inert={i !== slides.activeIndex}>
{#if slide.loaded}
{#if slide.loaded && slides.isRenderable(i)}
<svelte:component
this={templates[slide.template] ?? Default}
data={slide.data}

View file

@ -76,10 +76,16 @@ async function loadSlide(path) {
}
}
function loadAllSlidesInBackground(exceptPath) {
slides.all
.filter((s) => s.path !== exceptPath)
.forEach((s) => loadSlide(s.path));
async function loadAllSlidesInBackground(exceptPath) {
const activeIdx = slides.getIndexByPath(exceptPath)
const remaining = slides.all
.map((s, i) => ({ path: s.path, distance: Math.abs(i - activeIdx) }))
.filter(s => s.path !== exceptPath)
.sort((a, b) => a.distance - b.distance)
for (const { path } of remaining) {
await loadSlide(path)
}
}
export function slideTo(path, { skipHistory = false } = {}) {

View file

@ -3,20 +3,64 @@ let activeIndex = $state(0)
let pendingPath = $state(null)
let standaloneData = $state(null)
// --- Deferred rendering ---
let renderableSet = $state(new Set())
let activeMediaReady = $state(false)
let progressiveTimers = []
function getIndexByPath(path) {
return slidesData.findIndex(s => s.path === path)
}
function renderProgressively() {
progressiveTimers.forEach(clearTimeout)
progressiveTimers = []
const total = slidesData.length
if (total === 0) return
// Build list sorted by distance from active, excluding already renderable
const toRender = []
for (let dist = 1; dist < total; dist++) {
for (const i of [activeIndex - dist, activeIndex + dist]) {
if (i >= 0 && i < total && !renderableSet.has(i)) {
toRender.push(i)
}
}
}
toRender.forEach((idx, order) => {
const timer = setTimeout(() => {
renderableSet = new Set([...renderableSet, idx])
}, (order + 1) * 200)
progressiveTimers.push(timer)
})
}
export const slides = {
get all() { return slidesData },
get activeIndex() { return activeIndex },
get active() { return slidesData[activeIndex] ?? null },
get pendingPath() { return pendingPath },
get standalone() { return standaloneData },
get activeMediaReady() { return activeMediaReady },
setStandalone(data) { standaloneData = data },
clearStandalone() { standaloneData = null },
isRenderable(i) { return renderableSet.has(i) },
setActiveMediaReady(ready) {
activeMediaReady = ready
if (ready) renderProgressively()
},
markRenderable(i) {
if (!renderableSet.has(i)) {
renderableSet = new Set([...renderableSet, i])
}
},
init(siteNavigation) {
slidesData = siteNavigation.map(nav => ({
id: nav.id,
@ -56,6 +100,8 @@ export const slides = {
if (idx === -1) return
if (slidesData[idx].loaded) {
activeIndex = idx
activeMediaReady = false
renderableSet = new Set([...renderableSet, idx])
pendingPath = null
} else {
pendingPath = path
@ -64,6 +110,8 @@ export const slides = {
setActiveIndex(idx) {
activeIndex = idx
activeMediaReady = false
renderableSet = new Set([...renderableSet, idx])
},
resolvePending() {
@ -71,6 +119,8 @@ export const slides = {
const idx = getIndexByPath(pendingPath)
if (idx !== -1 && slidesData[idx].loaded) {
activeIndex = idx
activeMediaReady = false
renderableSet = new Set([...renderableSet, idx])
pendingPath = null
}
},