Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
99 changes: 99 additions & 0 deletions apps/playgrounds/solid/render-bench/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
# Render benchmarks — what each layer of WE's UI stack costs

Measures WE's renderer and design system against hand-written controls, in a real browser,
including paint. Published figures live in
[docs/architecture/performance.md](../../../../docs/architecture/performance.md).

## Run

```sh
pnpm --filter @we/playground-render-bench dev # browser harness → http://localhost:3300
pnpm --filter @we/playground-render-bench test # correctness tests (also run in CI)
pnpm --filter @we/playground-render-bench bench # headless timings (never in CI)

# production figures — what the doc should quote
pnpm --filter @we/playground-render-bench build
pnpm --filter @we/playground-render-bench preview
```

## The ladder

Four fixtures render **identical content** with one layer removed at each step, so the gap between
adjacent rows is that layer's cost:

| Rung | Adds |
| ------------------- | -------------------------------------------- |
| Raw DOM | nothing — `createElement` + inline styles |
| Plain Solid | Solid |
| Solid + design sys. | `Column` / `we-text` / `we-button` |
| WE templates | the schema renderer over the same components |

`tests/ladder.test.tsx` asserts they stay equivalent. Nothing in the code enforces it — the schema
fixture lives in `src/fixtures.ts` and the controls reimplement it in `src/controls.tsx` — so an
edit to one would otherwise invalidate every published ratio while still looking plausible.

**The controls are not capability-equivalent.** Raw DOM and Plain Solid render the same shape with
similar styling via the same tokens, but have no theming, no hover/focus/active states, no
shadow-DOM encapsulation, and no accessibility affordance beyond a bare `<button>`. "Raw DOM is
faster" always means "faster, and missing all of that".

## Why this is a separate app

It has **no AD4M, no stores, no app shell, and no embedded apps** — none of which the thing being
measured depends on, and all of which add noise a consumer of WE would not share. A team adopting
WE brings their own shell; ours is not representative of theirs.

That absence is load-bearing architecturally: this app cannot build if `@we/schema-solid` or the
design system ever acquires an AD4M dependency, so the benchmark doubles as a portability guard for
the seam its sibling [`portable-ui-slice`](../portable-ui-slice) proves. Verify with
`pnpm why @coasys/ad4m` in this package — it should resolve to nothing.

## Reading the numbers

- **JS work** (`build + mount + flush`) is bracketed by direct `performance.now()` reads. Trustworthy
at any scale, and the figure that scales.
- **Paint** spans a double `requestAnimationFrame`, so it can never report less than one frame of
waiting. The `minimal` fixture exists to measure that floor — on a near-empty tree it reads ~20ms,
which is scheduling latency, not work. Paint is only interpretable well above one frame.
- **Time to screen** is what a user actually waits, floor included. Honest for UX, but it compresses
ratios because ~20ms of it is fixed for everyone.
- **Spread** is the trimmed range of warm samples (slowest dropped, since plain min–max over five is
dominated by a single GC pause). A delta smaller than a fixture's spread means nothing.

Protocol: run three times and use the third. The first run of a session is consistently ~15% slower
(V8 tiering, Lit template compilation, per-class `CSSStyleSheet` creation), and cross-session drift
is larger than within-session variation, so only same-session numbers are ever compared.

## Headless vs browser

`bench/` runs the same fixtures under happy-dom in seconds — the fast filter for renderer changes,
because a browser round-trip is slow enough to encourage guessing. It cannot see Paint and
overstates Flush by roughly 2.7×.

**A regression there means stop; a win there is only a hypothesis.** Confirm in the browser before
believing a number, and never publish headless figures.

## Ablation notes

Attribution figures gathered by disabling code and re-running. These informed
[the performance doc](../../../../docs/architecture/performance.md) but are engineering notes rather
than published results — several were measured under an earlier in-app harness on a dev build, so
read them as direction and rough proportion, not as magnitudes comparable with that doc's tables.

**Design system**

- Skipping `removeProperty` for custom properties never written cut Flush 27–42%. An ablation
disabling `updateAllCustomVars` entirely showed this recovered ~87% of what that path had to give.
- The `JSON.stringify` dirty-check in `DesignSystemElement.updated()` is ~1.6% of Flush. Not worth
touching.
- Memoising `getKeysForLayers` measured no detectable change. Kept anyway as redundancy removal —
~20 primitives were re-deriving per instance what the base class derives once per class.
- **Untested:** all 78 design-system props are registered `reflect: true`, so Lit writes an attribute
for each, but only ~7 have `:host([...])` selectors that need it.

**Renderer**

- Consolidating per-prop memos into one shared memo per node measured **slower**. Recorded at the
site in `SchemaRenderer.tsx` so it is not retried blind.
- Removing the per-node wrapper `div` is worth ~11% of the template tax — less than its 2× element
count suggests, because `display: contents` generates no layout box.
111 changes: 111 additions & 0 deletions apps/playgrounds/solid/render-bench/bench/headless.bench.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
/**
* Headless timing benchmarks — the fast filter for renderer changes.
*
* `pnpm --filter @we/playground-render-bench bench`
*
* WHY BOTH THIS AND THE BROWSER HARNESS
*
* Iterating against a browser means edit → rebuild → reload → run → read. That loop is slow enough
* to encourage guessing, and guessing has already put a 2.5x Build regression into the app before
* it was caught. This runs in seconds.
*
* SCOPE — Build and Flush only. happy-dom has no layout engine, so Paint is invisible, and its JS
* CSSOM implementation overstates Flush by roughly 2.7x versus a real browser. A change that looked
* like +6% here measured +160% in the app, because the cost lived in what the per-prop effects then
* did rather than in the walk itself.
*
* So: **a regression here means stop; a win here is only a hypothesis.** Confirm in the browser
* harness before believing a number, and never publish figures from this file.
*
* Deliberately excluded from CI (`vitest.bench.config.ts`, not `vitest.config.ts`) — benchmarks on
* shared runners are noise and must not decide whether a merge is allowed. The correctness tests in
* `tests/` do run there.
*/
import '@we/primitives';

