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 isReady = $state(false)
let isResizing = $state(false) let isResizing = $state(false)
let resizeTimer = null let resizeTimer = null
let mediaCheckCleanup = null
// Active la transition seulement après le premier paint à la bonne position. // Active la transition seulement après le premier paint à la bonne position.
// Double rAF : le premier laisse passer un paint avec le bon translateX, // 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(() => { onMount(() => {
const handleResize = () => { const handleResize = () => {
isResizing = true isResizing = true
@ -161,7 +214,7 @@
> >
{#each slides.all as slide, i} {#each slides.all as slide, i}
<section class="slide" class:active={i === slides.activeIndex} data-slide={slide.id} inert={i !== slides.activeIndex}> <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 <svelte:component
this={templates[slide.template] ?? Default} this={templates[slide.template] ?? Default}
data={slide.data} data={slide.data}

View file

@ -76,10 +76,16 @@ async function loadSlide(path) {
} }
} }
function loadAllSlidesInBackground(exceptPath) { async function loadAllSlidesInBackground(exceptPath) {
slides.all const activeIdx = slides.getIndexByPath(exceptPath)
.filter((s) => s.path !== exceptPath) const remaining = slides.all
.forEach((s) => loadSlide(s.path)); .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 } = {}) { export function slideTo(path, { skipHistory = false } = {}) {

View file

@ -3,20 +3,64 @@ let activeIndex = $state(0)
let pendingPath = $state(null) let pendingPath = $state(null)
let standaloneData = $state(null) let standaloneData = $state(null)
// --- Deferred rendering ---
let renderableSet = $state(new Set())
let activeMediaReady = $state(false)
let progressiveTimers = []
function getIndexByPath(path) { function getIndexByPath(path) {
return slidesData.findIndex(s => s.path === 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 = { export const slides = {
get all() { return slidesData }, get all() { return slidesData },
get activeIndex() { return activeIndex }, get activeIndex() { return activeIndex },
get active() { return slidesData[activeIndex] ?? null }, get active() { return slidesData[activeIndex] ?? null },
get pendingPath() { return pendingPath }, get pendingPath() { return pendingPath },
get standalone() { return standaloneData }, get standalone() { return standaloneData },
get activeMediaReady() { return activeMediaReady },
setStandalone(data) { standaloneData = data }, setStandalone(data) { standaloneData = data },
clearStandalone() { standaloneData = null }, 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) { init(siteNavigation) {
slidesData = siteNavigation.map(nav => ({ slidesData = siteNavigation.map(nav => ({
id: nav.id, id: nav.id,
@ -56,6 +100,8 @@ export const slides = {
if (idx === -1) return if (idx === -1) return
if (slidesData[idx].loaded) { if (slidesData[idx].loaded) {
activeIndex = idx activeIndex = idx
activeMediaReady = false
renderableSet = new Set([...renderableSet, idx])
pendingPath = null pendingPath = null
} else { } else {
pendingPath = path pendingPath = path
@ -64,6 +110,8 @@ export const slides = {
setActiveIndex(idx) { setActiveIndex(idx) {
activeIndex = idx activeIndex = idx
activeMediaReady = false
renderableSet = new Set([...renderableSet, idx])
}, },
resolvePending() { resolvePending() {
@ -71,6 +119,8 @@ export const slides = {
const idx = getIndexByPath(pendingPath) const idx = getIndexByPath(pendingPath)
if (idx !== -1 && slidesData[idx].loaded) { if (idx !== -1 && slidesData[idx].loaded) {
activeIndex = idx activeIndex = idx
activeMediaReady = false
renderableSet = new Set([...renderableSet, idx])
pendingPath = null pendingPath = null
} }
}, },