Frontend Labs
⋯

public/js/stations/render.js

// Station 2: the rendering pipeline (§21, §22, §81).
import { h, station, panel, button, nextFrame, ms } from "../lib/dom.js";
import { missions, missionList } from "../lib/missions.js";

const STAGES = [
  ["js", "JavaScript"],
  ["style", "Style"],
  ["layout", "Layout"],
  ["paint", "Paint"],
  ["composite", "Composite"],
];

// Which stages a change to each property triggers in Blink/WebKit/Gecko
// (with the element on its own compositor layer for transform/opacity).
const EXPERIMENTS = [
  { prop: "left", label: "left (geometry)", stages: ["js", "style", "layout", "paint", "composite"],
    apply: (el, t) => (el.style.left = `${8 + t * 380}px`),
    why: "Changing geometry means positions of this box (and possibly everything around it) must be recomputed, then repainted." },
  { prop: "background-color", label: "background-color (paint only)", stages: ["js", "style", "paint", "composite"],
    apply: (el, t) => (el.style.backgroundColor = `hsl(${Math.round(t * 300)} 80% 55%)`),
    why: "No geometry changed, so layout is skipped, but the pixels inside the box must be repainted every frame." },
  { prop: "transform", label: "transform (composite only)", stages: ["js", "style", "composite"],
    apply: (el, t) => (el.style.transform = `translateX(${t * 380}px) rotate(${t * 360}deg)`),
    why: "The box's layer is already painted. The compositor just moves the bitmap: no layout, no paint, and it keeps running even if the main thread is busy (for CSS animations)." },
];

function pipeline() {
  const pills = STAGES.map(([id, name]) => h("span", { class: "stage-pill", "data-stage": id }, name));
  return {
    el: h("div", { class: "pipeline", "aria-label": "Rendering stages" }, pills),
    show(active) {
      pills.forEach((p) => {
        const on = active.includes(p.dataset.stage);
        p.classList.toggle("on", on);
        p.classList.toggle("skipped", !on);
        if (on) {
          p.classList.remove("flash");
          void p.offsetWidth; // restart the CSS animation (a deliberate forced layout!)
          p.classList.add("flash");
        }
      });
    },
  };
}

function animationPanel() {
  const pipe = pipeline();
  const mover = h("div", { class: "mover", "aria-hidden": "true" });
  const why = h("p", { "aria-live": "polite" }, "Pick a property to animate.");
  const stats = h("p", { class: "note mono" });
  let running = false;

  const run = async (exp) => {
    if (running) return;
    running = true;
    mover.style.cssText = ""; // reset all three properties
    mover.style.willChange = exp.prop === "transform" ? "transform" : "auto"; // promote to its own layer
    pipe.show(exp.stages);
    why.textContent = exp.why;
    const frames = [];
    const start = performance.now();
    let last = start;
    for (;;) {
      const now = await nextFrame();
      frames.push(now - last);
      last = now;
      const t = Math.min(1, (now - start) / 1500);
      exp.apply(mover, t);
      if (t === 1) break;
    }
    const avg = frames.reduce((a, b) => a + b, 0) / frames.length;
    stats.textContent = `${frames.length} frames · avg ${avg.toFixed(1)}ms per frame · worst ${Math.max(...frames).toFixed(1)}ms`;
    running = false;
    if (exp.prop === "transform") missions.complete("render", "composite");
  };

  return panel("Animate a property, watch which stages run",
    pipe.el,
    h("div", { class: "row" }, EXPERIMENTS.map((e) => button(e.label, () => run(e)))),
    h("div", { class: "track" }, mover),
    why, stats,
    h("p", { class: "note" }, "See it for real: DevTools → More tools → Rendering → tick \"Paint flashing\" and \"Layer borders\", then run each animation. Green flashes are repaints."));
}