import type { SchemaNode } from '@we/schema-shared';
import type { ComponentRegistry } from '@we/schema-solid';
import { RenderSchema } from '@we/schema-solid';
import { createStore } from 'solid-js/store';
import { render } from 'solid-js/web';
import { describe, expect, it } from 'vitest';

import { benchStore, cardGrid, staticCard, tokenCard, wcCard } from '../src/fixtures';
import { registry, stubRegistry } from '../src/registry';

const WARMUP = 2;
const SAMPLES = 5;

const stores = { benchStore };

type Sample = { build: number; flush: number };
type MaybeLitElement = Element & { updateComplete?: Promise<unknown> };

/** One render. Build is the synchronous walk; flush drains Lit's async first render. */
async function timeRender(node: SchemaNode, reg: ComponentRegistry): Promise<Sample> {
const container = document.createElement('div');
document.body.appendChild(container);
// Wrapped in a store because the real renderer reads `node.props`/`node.children` through a store
// proxy, and the proxy traps are a real part of the cost. Plain objects would understate it.
const [schema] = createStore(node);

const t0 = performance.now();
const dispose = render(() => <RenderSchema node={schema} stores={stores} registry={reg} />, container);
const built = performance.now();

const pending = Array.from(container.querySelectorAll('*'))
.map((el) => (el as MaybeLitElement).updateComplete)
.filter(Boolean);
const collected = performance.now();
await Promise.all(pending);
const flushed = performance.now();

dispose();
container.remove();
return { build: built - t0, flush: flushed - collected };
}

const median = (xs: number[]) => [...xs].sort((a, b) => a - b)[Math.floor((xs.length - 1) / 2)];

async function measure(node: SchemaNode, reg: ComponentRegistry): Promise<Sample> {
for (let i = 0; i < WARMUP; i++) await timeRender(node, reg);
const samples: Sample[] = [];
for (let i = 0; i < SAMPLES; i++) samples.push(await timeRender(node, reg));
return { build: median(samples.map((s) => s.build)), flush: median(samples.map((s) => s.flush)) };
}

/**
* Reports both registries. The stub isolates the schema walk; the real one adds everything the walk
* causes downstream (`buildLayoutStyles`, Lit reactive-property setters, the CSSOM writes). The gap
* between them is the point — a stub-only harness is blind to the class of change most renderer
* optimisations fall into.
*/
async function compare(label: string, node: SchemaNode) {
const stub = await measure(node, stubRegistry);
const real = await measure(node, registry);
console.log(
`${label.padEnd(28)}` +
`stub build ${stub.build.toFixed(1).padStart(7)}ms | ` +
`real build ${real.build.toFixed(1).padStart(7)}ms flush ${real.flush.toFixed(1).padStart(7)}ms`,
);
}

describe('headless render timings', () => {
it('static trees', async () => {
await compare('static 50 (200 nodes)', cardGrid(50, staticCard, '200px', '8px'));
await compare('static 200 (800 nodes)', cardGrid(200, staticCard, '180px', '8px'));
await compare('static 1000 (4000 nodes)', cardGrid(1000, staticCard, '180px', '8px'));
expect(true).toBe(true);
});

it('token trees', async () => {
await compare('token 50 (150 nodes)', cardGrid(50, tokenCard, '200px', '8px'));
await compare('token 200 (600 nodes)', cardGrid(200, tokenCard, '200px', '8px'));
expect(true).toBe(true);
});

it('the ladder fixture', async () => {
await compare('wc 100 (300 nodes)', cardGrid(100, wcCard));
expect(true).toBe(true);
});
});
12 changes: 12 additions & 0 deletions apps/playgrounds/solid/render-bench/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>WE render benchmarks</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
30 changes: 30 additions & 0 deletions apps/playgrounds/solid/render-bench/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
{
"private": true,
"name": "@we/playground-render-bench",
"version": "0.0.0",
"description": "Measurement harness — what WE's renderer and design system cost, against raw DOM and plain Solid controls. No AD4M.",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"test": "vitest run --config vitest.config.ts",
"bench": "vitest run --config vitest.bench.config.ts --reporter=verbose"
},
"dependencies": {
"@we/components": "workspace:*",
"@we/design-utils": "workspace:*",
"@we/primitives": "workspace:*",
"@we/schema-shared": "workspace:*",
"@we/schema-solid": "workspace:*",
"@we/tokens": "workspace:*",
"solid-js": "^1.9.5"
},
"devDependencies": {
"happy-dom": "^20.8.4",
"typescript": "^5.9.3",
"vite": "^6.0.7",
"vite-plugin-solid": "^2.11.11",
"vitest": "^4.1.0"
}
}
Loading
Loading