← back to index

09. replace, don't diff (do we still need the virtual DOM?)

A live leaderboard that reshuffles every second. The server re-renders the whole sorted list and streams it as a <template for="board"> that replaces the region in place. No virtual DOM, no diffing, no reconciliation, no framework, and no client JS. Each row carries a stable view-transition-name, so a browser that opts into view transitions animates the reshuffle for free.

  1. 1 Astro Foxes 42
  2. 2 Quantum Owls 39
  3. 3 Neon Yaks 37
  4. 4 Pixel Pandas 35
  5. 5 Turbo Newts 31
  6. 6 Velvet Crabs 28

Open DevTools' Network panel: it's one response that never finishes, flushing a fresh <template for="board"> each tick. Without the API you'll see this first board as a static snapshot instead of a live one.

The virtual DOM exists to avoid re-rendering everything: diff the new render against the old tree, then patch only what changed. When the platform can replace a whole named region from streamed HTML, a large class of "you need a framework for this" UIs become a server that prints HTML.

learn more

source

Everything the server runs for this example. Each file links to GitHub so you can copy or fork it.

handler.ts/app/src/examples/09-replace-dont-diff/handler.ts
import { sleep, streamingResponse } from "../../lib/streaming.ts";
import { loadShell, tryStatic } from "../../lib/files.ts";

const SHELL = loadShell(import.meta.url);

// The "data": a few teams whose scores random-walk each tick. The starting scores
// match the static board baked into index.html, so the first paint (and the no-API
// fallback) line up with the streamed updates that follow.
type Row = { name: string; score: number };
const INITIAL: Row[] = [
  { name: "Astro Foxes", score: 42 },
  { name: "Quantum Owls", score: 39 },
  { name: "Neon Yaks", score: 37 },
  { name: "Pixel Pandas", score: 35 },
  { name: "Turbo Newts", score: 31 },
  { name: "Velvet Crabs", score: 28 },
];

function step(teams: Row[]): Row[] {
  for (const t of teams) {
    t.score = Math.max(0, t.score + Math.round((Math.random() - 0.45) * 6));
  }
  return [...teams].sort((a, b) => b.score - a.score);
}

/** Render the ordered rows. Each row carries a stable view-transition-name so a
 *  browser that opts into transitions animates the reshuffle for free, but the
 *  replacement itself needs none of that. */
function rows(sorted: Row[]): string {
  const max = Math.max(...sorted.map((t) => t.score), 1);
  return sorted.map((t, i) => {
    const slug = t.name.toLowerCase().replace(/[^a-z]+/g, "-");
    const pct = Math.round((t.score / max) * 100);
    return `    <li style="view-transition-name:row-${slug}">
      <span class="rank">${i + 1}</span>
      <span class="name">${t.name}</span>
      <span class="bar"><span class="fill" style="width:${pct}%"></span></span>
      <span class="score">${t.score}</span>
    </li>`;
  }).join("\n");
}

export default function handle(_req: Request, path: string): Response | Promise<Response> {
  if (path === "/" || path === "/index.html") {
    // Per-connection state so concurrent viewers get independent walks.
    const teams = INITIAL.map((t) => ({ ...t }));
    return streamingResponse(async (write) => {
      await write(SHELL);
      // Never-ending: each tick replaces the whole list. When the client closes,
      // the next write() throws StreamAborted and the loop exits quietly.
      while (true) {
        await sleep(1000);
        // Re-emit the <?start>/<?end> marker inside each template so the region
        // stays addressable for the next tick; the whole list is replaced in place.
        await write(
          `<template for="board"><?start name="board"><ol class="frame">\n${
            rows(step(teams))
          }\n</ol><?end></template>\n`,
        );
      }
    });
  }
  return tryStatic(import.meta.url, path) ?? new Response("Not found", { status: 404 });
}

open on GitHub →

index.html/app/src/examples/09-replace-dont-diff/index.html
<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>09 · replace, don't diff</title>
  <link rel="stylesheet" href="/public/styles.css">
  <link rel="stylesheet" href="/09/styles.css">
