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 Astro Foxes 42
- 2 Quantum Owls 39
- 3 Neon Yaks 37
- 4 Pixel Pandas 35
- 5 Turbo Newts 31
- 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
- Chrome blog: Declarative Partial Updates
- WICG explainer: patching — The same marker is re-emitted each tick so the region stays addressable.
- View Transitions — The stable per-row
view-transition-nameis what animates the reshuffle. - template-for polyfill
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 });
}
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="/">← 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><template for="board"></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><template for="board"></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 "you need a framework for this" 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>
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;
}
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));
}
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;
}
}