function thrashPanel() {
  const N = 400;
  const bars = h("div", { class: "bars", "aria-hidden": "true" }, Array.from({ length: N }, () => h("div")));
  const result = h("div", { class: "readouts" });
  const times = {};

  const reset = () => [...bars.children].forEach((b) => (b.style.width = "100px"));
  const show = () => {
    result.replaceChildren(
      ...Object.entries(times).map(([k, v]) => h("div", { class: "readout" }, h("small", {}, k), h("b", {}, ms(v)))),
    );
    if (times["interleaved (thrash)"] != null && times["batched reads, then writes"] != null) missions.complete("render", "thrash");
  };

  const thrash = () => {
    reset();
    const t0 = performance.now();
    for (const b of bars.children) {
      const w = b.offsetWidth; // READ: forces layout, because the previous write dirtied it
      b.style.width = `${w + 1}px`; // WRITE: dirties layout again
    }
    void bars.offsetWidth;
    times["interleaved (thrash)"] = performance.now() - t0;
    show();
  };
  const batched = () => {
    reset();
    const t0 = performance.now();
    const widths = [...bars.children].map((b) => b.offsetWidth); // all reads: ONE layout
    [...bars.children].forEach((b, i) => (b.style.width = `${widths[i] + 1}px`)); // all writes
    void bars.offsetWidth; // the one layout the browser would do anyway
    times["batched reads, then writes"] = performance.now() - t0;
    show();
  };

  return panel("Experiment: forced synchronous layout (layout thrashing)",
    h("p", {}, `${N} bars. Each run reads every bar's width and makes it 1px wider. The only difference is the order of reads and writes.`),
    h("div", { class: "row" }, button("Interleave read/write", thrash, { class: "danger" }), button("Batch reads, then writes", batched, { class: "primary" })),
    result, bars,
    h("pre", { class: "code" }, h("span", {}, "// thrash: N layouts"), h("span", {}, "for (const b of bars) { b.style.width = b.offsetWidth + 1 + 'px'; }"),
      h("span", {}, "// batched: 1 layout"), h("span", {}, "const ws = bars.map(b => b.offsetWidth);"), h("span", {}, "bars.forEach((b, i) => b.style.width = ws[i] + 1 + 'px');")));
}

function renderTreePanel() {
  const sample = h("div", { class: "sample", id: "rt-sample" },
    h("h4", {}, "Title"),
    h("p", { hidden: true }, "display:none paragraph"),
    h("span", { style: { visibility: "hidden" } }, "visibility:hidden span"),
    h("span", {}, "visible span"));
  const out = h("div", { class: "grid-2" });
  const describe = () => {
    const domItems = [];
    const renderItems = [];
    const walk = (el, depth) => {
      const cs = getComputedStyle(el);
      const name = `${el.tagName.toLowerCase()}${el.hidden ? " [hidden]" : ""}`;
      domItems.push(h("li", { style: { marginLeft: `${depth * 14}px` } }, name));
      if (cs.display === "none") {
        renderItems.push(h("li", { class: "gone", style: { marginLeft: `${depth * 14}px` } }, `${name} (display:none → no box)`));
        return; // and none of its children get boxes either
      }
      const before = getComputedStyle(el, "::before").content;
      renderItems.push(h("li", { style: { marginLeft: `${depth * 14}px` } }, `${name}${cs.visibility === "hidden" ? " (box, but invisible)" : ""}`));
      if (before && before !== "none" && before !== "normal") renderItems.push(h("li", { class: "pseudo", style: { marginLeft: `${(depth + 1) * 14}px` } }, `::before ${before}`));
      for (const c of el.children) walk(c, depth + 1);
    };
    walk(sample, 0);
    out.replaceChildren(
      h("div", {}, h("h4", {}, "DOM tree"), h("ul", { class: "tree" }, domItems)),
      h("div", {}, h("h4", {}, "Render tree (layout boxes)"), h("ul", { class: "tree" }, renderItems)));
  };
  // getComputedStyle() on a node that isn't in the document returns empty
  // values, so wait until the station has actually been attached.
  const whenConnected = () => (sample.isConnected ? describe() : requestAnimationFrame(whenConnected));
  requestAnimationFrame(whenConnected);
  return panel("DOM tree vs render tree", h("p", {}, "The render tree only has nodes that produce boxes: display:none disappears (with all its children), visibility:hidden keeps its box, and ::before/::after appear even though they are not in the DOM. Computed live with getComputedStyle()."), sample, out);
}

export function mount() {
  return {
    el: station(
      "The rendering pipeline",
      "Every frame, the main thread may run JavaScript, recompute styles, lay out boxes and record paint commands. Then the compositor thread assembles layers on the GPU. Fast UIs skip as many stages as they can.",
      missionList("render"),
      animationPanel(),
      thrashPanel(),
      renderTreePanel(),
    ),
  };
}

Generated from SOURCE.md · 8 Oct 2026 16:36