</head>
<body>
<main>
  <p class="crumbs"><a href="/">&larr; back to index</a></p>
  <h1>09. replace, don't diff (do we still need the virtual DOM?)</h1>
  <p class="lede">A live leaderboard that reshuffles every second. The server re-renders the <em>whole</em> sorted list and streams it as a <code>&lt;template for=&quot;board&quot;&gt;</code> that replaces the region in place. No virtual DOM, no diffing, no reconciliation, no framework, and no client JS. Each row carries a stable <code>view-transition-name</code>, so a browser that opts into view transitions animates the reshuffle for free.</p>

  <div class="board">
    <?start name="board"><ol class="frame">
    <li style="view-transition-name:row-astro-foxes">
      <span class="rank">1</span>
      <span class="name">Astro Foxes</span>
      <span class="bar"><span class="fill" style="width:100%"></span></span>
      <span class="score">42</span>
    </li>
    <li style="view-transition-name:row-quantum-owls">
      <span class="rank">2</span>
      <span class="name">Quantum Owls</span>
      <span class="bar"><span class="fill" style="width:93%"></span></span>
      <span class="score">39</span>
    </li>
    <li style="view-transition-name:row-neon-yaks">
      <span class="rank">3</span>
      <span class="name">Neon Yaks</span>
      <span class="bar"><span class="fill" style="width:88%"></span></span>
      <span class="score">37</span>
    </li>
    <li style="view-transition-name:row-pixel-pandas">
      <span class="rank">4</span>
      <span class="name">Pixel Pandas</span>
      <span class="bar"><span class="fill" style="width:83%"></span></span>
      <span class="score">35</span>
    </li>
    <li style="view-transition-name:row-turbo-newts">
      <span class="rank">5</span>
      <span class="name">Turbo Newts</span>
      <span class="bar"><span class="fill" style="width:74%"></span></span>
      <span class="score">31</span>
    </li>
    <li style="view-transition-name:row-velvet-crabs">
      <span class="rank">6</span>
      <span class="name">Velvet Crabs</span>
      <span class="bar"><span class="fill" style="width:67%"></span></span>
      <span class="score">28</span>
    </li>
    </ol><?end>
  </div>

  <p style="color:var(--muted);font-size:.85rem;">Open DevTools' Network panel: it's one response that never finishes, flushing a fresh <code>&lt;template for=&quot;board&quot;&gt;</code> each tick. Without the API you'll see this first board as a static snapshot instead of a live one.</p>

  <p style="color:var(--muted);font-size:.85rem;">The virtual DOM exists to avoid re-rendering everything: diff the new render against the old tree, then patch only what changed. When the platform can replace a whole named region from streamed HTML, a large class of &quot;you need a framework for this&quot; UIs become a server that prints HTML.</p>

  <section class="refs">
    <h3>learn more</h3>
    <ul>
      <li><a href="https://developer.chrome.com/blog/declarative-partial-updates?hl=en" target="_blank" rel="noopener">Chrome blog: Declarative Partial Updates</a></li>
      <li><a href="https://github.com/WICG/declarative-partial-updates/blob/main/patching-explainer.md" target="_blank" rel="noopener">WICG explainer: patching</a> <span class="note">— The same marker is re-emitted each tick so the region stays addressable.</span></li>
      <li><a href="https://developer.chrome.com/docs/web-platform/view-transitions/" target="_blank" rel="noopener">View Transitions</a> <span class="note">— The stable per-row <code>view-transition-name</code> is what animates the reshuffle.</span></li>
      <li><a href="https://github.com/GoogleChromeLabs/template-for-polyfill" target="_blank" rel="noopener">template-for polyfill</a></li>
    </ul>
  </section>

  <!-- source-viewer goes here -->

  <footer class="byline">made by <a href="https://paul.kinlan.me/" target="_blank" rel="noopener">Paul Kinlan</a></footer>
</main>
</body>
</html>

open on GitHub →

styles.css/app/src/examples/09-replace-dont-diff/styles.css
.board {
  background: var(--card);
  border: 1px solid var(--border);
  border-radius: 12px;
  padding: 0.5rem;
}

/* Each tick emits a fresh <ol class="frame">; show only the newest. */
.board .frame:not(:last-of-type) {
  display: none;
}

.board ol {
  list-style: none;
  margin: 0;
  padding: 0;
}

.board li {
  display: grid;
  grid-template-columns: 2rem 8.5rem 1fr 2.5rem;
  align-items: center;
  gap: 0.75rem;
  padding: 0.55rem 0.6rem;
  border-radius: 8px;
}

.board li + li {
  border-top: 1px solid var(--border);
}

