Skip to content

Commit 79bed86

Browse files
committed
perf(website): paint a loop's poster without waiting for script, and keep video out of the page load
Lighthouse on the captions page, whose first loop sits in the first screen: the loop's poster is the page's largest paint, and it only appeared once the bundle had hydrated (859 ms render delay), while a 1 MB clip downloaded inside the page load. Desktop LCP went from 0.92 s before the loops to 1.26 s. - The poster is now an <img loading="lazy"> in the server HTML, under the video: the browser fetches it natively near the viewport, before any script, and a reader without JavaScript sees it. - No video source before the window's load event. Desktop LCP 1.26 s -> 1.11 s, mobile back to the pre-loops figure (4.64 s simulated against 4.65 s). Preloading the poster at high priority was tried and dropped: on a throttled phone it pushed LCP to 5.3 s.
1 parent 8e4d926 commit 79bed86

2 files changed

Lines changed: 46 additions & 6 deletions

File tree

‎website/src/components/DemoLoop/index.tsx‎

Lines changed: 36 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,19 @@
44
*
55
* `<video>` has no `loading="lazy"`, so every part of the laziness is done here:
66
*
7-
* - Nothing but an empty 16:9 box is in the server HTML. The sources and the
8-
* poster are attached when the box comes within a screen of the viewport,
9-
* so a reader who never scrolls to a loop downloads none of it, poster
10-
* included (a `poster` attribute in the markup is fetched eagerly).
7+
* - The server HTML holds a 16:9 box and the poster as an <img
8+
* loading="lazy">, not as the video's `poster` attribute, which every
9+
* engine fetches eagerly. The browser loads it natively when the box nears
10+
* the viewport, before any script has run, so a loop at the top of a page
11+
* paints its poster as early as the text around it (it is often the
12+
* page's largest contentful paint), and a reader without JavaScript still
13+
* sees it. It stays lazy even at the top of a page: preloading it at high
14+
* priority was measured, and on a throttled phone it delays the CSS and
15+
* the page's text more than it hurries the poster (LCP 4.6 -> 5.3 s).
16+
* - The video sources are attached when the box comes within a screen of
17+
* the viewport, but never before the page's load event: a loop near the
18+
* top must not compete with the page it sits on (measured: a 1 MB clip
19+
* inside the captions page's load, LCP 0.92 s -> 1.26 s on desktop).
1120
* - Which file is decided at that moment, from the box's real width: 720p for
1221
* a phone column or a 1× display, 1080p when the pixels are there to show
1322
* it. HEVC first, H.264 for engines that cannot decode it.
@@ -90,6 +99,8 @@ export default function DemoLoop({
9099
const box = useRef<HTMLDivElement>(null);
91100
const video = useRef<HTMLVideoElement>(null);
92101
const [height, setHeight] = useState<LoopHeight | null>(null);
102+
// The window's load event, once: no video bytes before it.
103+
const [pageLoaded, setPageLoaded] = useState(false);
93104
const [playing, setPlaying] = useState(false);
94105
// null until the reader decides; then their choice beats visibility.
95106
const [wanted, setWanted] = useState<boolean | null>(null);
@@ -112,7 +123,17 @@ export default function DemoLoop({
112123
return () => mq.removeEventListener("change", onChange);
113124
}, []);
114125

115-
// Attach the poster (and pick the file) once the box is within one screen
126+
useEffect(() => {
127+
if (document.readyState === "complete") {
128+
setPageLoaded(true);
129+
return;
130+
}
131+
const onLoad = () => setPageLoaded(true);
132+
window.addEventListener("load", onLoad, { once: true });
133+
return () => window.removeEventListener("load", onLoad);
134+
}, []);
135+
136+
// Pick the file once the box is within one screen
116137
// height. In pixels: a percentage rootMargin resolves against the root's
117138
// width, which on a portrait phone is well under a screen of lead.
118139
useEffect(() => {
@@ -185,11 +206,19 @@ export default function DemoLoop({
185206
<figure className={styles.figure}>
186207
{schema && <LoopSchema names={[name]} />}
187208
<div ref={box} className={styles.box}>
209+
<img
210+
className={styles.poster}
211+
src={loopPoster(name)}
212+
alt=""
213+
width={1280}
214+
height={720}
215+
loading="lazy"
216+
decoding="async"
217+
/>
188218
<video
189219
ref={video}
190220
className={styles.video}
191221
aria-label={label}
192-
poster={height ? loopPoster(name) : undefined}
193222
muted
194223
loop={loop}
195224
playsInline
@@ -205,6 +234,7 @@ export default function DemoLoop({
205234
the play button: `preload="none"` is only a hint, and some engines
206235
fetch anyway once a source is there. */}
207236
{height &&
237+
pageLoaded &&
208238
(auto || wanted === true) &&
209239
loopSources(name, height).map((s) => <source key={s.src} src={s.src} type={s.type} />)}
210240
</video>

‎website/src/components/DemoLoop/styles.module.css‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,13 +14,23 @@
1414
background: var(--os-surface-1);
1515
}
1616

17+
/* The poster under the video: a native lazy image, in the server HTML. The
18+
video sits on top and covers it from its first frame; until then a <video>
19+
with no poster paints nothing, so the image shows through. */
20+
.poster,
1721
.video {
22+
position: absolute;
23+
inset: 0;
1824
display: block;
1925
width: 100%;
2026
height: 100%;
2127
object-fit: cover;
2228
}
2329

30+
.video {
31+
z-index: 1;
32+
}
33+
2434
/* Top right: the bottom-right corner is where a webcam vignette sits. */
2535
.toggle {
2636
position: absolute;

0 commit comments

Comments
 (0)