From c9f5ba2fda1e5ae17e632a33717c37818fe73576 Mon Sep 17 00:00:00 2001 From: Bendik Aagaard Lynghaug Date: Tue, 8 Sep 2026 17:59:53 +0200 Subject: [PATCH] =?UTF-8?q?bench:=20@choojs/html=20vs=20nanohtml=20v1=20vs?= =?UTF-8?q?=20=C2=B5html=20v5,=20happy-dom=20+=20real=20Chromium?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit npm run bench (happy-dom, all three engines) and npm run bench:browser (Playwright Chromium, the two ESM engines). Honest numbers recorded in docs/v8.md: we create ~12% faster than µhtml in real Chromium (the parse-once/clone design), µhtml updates in place ~5x faster than fresh-tree + nanomorph (choo's architectural cost, mitigated by component caching), and server strings are on par with nanohtml v1. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_014NgfSjHE11oFpoSnLVKLXd --- bench/real-browser.js | 80 +++++++++++++++++++++++++++++ bench/render.js | 114 ++++++++++++++++++++++++++++++++++++++++++ docs/v8.md | 18 +++++-- package.json | 7 ++- 4 files changed, 214 insertions(+), 5 deletions(-) create mode 100644 bench/real-browser.js create mode 100644 bench/render.js diff --git a/bench/real-browser.js b/bench/real-browser.js new file mode 100644 index 0000000..f10e112 --- /dev/null +++ b/bench/real-browser.js @@ -0,0 +1,80 @@ +// Real-browser benchmark (Playwright Chromium): @choojs/html vs µhtml v5, +// same create/update scenarios as bench/render.js but with native DOM. +// nanohtml v1 is CJS-only and can't be import-mapped, so it only appears +// in the happy-dom bench. +// +// node bench/real-browser.js + +import { chromium } from 'playwright' +import { startServer } from '../test/e2e/serve.js' + +const srv = await startServer() +const browser = await chromium.launch() +const page = await browser.newPage() + +const PAGE = ` + + +` + +await page.route('**/bench-page', (route) => route.fulfill({ contentType: 'text/html', body: PAGE })) +await page.goto(srv.origin + '/bench-page') + +const results = await page.evaluate(async () => { + const { default: html } = await import('@choojs/html') + const { default: morph } = await import('@choojs/html/morph') + const { html: uhtml, render: urender } = await import('uhtml') + + const ROWS = 100 + const data = (tick) => Array.from({ length: ROWS }, (_, i) => ({ + id: i, label: 'row ' + i + ' rev ' + tick, selected: i === tick % ROWS + })) + + const bench = (fn) => { + let t = 0 + const warmupEnd = performance.now() + 200 + while (performance.now() < warmupEnd) fn(t++) + let ops = 0 + const start = performance.now() + const end = start + 1000 + while (performance.now() < end) { fn(t++); ops++ } + return Math.round((ops / (performance.now() - start)) * 1000) + } + + const ourTable = (rows) => html`${rows.map((r) => html``)}
${r.id}${r.label}
` + const uTable = (rows) => uhtml`${rows.map((r) => uhtml``)}
${r.id}${r.label}
` + + const out = {} + out['@choojs/html create'] = bench((t) => ourTable(data(t))) + out['uhtml v5 create (fresh container)'] = bench((t) => { + const c = document.createElement('div') + urender(c, uTable(data(t))) + }) + + const live = ourTable(data(0)) + document.body.appendChild(live) + out['@choojs/html + nanomorph update'] = bench((t) => morph(live, ourTable(data(t)))) + live.remove() + + const c = document.createElement('div') + document.body.appendChild(c) + out['uhtml v5 update (in place)'] = bench((t) => urender(c, uTable(data(t)))) + c.remove() + + return out +}) + +console.log('\nreal Chromium — 100-row table, ops/s:') +for (const [name, ops] of Object.entries(results)) { + console.log(' ' + name.padEnd(38) + String(ops).padStart(8) + ' ops/s') +} +console.log() + +await browser.close() +await srv.close() diff --git a/bench/render.js b/bench/render.js new file mode 100644 index 0000000..986867d --- /dev/null +++ b/bench/render.js @@ -0,0 +1,114 @@ +// Rendering benchmarks: @choojs/html vs nanohtml v1 vs µhtml. +// +// npm run bench +// +// Two environments: +// - server: pure string rendering in Node (no DOM at all) +// - browser-path: DOM construction in happy-dom. Caveat: happy-dom is a +// JS DOM, so absolute numbers are not real-browser numbers — but all +// contenders pay the same DOM tax, so the relative ordering is +// indicative of template-engine overhead. +// +// Scenarios: "create" builds a fresh 100-row table per iteration; +// "update" re-renders the same live tree with new data each iteration +// (choo-style engines build fresh + morph, µhtml updates holes in place). + +import { createRequire } from 'node:module' +const require = createRequire(import.meta.url) + +const ROWS = 100 +const DURATION_MS = 500 +const WARMUP_MS = 100 + +function data (tick) { + const rows = new Array(ROWS) + for (let i = 0; i < ROWS; i++) { + rows[i] = { id: i, label: 'row ' + i + ' rev ' + tick, selected: i === tick % ROWS } + } + return rows +} + +function bench (name, fn) { + let t = performance.now() + const warmupEnd = t + WARMUP_MS + let tick = 0 + while (performance.now() < warmupEnd) fn(tick++) + + let ops = 0 + const start = performance.now() + const end = start + DURATION_MS + while (performance.now() < end) { + fn(tick++) + ops++ + } + const elapsed = performance.now() - start + const opsSec = Math.round((ops / elapsed) * 1000) + console.log(' ' + name.padEnd(34) + String(opsSec).padStart(8) + ' ops/s') + return opsSec +} + +// ---------- server (string) rendering ---------- + +console.log('\nserver rendering — %d-row table to string:', ROWS) +{ + const { default: ourHtml } = await import('@choojs/html/server') + const nano = require('nanohtml') // main entry is the server renderer + + const table = (html, rows) => html`${rows.map((r) => html``)}
${r.id}${r.label}
` + + bench('@choojs/html (server)', (t) => String(table(ourHtml, data(t)))) + bench('nanohtml v1 (server)', (t) => String(table(nano, data(t)))) +} + +// ---------- browser-path rendering (happy-dom) ---------- + +const { Window } = await import('happy-dom') +const win = new Window() +globalThis.window = win +globalThis.document = win.document +// µhtml touches DOM constructors as ambient globals at import time +for (const name of [ + 'DocumentFragment', 'HTMLElement', 'SVGElement', 'Element', 'Node', + 'Text', 'Comment', 'Range', 'MutationObserver', 'customElements', + 'requestAnimationFrame', 'cancelAnimationFrame' +]) { + if (!(name in globalThis) && name in win) globalThis[name] = win[name] +} + +const { default: ourBrowserHtml } = await import('@choojs/html/browser') +const { default: morph } = await import('@choojs/html/morph') +const nanoBrowser = require('nanohtml/lib/browser.js') +const { html: uhtml, render: urender } = await import('uhtml') + +const table = (html, rows) => html`${rows.map((r) => html``)}
${r.id}${r.label}
` + +console.log('\nbrowser-path create — fresh %d-row table per iteration (happy-dom):', ROWS) +bench('@choojs/html (cached templates)', (t) => table(ourBrowserHtml, data(t))) +bench('nanohtml v1 (hyperx runtime)', (t) => table(nanoBrowser, data(t))) +bench('uhtml v5 (fresh container)', (t) => { + const c = document.createElement('div') + urender(c, uhtml`${data(t).map((r) => uhtml``)}
${r.id}${r.label}
`) +}) + +console.log('\nbrowser-path update — re-render same live tree (happy-dom):') +{ + const live = table(ourBrowserHtml, data(0)) + document.body.appendChild(live) + bench('@choojs/html + nanomorph', (t) => morph(live, table(ourBrowserHtml, data(t)))) + document.body.removeChild(live) +} +{ + const live = table(nanoBrowser, data(0)) + document.body.appendChild(live) + bench('nanohtml v1 + nanomorph', (t) => morph(live, table(nanoBrowser, data(t)))) + document.body.removeChild(live) +} +{ + const c = document.createElement('div') + document.body.appendChild(c) + const view = (t) => uhtml`${data(t).map((r) => uhtml``)}
${r.id}${r.label}
` + bench('uhtml v5 (keyed holes, in place)', (t) => urender(c, view(t))) + document.body.removeChild(c) +} + +console.log('\ncaveat: happy-dom numbers are indicative (same DOM tax for all), not real-browser numbers.\n') diff --git a/docs/v8.md b/docs/v8.md index 949504c..e5b8a02 100644 --- a/docs/v8.md +++ b/docs/v8.md @@ -44,9 +44,21 @@ everything new lives under `packages/`. wildcard decode, decoded state.href - [x] Phase 2 (core): browser renderer rewrite, `@choojs/component`, zero-build counter example, full-app integration test in happy-dom -- [ ] Phase 2 (remaining): adoption-style hydration with mismatch - warnings, Playwright suite in a real browser, benchmarks vs - nanohtml v1 / µhtml +- [x] Phase 2 (tail): adoption-style hydration with mismatch warnings + (`@choojs/html/hydrate`, wired into `mount()`), real-browser + Playwright e2e (zero-build page, SSR-then-hydrate page, adoption + proof; CI job included), benchmarks vs nanohtml v1 / µhtml + +## Benchmarks (2026-09, 100-row table, `npm run bench` / `bench:browser`) + +Real Chromium: @choojs/html creates fresh trees ~12% faster than µhtml v5 +(3.5k vs 3.1k ops/s) — the parse-once/clone design pays off in native DOM. +µhtml updates in place ~5x faster than our fresh-tree + nanomorph loop +(3.0k vs 0.6k ops/s): that is choo's architectural cost, mitigated in real +apps by @choojs/component caching (proxy nodes skip unchanged subtrees), +and the number to beat if Phase 3 explores keyed-hole optimizations. +Server string rendering is on par with nanohtml v1 (~13k ops/s, within +6%). happy-dom numbers in bench/render.js are indicative only. - [ ] Phase 3: v8 wiring (`toStream`, devtools, codemod). Includes the answer to choojs/choo#653 (async/lazy routes): route handlers may resolve lazily via native import(); browser renders previous tree diff --git a/package.json b/package.json index fa77d2e..f0669d2 100644 --- a/package.json +++ b/package.json @@ -13,12 +13,15 @@ "scripts": { "test": "node --test packages/core/test/ packages/html/test/ packages/component/test/", "test:e2e": "node --test test/e2e/", - "bench": "node bench/render.js" + "bench": "node bench/render.js", + "bench:browser": "node bench/real-browser.js" }, "repository": "choojs/choo", "license": "MIT", "devDependencies": { "happy-dom": "^20.14.0", - "playwright": "^1.63.0" + "nanohtml": "^1.10.0", + "playwright": "^1.63.0", + "uhtml": "^5.0.9" } }