.board .rank {
  color: var(--muted);
  font-variant-numeric: tabular-nums;
  text-align: center;
}

.board .name {
  font-weight: 600;
}

.board .bar {
  height: 0.55rem;
  background: color-mix(in srgb, var(--fg) 8%, transparent);
  border-radius: 999px;
  overflow: hidden;
}

.board .fill {
  display: block;
  height: 100%;
  background: var(--accent);
  border-radius: 999px;
}

.board .score {
  font-variant-numeric: tabular-nums;
  text-align: right;
  color: var(--accent);
  font-weight: 600;
}

/* Opt every element into cross-fade view transitions so the reshuffle animates.
   A pure-CSS enhancement, not required for the replacement to work. */
@view-transition {
  navigation: auto;
}
::view-transition-group(*) {
  animation-duration: 0.35s;
}

open on GitHub →

lib/streaming.ts/app/src/lib/streaming.ts
export class StreamAborted extends Error {
  constructor() {
    super("stream aborted by client");
    this.name = "StreamAborted";
  }
}

export type ChunkSource = (write: (chunk: string) => Promise<void>) => Promise<void>;

const encoder = new TextEncoder();

export function streamingResponse(
  source: ChunkSource,
  contentType = "text/html; charset=utf-8",
): Response {
  let aborted = false;
  const body = new ReadableStream<Uint8Array>({
    async start(controller) {
      const write = async (chunk: string) => {
        if (aborted) throw new StreamAborted();
        try {
          controller.enqueue(encoder.encode(chunk));
        } catch {
          aborted = true;
          throw new StreamAborted();
        }
      };
      try {
        await source(write);
      } catch (err) {
        if (!(err instanceof StreamAborted)) console.error("stream source error", err);
      } finally {
        if (!aborted) {
          try {
            controller.close();
          } catch {
            // already closed
          }
        }
      }
    },
    cancel() {
      aborted = true;
    },
  });
  return new Response(body, {
    headers: {
      "content-type": contentType,
      "cache-control": "no-store",
      "x-content-type-options": "nosniff",
    },
  });
}

export function sleep(ms: number): Promise<void> {
  return new Promise((resolve) => setTimeout(resolve, ms));
}

open on GitHub →

lib/files.ts/app/src/lib/files.ts
// File helpers for examples: read sibling files, render the page shell with the
// source viewer injected, and serve sibling static assets (CSS/JS/HTML) on demand.

import { sourceBlock } from "./source.ts";

const MIME: Record<string, string> = {
  css: "text/css; charset=utf-8",
  js: "application/javascript; charset=utf-8",
  html: "text/html; charset=utf-8",
  svg: "image/svg+xml",
  json: "application/json; charset=utf-8",
};

function dirFor(handlerUrl: string): string {
  return new URL(".", handlerUrl).pathname;
}

export function readSibling(handlerUrl: string, name: string): string {
  return Deno.readTextFileSync(dirFor(handlerUrl) + name);
}

// Reads a sibling HTML file. The marker `<!-- source-viewer goes here -->` is replaced
// with the source-viewer block. Extra named placeholders ({{name}}) can be supplied via `vars`.
const SOURCE_MARKER = "<!-- source-viewer goes here -->";

export function loadShell(
  handlerUrl: string,
  name = "index.html",
  vars: Record<string, string> = {},
): string {
  let raw = readSibling(handlerUrl, name);
  raw = raw.replace(SOURCE_MARKER, sourceBlock(handlerUrl));
  for (const [k, v] of Object.entries(vars)) {
    raw = raw.replaceAll(`{{${k}}}`, v);
  }
  return raw;
}

// Try to serve a sibling static file from the example folder. Returns null when the
// file is missing, the path escapes the folder, or the path is empty.
export function tryStatic(handlerUrl: string, requestPath: string): Response | null {
  const safe = requestPath.replace(/^\/+/, "");
  if (!safe || safe.includes("..")) return null;
  // Never let the browser fetch handler.ts or README.md through this fallback.
  if (safe === "handler.ts" || safe === "README.md") return null;
  try {
    const content = Deno.readFileSync(dirFor(handlerUrl) + safe);
    const ext = safe.split(".").pop() ?? "";
    return new Response(content, {
      headers: {
        "content-type": MIME[ext] ?? "application/octet-stream",
        "cache-control": "no-store",
      },
    });
  } catch {
    return null;
  }
}

open on GitHub →