Resize-Resilient `content-visibility` Fixes
Update: After hitting a bug related to initial rendering on Android, I've updated the code here and in the snippet to be resilient to browsers deciding (wrongly) to skip rendering the first <article>
in the document.
Update, The Second: After holiday explorations, it turns out that one can, indeed, use contain-intrinsic-size
on elements that aren't laid out yet. Contra previous advice, you absolutely should do that, even if you don't know natural widths yet. Assuming the browser will calculate a width for an <article>
once finally laid out, there's no harm in reserving a narrow but tall place-holder. Code below has been updated to reflect this.
The last post on avoiding rendering work for content out of the viewport with content-visibility
included a partial solution for how to prevent jumpy scrollbars. This approach had a few drawbacks:
- Because
content-visibility: visible
was never removed, layouts inevitably grew slower as more and more content was unfurled by scrolling. - Layouts caused by resize can be particularly slow on large documents as they can cause all text flows to need recalculation. This would not be improved by the previous solution if many articles had been marked
visible
. - Jumping "far" into a document via link or quick scroll could still be jittery.
Much of this was pointed out (if obliquely) in a PR to the ECMAScript spec. One alternative is a "place-keeper" element that grows with rendered content to keep elements that eventually disappear from perturbing scroll position. This was conceptually hacky and I couldn't get it working well.
What should happen if an element in the flow changes its width or height in reaction to the browser lazy-loading content? And what if the scroll direction is up, rather than down?
For a mercifully brief while I also played with absolutely positioning elements on reveal and moving them later. This also smelled, but it got me playing with ResizeObserver
s. After sleeping on the problem, a better answer presented itself: leave the flow alone and use IntersectionObserver
s and ResizeObserver
s to reserve vertical space using CSS's new contain-intrinsic-size
property once elements have been laid out.
I'd dismissed contain-intrinsic-size
for use in the base stylesheet because it's impossible to know widths and heights to reserve. IntersectionObserver
s and ResizeObserver
s, however, guarantee that we can know the bounding boxes of the elements cheaply (post layout) and use the sizing info they provide to reserve space should the system decide to stop laying out elements (setting their height to 0) when they leave the viewport. We can still use contain-intrinsic-size
, however, to reserve a conservative "place-holder" for a few purposes:
- If our element would be content sized, we can give it a narrow
intrinsic-size
width; the browser will still give us the natural width upon first layout. - Reserving some height for each element creates space for
content-visibility
s internal Intersection Observer to interact with. If we don't provide a height (in this case,500px
as a guess), fast scrolls will encounter "bunched" elements upon hitting the bottom, andcontent-visiblity: auto
will respond by laying out all of the elements at once, which isn't what we want. Creating space means we are more likely to lay them out more granularly.
The snippet below works around a Chrome bug with applying content-visibility: auto;
to all <articles>
, forcing initial paint of the first, then allowing it to later be elided. Observers update sizes in reaction to layouts or browser resizing, updating place-holder sizes while allowing the natural flow to be used. Best of all, it acts a progressive enhancement:
<!-- Basic document structure -->
<html>
<head>
<style>
/* Workaround for Chrome bug, part 1
*
* Chunk rendering for all but the first article.
*
* Eventually, this selector will just be:
*
* body > main > article
*
*/
body > main > article + article {
content-visibility: auto;
/* Add vertical space for each "phantom" */
contain-intrinsic-size: 10px 500px;
}
</style>
<head>
<body>
<!-- header elements -->
<main>
<article><!-- ... --></article>
<article><!-- ... --></article>
<article><!-- ... --></article>
<!-- ... -->
<!-- Inline, at the bottom of the document -->
<script type="module">
let eqIsh = (a, b, fuzz=2) => {
return (Math.abs(a - b) <= fuzz);
};
let rectNotEQ = (a, b) => {
return (!eqIsh(a.width, b.width) ||
!eqIsh(a.height, b.height));
};
// Keep a map of elements and the dimensions of
// their place-holders, re-setting the element's
// intrinsic size when we get updated measurements
// from observers.
let spaced = new WeakMap();
// Only call this when known cheap, post layout
let reserveSpace = (el, rect=el.getClientBoundingRect()) => {
let old = spaced.get(el);
// Set intrinsic size to prevent jumping on un-painting:
// https://drafts.csswg.org/css-sizing-4/#intrinsic-size-override
if (!old || rectNotEQ(old, rect)) {
spaced.set(el, rect);
el.attributeStyleMap.set(
"contain-intrinsic-size",
`${rect.width}px ${rect.height}px`
);
}
};
let iObs = new IntersectionObserver(
(entries, o) => {
entries.forEach((entry) => {
// We don't care if the element is intersecting or
// has been laid out as our page structure ensures
// they'll get the right width.
reserveSpace(entry.target,
entry.boundingClientRect);
});
},
{ rootMargin: "500px 0px 500px 0px" }
);
let rObs = new ResizeObserver(
(entries, o) => {
entries.forEach((entry) => {
reserveSpace(entry.target, entry.contentRect);
});
}
);
let articles =
document.querySelectorAll("body > main > article");
articles.forEach((el) => {
iObs.observe(el);
rObs.observe(el);
});
// Workaround for Chrome bug, part 2.
//
// Re-enable browser management of rendering for the
// first article after the first paint. Double-rAF
// to ensure we get called after a layout.
requestAnimationFrame(() => {
requestAnimationFrame(() => {
articles[0].attributeStyleMap.set(
"content-visibility",
"auto"
);
});
});
</script>
This solves most of our previous problems:
- All articles can now take
content-visibility: auto
, removing the need to continually lay out and render the first article when off-screen. content-visibility
functions as a hint, enhancing the page's performance (where supported) without taking responsibility for layout or positioning.- Elements grow and shrink in response to content changes
- Presuming browsers do a good job of scroll anchoring, this solution is robust to both upward and downward scrolls, plus resizing.
Debugging this wouldn't have been possible without help from Vladimir Levin and Una Kravets whose article has been an indispensable reference, laying out the pieces for me to eventually (too-slowly) cobble into a full solution.