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. 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:

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.

`content-visiblity` Without Jittery Scrollbars

Update: After further investigation, an even better solution has presented itself, which is documented in the next post.

The new content-visibility CSS property finally allows browsers to intelligently decide to defer layout and rendering work for content that isn't on-screen. For pages with large DOMs, this can be transformative.

In applications that might otherwise be tempted to adopt large piles of JS to manage viewports, it can be the difference between SPA monoliths and small progressive enhancements to HTML.

One challenge with naive application of content-visibility, though, is the way that it removes elements from the rendered tree once they leave the viewport -- particularly as you scroll downward. If the scroll position depends on elements above the currently viewable content "accordion scrollbars" can dance gleefully as content-visibility: auto does its thing.

To combat this effect, I've added some CSS to this site that optimistically renders the first article on a page but defers others. Given how long my articles are, it's a safe bet this won't usually trigger multiple renders for "above the fold" content on initial pageload (sorry not sorry).

This is coupled with an IntersectionObserver that to unfurls subsequesnt articles as you scroll down.

The snippets:

<style>
/* Defer rendering for the 2nd+ article */
body > main > *+* {
content-visibility: auto;
}
</style>
<script type="module">
let observer = new IntersectionObserver(
(entries, o) => {
entries.forEach((entry) => {
let el = entry.target;
// Not currently in intersection area.
if (entry.intersectionRatio == 0) {
return;
}
// Trigger rendering for elements within
// scroll area that haven't already been
// marked.
if (!el.markedVisible) {
el.attributeStyleMap.set(
"content-visibility",
"visible"
);
el.markedVisible = true;
}
});
},
// Set a rendering "skirt" 50px above
// and 100px below the main scroll area.
{ rootMargin: "50px 0px 100px 0px" }
);

let els =
document.querySelectorAll("body > main > *+*");
els.forEach((el) => { observer.observe(el); });
</script>

With this fix in place content will continue to appear at the bottom (ala "infinite scrolling") as you scroll down, but never vanish from the top or preturb your scroll position in unnatural ways.

Older Posts

Newer Posts