Infrequently Noted

Alex Russell on browsers, standards, and the process of progress.

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 and, contra a previous version of this post, you absolutely should do that, even if you don't know their natural widths. Assuming the browser will calculate a width for your <article> (e.g.) based on it's container, there's no harm in reserving a very narrow, but suitably tall, place-holder. Code below has been updated to reflect this, simplifying much.


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:

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 ResizeObservers. After sleeping on the problem, a better answer presented itself: leave the flow alone and use IntersectionObservers and ResizeObservers 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. IntersectionObservers and ResizeObservers, 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:

  1. 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.
  2. Reserving some height for each element creates space for content-visibilitys 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, and content-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:

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.