From 005db549c0d2c8ae31e45ded07c36eefc20a5b2d Mon Sep 17 00:00:00 2001
From: ayo
Date: Wed, 22 Jul 2026 23:41:52 +0200
Subject: [PATCH] docs(demo): add the library-comparison counter demo
---
demo/examples/library-comparison/README.md | 29 ++
.../library-comparison/counters/elena.mjs | 24 ++
.../library-comparison/counters/fast.mjs | 25 ++
.../library-comparison/counters/lit.mjs | 17 +
.../library-comparison/counters/vanilla.mjs | 33 ++
.../library-comparison/counters/wcb.mjs | 15 +
demo/examples/library-comparison/index.html | 231 +++++++++++
demo/examples/library-comparison/measure.mjs | 125 ++++++
demo/index.html | 14 +
demo/package.json | 4 +
docs/src/content/docs/guides/comparison.md | 10 +-
pnpm-lock.yaml | 390 +++++++++---------
12 files changed, 728 insertions(+), 189 deletions(-)
create mode 100644 demo/examples/library-comparison/README.md
create mode 100644 demo/examples/library-comparison/counters/elena.mjs
create mode 100644 demo/examples/library-comparison/counters/fast.mjs
create mode 100644 demo/examples/library-comparison/counters/lit.mjs
create mode 100644 demo/examples/library-comparison/counters/vanilla.mjs
create mode 100644 demo/examples/library-comparison/counters/wcb.mjs
create mode 100644 demo/examples/library-comparison/index.html
create mode 100644 demo/examples/library-comparison/measure.mjs
diff --git a/demo/examples/library-comparison/README.md b/demo/examples/library-comparison/README.md
new file mode 100644
index 0000000..78e6d20
--- /dev/null
+++ b/demo/examples/library-comparison/README.md
@@ -0,0 +1,29 @@
+# Library comparison
+
+The counter component from the [comparison guide](https://webcomponent.io/comparison/),
+implemented in each library it's measured against, plus the reproducible size
+benchmark behind the guide's numbers.
+
+- `counters/*.mjs` — the same minimal counter (one reactive `count` prop, a
+ click handler, a re-render on change) in `web-component-base`, Elena, Lit,
+ FAST, and vanilla `HTMLElement`. `index.html` runs all five live.
+- `measure.mjs` — bundles each counter _with_ its library runtime (esbuild,
+ `--bundle --minify --format=esm`) and compresses the result with gzip (−9) and
+ brotli (q11) via Node's `zlib`. That's the real "cost of your first
+ component": library runtime + component code, everything the browser
+ downloads.
+
+## Run the benchmark
+
+From this folder:
+
+```sh
+node measure.mjs # human-readable table
+node measure.mjs --md # Markdown table (what the guide embeds)
+node measure.mjs --json # raw numbers
+```
+
+The external libraries are pinned dev dependencies of the `demo` workspace, and
+`web-component-base` is bundled from its published `dist/` (run `pnpm build` at
+the repo root first), so the numbers are deterministic. Bump a pinned version in
+`demo/package.json`, re-run, and the table moves with it.
diff --git a/demo/examples/library-comparison/counters/elena.mjs b/demo/examples/library-comparison/counters/elena.mjs
new file mode 100644
index 0000000..65c7811
--- /dev/null
+++ b/demo/examples/library-comparison/counters/elena.mjs
@@ -0,0 +1,24 @@
+// The same reactive counter, written in Elena (@elenajs/core).
+// Elena's `html` has no inline event syntax, so the click handler is attached
+// in `firstUpdated` (after the first render) rather than in the template.
+// `count` is a non-reflected reactive prop.
+import { Elena, html } from '@elenajs/core'
+
+export class ElenaCounter extends Elena(HTMLElement) {
+ static tagName = 'elena-counter'
+ static props = [{ name: 'count', reflect: false }]
+
+ count = 0
+
+ render() {
+ return html``
+ }
+
+ firstUpdated() {
+ this.element.addEventListener('click', () => {
+ this.count++
+ })
+ }
+}
+
+ElenaCounter.define()
diff --git a/demo/examples/library-comparison/counters/fast.mjs b/demo/examples/library-comparison/counters/fast.mjs
new file mode 100644
index 0000000..319c2bc
--- /dev/null
+++ b/demo/examples/library-comparison/counters/fast.mjs
@@ -0,0 +1,25 @@
+// The same reactive counter, written in FAST (@microsoft/fast-element).
+// The buildless, non-decorator form: the template binds against the element
+// instance (`x`), and `count` is declared as a number attribute.
+import {
+ FASTElement,
+ html,
+ nullableNumberConverter,
+} from '@microsoft/fast-element'
+
+const template = html`
+
+`
+
+export class FastCounter extends FASTElement {
+ constructor() {
+ super()
+ this.count = 0
+ }
+}
+
+FastCounter.define({
+ name: 'fast-counter',
+ template,
+ attributes: [{ property: 'count', converter: nullableNumberConverter }],
+})
diff --git a/demo/examples/library-comparison/counters/lit.mjs b/demo/examples/library-comparison/counters/lit.mjs
new file mode 100644
index 0000000..6738ccc
--- /dev/null
+++ b/demo/examples/library-comparison/counters/lit.mjs
@@ -0,0 +1,17 @@
+// The same reactive counter, written in Lit.
+import { LitElement, html } from 'lit'
+
+export class LitCounter extends LitElement {
+ static properties = { count: { type: Number } }
+
+ constructor() {
+ super()
+ this.count = 0
+ }
+
+ render() {
+ return html``
+ }
+}
+
+customElements.define('lit-counter', LitCounter)
diff --git a/demo/examples/library-comparison/counters/vanilla.mjs b/demo/examples/library-comparison/counters/vanilla.mjs
new file mode 100644
index 0000000..c02ebb9
--- /dev/null
+++ b/demo/examples/library-comparison/counters/vanilla.mjs
@@ -0,0 +1,33 @@
+// The same reactive counter, written from scratch on top of HTMLElement — the
+// baseline every library above is measured against. No library runtime: the
+// reactivity, the attribute reflection, and the re-render are all hand-rolled.
+export class VanillaCounter extends HTMLElement {
+ static observedAttributes = ['count']
+
+ connectedCallback() {
+ if (!this.hasAttribute('count')) this.setAttribute('count', '0')
+ this.render()
+ this.addEventListener('click', () => {
+ this.setAttribute('count', String(this.count + 1))
+ })
+ }
+
+ attributeChangedCallback() {
+ this.render()
+ }
+
+ get count() {
+ return Number(this.getAttribute('count')) || 0
+ }
+
+ render() {
+ let button = this.querySelector('button')
+ if (!button) {
+ button = document.createElement('button')
+ this.append(button)
+ }
+ button.textContent = String(this.count)
+ }
+}
+
+customElements.define('vanilla-counter', VanillaCounter)
diff --git a/demo/examples/library-comparison/counters/wcb.mjs b/demo/examples/library-comparison/counters/wcb.mjs
new file mode 100644
index 0000000..7d007e4
--- /dev/null
+++ b/demo/examples/library-comparison/counters/wcb.mjs
@@ -0,0 +1,15 @@
+// The same reactive counter, written in web-component-base.
+// One reactive `count` prop, a click handler, a re-render on change.
+import { WebComponent, html } from 'web-component-base'
+
+export class WcbCounter extends WebComponent {
+ static props = { count: 0 }
+
+ get template() {
+ return html`
+
+ `
+ }
+}
+
+customElements.define('wcb-counter', WcbCounter)
diff --git a/demo/examples/library-comparison/index.html b/demo/examples/library-comparison/index.html
new file mode 100644
index 0000000..6710ed3
--- /dev/null
+++ b/demo/examples/library-comparison/index.html
@@ -0,0 +1,231 @@
+
+
+
+
+
+ Library comparison — the same counter, measured
+
+
+
+
+
+
+
+
+
+
+
+
The same counter, in each library
+
+ This is the component behind the
+ comparison guide: one
+ minimal counter — a single reactive count prop, a click
+ handler, a re-render on change — implemented in
+ web-component-base and in each library it's measured against.
+ Every counter below is live; click it. The source of each is listed at
+ the bottom of the page, and the
+ measurement that produces the sizes is
+ measure.mjs, runnable from this folder.
+
+
+
+
+ web-component-base
+ 2.6 kB brotli
+
+ static props + html
+
+
+ Elena
+ 3.4 kB brotli
+
+ click handler wired in firstUpdated
+
+
+ Lit
+ 5.3 kB brotli
+
+ static properties, shadow DOM
+
+
+ FAST
+ 12.2 kB brotli
+
+ binding directives, shadow DOM
+
+
+ vanilla HTMLElement
+ ~0.2 kB brotli
+
+ no library — the hand-rolled baseline
+
+
+
+
How it's measured
+
+ The number that matters for a first component is not the library's
+ advertised size — it's library runtime + your component code,
+ bundled and compressed, because that's everything the browser
+ actually downloads. So measure.mjs, for each library:
+
+
+
+ bundles the counter with its library runtime using
+ esbuild (bundle: true,
+ minify: true, format: 'esm') — tree-shaking
+ away whatever that component doesn't touch;
+
+
+ compresses the bundle with gzip (level 9) and
+ brotli (quality 11) via Node's built-in
+ zlib — the same codecs a CDN or server serves with.
+
+
+
+ The counters are byte-for-byte the same components running above and
+ listed under Implementation below. The external libraries are pinned dev
+ dependencies of this demo workspace, and web-component-base
+ is bundled from its published dist/, so the run is
+ deterministic.
+
+
+
Output
+
+ Measured at the pinned versions below (WCB from this repo's build,
+ esbuild 0.27, Node zlib):
+
+
+
+
+
Library
+
Version
+
Minified
+
Gzip
+
Brotli
+
+
+
+
+
web-component-base
+
6.1.1
+
6.7 kB
+
2.9 kB
+
2.6 kB
+
+
+
@elenajs/core
+
1.0.0
+
9.1 kB
+
3.7 kB
+
3.4 kB
+
+
+
lit
+
3.3.3
+
15.3 kB
+
5.9 kB
+
5.3 kB
+
+
+
@microsoft/fast-element
+
3.0.1
+
44.8 kB
+
13.6 kB
+
12.2 kB
+
+
+
vanilla HTMLElement
+
—
+
0.6 kB
+
0.3 kB
+
0.2 kB
+
+
+
+
+ The WCB counter is the smallest real component here — about
+ 21% under Elena, 51% under Lit, and 79% under FAST once
+ compressed.
+
+
+
Reproduce it
+
+ From demo/examples/library-comparison/:
+ node measure.mjs for the table,
+ node measure.mjs --md for Markdown, or
+ node measure.mjs --json for raw numbers.
+
+
+ Bump a pinned version in demo/package.json, re-run, and the
+ numbers move with it — the benchmark is the source of truth, not this
+ page.
+
+
+
diff --git a/demo/examples/library-comparison/measure.mjs b/demo/examples/library-comparison/measure.mjs
new file mode 100644
index 0000000..95f7b16
--- /dev/null
+++ b/demo/examples/library-comparison/measure.mjs
@@ -0,0 +1,125 @@
+// Reproducible size benchmark behind the comparison guide's numbers.
+//
+// For each library it bundles the *same* counter component
+// (`counters/.mjs`) together with that library's runtime, minifies with
+// esbuild, and compresses the result with gzip (level 9) and brotli (quality
+// 11) using Node's built-in zlib. That is the real "cost of your first
+// component": everything the browser downloads for one reactive element.
+//
+// node measure.mjs # human-readable table
+// node measure.mjs --md # a Markdown table (what the guide embeds)
+// node measure.mjs --json # machine-readable JSON
+//
+// The external libraries are pinned dev dependencies of this demo workspace, so
+// the run is deterministic; `web-component-base` is bundled from its published
+// build output (`dist/`) rather than the workspace source, to measure exactly
+// what consumers install.
+import { build } from 'esbuild'
+import { gzipSync, brotliCompressSync, constants } from 'node:zlib'
+import { fileURLToPath } from 'node:url'
+import { dirname, resolve } from 'node:path'
+import { readFileSync } from 'node:fs'
+import process from 'node:process'
+
+const here = dirname(fileURLToPath(import.meta.url))
+const demoRoot = resolve(here, '../..')
+
+// Resolve `web-component-base` to its built entry (the published artifact), not
+// the workspace `src/` the Vite dev server aliases it to.
+const wcbDist = resolve(here, '../../../dist/index.js')
+const wcbAlias = {
+ name: 'wcb-dist',
+ setup(b) {
+ b.onResolve({ filter: /^web-component-base$/ }, () => ({ path: wcbDist }))
+ },
+}
+
+// Not every library exposes `./package.json` in its `exports` map, so read the
+// installed manifest straight from the demo's `node_modules`.
+const version = (pkg) =>
+ JSON.parse(
+ readFileSync(resolve(demoRoot, 'node_modules', pkg, 'package.json'), 'utf8')
+ ).version
+
+const wcbVersion = JSON.parse(
+ readFileSync(resolve(here, '../../../package.json'), 'utf8')
+).version
+
+const targets = [
+ { name: 'web-component-base', file: 'wcb.mjs', version: wcbVersion },
+ {
+ name: '@elenajs/core',
+ file: 'elena.mjs',
+ version: version('@elenajs/core'),
+ },
+ { name: 'lit', file: 'lit.mjs', version: version('lit') },
+ {
+ name: '@microsoft/fast-element',
+ file: 'fast.mjs',
+ version: version('@microsoft/fast-element'),
+ },
+ { name: 'vanilla HTMLElement', file: 'vanilla.mjs', version: '-' },
+]
+
+const gzip = (buf) => gzipSync(buf, { level: 9 }).length
+const brotli = (buf) =>
+ brotliCompressSync(buf, {
+ params: { [constants.BROTLI_PARAM_QUALITY]: 11 },
+ }).length
+
+const kb = (n) => (n / 1000).toFixed(1) + ' kB'
+
+const rows = []
+for (const t of targets) {
+ const result = await build({
+ entryPoints: [resolve(here, 'counters', t.file)],
+ bundle: true,
+ minify: true,
+ format: 'esm',
+ write: false,
+ plugins: [wcbAlias],
+ logLevel: 'silent',
+ })
+ const code = result.outputFiles[0].contents
+ rows.push({
+ name: t.name,
+ version: t.version,
+ minified: code.length,
+ gzip: gzip(code),
+ brotli: brotli(code),
+ })
+}
+
+const arg = process.argv[2]
+
+if (arg === '--json') {
+ console.log(JSON.stringify(rows, null, 2))
+} else if (arg === '--md') {
+ console.log('| Library | Version | Minified | Gzip | Brotli |')
+ console.log('| ------- | ------- | -------- | ---- | ------ |')
+ for (const r of rows) {
+ console.log(
+ `| ${r.name} | ${r.version} | ${kb(r.minified)} | ${kb(r.gzip)} | ${kb(r.brotli)} |`
+ )
+ }
+} else {
+ const pad = (s, n) => String(s).padEnd(n)
+ const lpad = (s, n) => String(s).padStart(n)
+ console.log(
+ pad('Library', 26),
+ pad('Version', 9),
+ lpad('Minified', 10),
+ lpad('Gzip', 8),
+ lpad('Brotli', 8)
+ )
+ console.log('-'.repeat(63))
+ for (const r of rows) {
+ console.log(
+ pad(r.name, 26),
+ pad(r.version, 9),
+ lpad(kb(r.minified), 10),
+ lpad(kb(r.gzip), 8),
+ lpad(kb(r.brotli), 8)
+ )
+ }
+}
diff --git a/demo/index.html b/demo/index.html
index 4d10a81..f91bb14 100644
--- a/demo/index.html
+++ b/demo/index.html
@@ -25,6 +25,20 @@