Web components that measure

What a composition's DOM answers, as Chrome does: refs' geometry, style and tree, document and window, canvas and svg text measures, Range, ResizeObserver, and the frame held until a measure lands. And what it lacks.

View as Markdown

Many components written for a web page measure before they draw: a title sized to fit its box, captions that wrap at the width a line really takes, a caret placed after the last typed letter. In a composition those reads answer as they do in Chrome, from the same layout and the same font files that draw the frame. Nothing needs importing: the entry installs them on every ref and on document and window.

Measure, then let the frame be taken

A measure runs in an effect, after the first commit. Hold the frame until it's done, so the frame taken is the measured one:

TSX
import { continueRender, delayRender } from "raea";
import { loadFont } from "raea/text";

loadFont("Inter");

export const Fit = ({ text }: { text: string }) => {
  const ref = useRef<HTMLDivElement>(null);
  const [scale, setScale] = useState(1);
  const [handle] = useState(() => delayRender("fit the title"));
  useEffect(() => {
    document.fonts.ready.then(() => {
      setScale(1080 / ref.current!.getBoundingClientRect().width);
      continueRender(handle);
    });
  }, []);
  return <div ref={ref} style={{ display: "inline-block", fontFamily: "Inter", transform: `scale(${scale})` }}>{text}</div>;
};

A hold never released fails the render after 30 s, naming its label (raea/hold). Load a web font with loadFont so the measure uses its own faces; a family you never asked for is measured in raea's bundled faces, as a browser falls back.

What a ref answers

GeometrygetBoundingClientRect() (transforms included), getClientRects(), offsetWidth, offsetHeight, offsetLeft, offsetTop, offsetParent, client*, scroll*. Values are Chrome's, in the composition's CSS px.
Styleel.style reads what React committed. Writing it (a property, setProperty, cssText) restyles the element at once; React's next commit to the same property wins. getComputedStyle(el) answers from the page's computed style.
The treechildren, childNodes, the sibling and parent links, tagName, id, className, classList, dataset, getAttribute, textContent, contains, matches, closest, querySelector and querySelectorAll.
svg textgetComputedTextLength, getNumberOfChars, getStartPositionOfChar, getEndPositionOfChar and getSubStringLength, in the element's computed font, letter- and word-spacing and font-variation-settings included, hidden text too.
canvasgetContext("2d") with measureText (width, the ink's and the font's boxes, shaped from the faces the canvas draws with), letterSpacing and wordSpacing, getImageData and putImageData.
imagesAn <img>'s onLoad fires once it's made or its src changes; the frame still waits for the picture itself.

Selectors cover tag, *, #id, .class, [attr] and [attr=v] (and ~=, ^=, $=, *=), the descendant and child combinators, comma lists, :first-child, :last-child, :only-child, :nth-child() and :not(). Anything else throws a SyntaxError, as Chrome's does.

document and window

  • document.body is the composition's root. getElementById, querySelector and querySelectorAll search it.
  • document.createElement makes an element of the page: appended into the composition it's laid out, measured and drawn, and removed it's gone. That's how a component measures a hidden copy. A canvas made there is an OffscreenCanvas with the same 2D context.
  • document.createRange() over a text node gives the rect of its characters, placed where the line starts. One line only: a range across wrapped lines gets one rect, the width of its characters.
  • document.fonts has ready, load and check; a FontFace given by URL is fetched and its text set in it. One that doesn't load rejects, and the text falls back.
  • window has innerWidth, innerHeight and devicePixelRatio for the composition's size, and getComputedStyle.
  • requestAnimationFrame calls back once per frame drawn, given that frame's time, so a frame drawn again gets the same calls. new Image() fires load when its src is set, with no pixel size.
  • ResizeObserver calls back once when it starts observing, then after any layout that changed an observed element's size, with contentRect, borderBoxSize and contentBoxSize.

What it lacks

  • MutationObserver and IntersectionObserver exist, so a component that makes one doesn't throw, but they never call back.
  • An <img>'s onError never fires: an image that doesn't load fails the render, naming its src.
  • new Image() has no naturalWidth or naturalHeight (they stay 0).
  • An element has no innerHTML or outerHTML: read textContent, or walk children and childNodes.
  • A Range across wrapped lines is one rect, not one per line, and an inline element's getClientRects() is one rect too.
  • In a pass that draws no page (the sound pass, a 3D pre-pass) every read answers 0, and holds there are released.
  • Inside a RuneLayer in an app, getBoundingClientRect is zero.

Measured against Chrome on snapcn's measuring components, bench/layout-reads/ in the repository is the harness that holds these to it (#54).