perf(pitch-deck): smooth SDK demo carousel — no blank frames, parallel preload
All checks were successful
Build pitch-deck / build-push-deploy (push) Successful in 1m14s
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / test-go-consent (push) Successful in 34s
CI / test-python-voice (push) Successful in 34s
CI / test-bqas (push) Successful in 31s
All checks were successful
Build pitch-deck / build-push-deploy (push) Successful in 1m14s
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / test-go-consent (push) Successful in 34s
CI / test-python-voice (push) Successful in 34s
CI / test-bqas (push) Successful in 31s
The SDK Live Demo was janky: AnimatePresence mode="wait" unmounted the current Image before mounting the next, so every advance forced a cold fetch and left an empty black frame until the new image decoded. Only the first three screenshots had priority; the rest fetched lazily, so the first pass through the carousel repeatedly stalled. Replaces the single swap-in/swap-out Image with a stack of 23 images layered in an aspect-[1920/1080] container. Cross-fades are now pure CSS opacity on always-mounted nodes, so there is no unmount and no gap. Key details: - priority on the first 3 (triggers <link rel="preload">); loading=eager on the remaining 20 so the browser starts all fetches at mount rather than deferring via IntersectionObserver. - sizes="(max-width: 1024px) 100vw, 1024px" lets next/image serve the actual displayed resolution instead of the 1920 hint — fewer bytes, faster first paint. - Load-gated reveal: a new `shown` state trails `current` until the target image fires onLoadingComplete. If the user clicks ahead of the network, the previous loaded screenshot stays visible — no more black flashes before images arrive. Second pass through the carousel is instant (images are in-cache). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState, useEffect, useCallback } from 'react'
|
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||||
import { motion, AnimatePresence } from 'framer-motion'
|
import { motion, AnimatePresence } from 'framer-motion'
|
||||||
import Image from 'next/image'
|
import Image from 'next/image'
|
||||||
import { Language } from '@/lib/types'
|
import { Language } from '@/lib/types'
|
||||||
@@ -43,6 +43,26 @@ export default function SDKDemoSlide({ lang }: SDKDemoSlideProps) {
|
|||||||
const [fullscreen, setFullscreen] = useState(false)
|
const [fullscreen, setFullscreen] = useState(false)
|
||||||
const [autoPlay, setAutoPlay] = useState(true)
|
const [autoPlay, setAutoPlay] = useState(true)
|
||||||
|
|
||||||
|
// Track which images have actually loaded so we never cross-fade to a blank
|
||||||
|
// frame. While the target image is still fetching, `shown` stays on the
|
||||||
|
// previous loaded one — this eliminates the flash of empty canvas the user
|
||||||
|
// hit on the first pass through the carousel.
|
||||||
|
const loadedRef = useRef<Set<number>>(new Set())
|
||||||
|
const [shown, setShown] = useState(0)
|
||||||
|
|
||||||
|
const handleLoaded = useCallback((idx: number) => {
|
||||||
|
loadedRef.current.add(idx)
|
||||||
|
// If the user is currently waiting on this image, reveal it immediately.
|
||||||
|
// Otherwise the preceding loaded image keeps showing — no blank flash.
|
||||||
|
if (idx === current) setShown(idx)
|
||||||
|
}, [current])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (loadedRef.current.has(current)) {
|
||||||
|
setShown(current)
|
||||||
|
}
|
||||||
|
}, [current])
|
||||||
|
|
||||||
const next = useCallback(() => {
|
const next = useCallback(() => {
|
||||||
setCurrent(i => (i + 1) % SCREENSHOTS.length)
|
setCurrent(i => (i + 1) % SCREENSHOTS.length)
|
||||||
}, [])
|
}, [])
|
||||||
@@ -101,25 +121,31 @@ export default function SDKDemoSlide({ lang }: SDKDemoSlideProps) {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Screenshot */}
|
{/* Screenshot stack — all images mount at once so we can cross-fade
|
||||||
<AnimatePresence mode="wait">
|
between them by toggling opacity. AnimatePresence mode="wait"
|
||||||
<motion.div
|
unmounts before the next mounts, which forces a cold fetch and
|
||||||
key={current}
|
produces a blank frame; the stack avoids both. */}
|
||||||
initial={{ opacity: 0 }}
|
<div className="relative aspect-[1920/1080] bg-black/40">
|
||||||
animate={{ opacity: 1 }}
|
{SCREENSHOTS.map((s, idx) => (
|
||||||
exit={{ opacity: 0 }}
|
<div
|
||||||
transition={{ duration: 0.3 }}
|
key={s.file}
|
||||||
>
|
className="absolute inset-0 transition-opacity duration-300 ease-out"
|
||||||
<Image
|
style={{ opacity: idx === shown ? 1 : 0 }}
|
||||||
src={`/screenshots/${shot.file}`}
|
aria-hidden={idx !== shown}
|
||||||
alt={de ? shot.de : shot.en}
|
>
|
||||||
width={1920}
|
<Image
|
||||||
height={1080}
|
src={`/screenshots/${s.file}`}
|
||||||
className="w-full h-auto"
|
alt={de ? s.de : s.en}
|
||||||
priority={current < 3}
|
fill
|
||||||
/>
|
sizes="(max-width: 1024px) 100vw, 1024px"
|
||||||
</motion.div>
|
className="object-cover"
|
||||||
</AnimatePresence>
|
priority={idx < 3}
|
||||||
|
loading={idx < 3 ? undefined : 'eager'}
|
||||||
|
onLoadingComplete={() => handleLoaded(idx)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Navigation arrows */}
|
{/* Navigation arrows */}
|
||||||
|
|||||||
Reference in New Issue
Block a user