diff --git a/apps/playgrounds/solid/render-bench/README.md b/apps/playgrounds/solid/render-bench/README.md new file mode 100644 index 00000000..2cb3ff7d --- /dev/null +++ b/apps/playgrounds/solid/render-bench/README.md @@ -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 ` + + ))} + + ); +} + +/** + * What a developer writes today: real design-system components, plain JSX attributes, `.map()` for + * a static list. + * + * Whether the plain-attribute form handicaps this control is a fair question — see + * `HandWrittenCardsPropBound` below, which answers it by measurement rather than assertion. + */ +export function HandWrittenCards(): JSX.Element { + return ( + + {ids().map((id) => ( + + + + + ))} + + ); +} + +/** + * The same cards, binding design-system props as DOM **properties** via Solid's `prop:` directive + * rather than as HTML attributes. + * + * Exists so the ladder is not open to the charge that its hand-written rung was written badly. + * `` sets an attribute, which Lit round-trips through `attributeChangedCallback` + * → converter → property → update request; the schema renderer skips that by assigning the property + * directly. If that mattered, the fair control would be this one and the published template tax + * would be overstated. + * + * Note the `@ts-expect-error` on each element. The generated Solid declarations emit `prop:` + * variants only for the four object-valued state props (`hoverProps`, `activeProps`, `focusProps`, + * `disabledProps`) — the ones where a property binding is *required* because they cannot be + * serialised to an attribute. Regular design-system props have no `prop:` variant, so this binding + * is not expressible without a cast: a developer following the types is steered onto the attribute + * path. That is a deliberate generator decision rather than an oversight, and only worth revisiting + * if the measurement shows a meaningful gap. + */ +export function HandWrittenCardsPropBound(): JSX.Element { + return ( + + {ids().map((id) => ( + + {/* @ts-expect-error `prop:` is not emitted for regular DS props — see the note above. */} + + {/* @ts-expect-error `prop:` is not emitted for regular DS props — see the note above. */} + + + ))} + + ); +} + +// --------------------------------------------------------------------------- +// Realistic ladder — the same rungs on page-shaped content +// --------------------------------------------------------------------------- + +const postIds = () => Array.from({ length: REALISTIC_COUNT }, (_, i) => i + 1); + +const REALISTIC_GRID = { + display: 'grid', + 'grid-template-columns': 'repeat(auto-fill, minmax(320px, 1fr))', + gap: '12px', +}; + +/** + * Hand-written equivalent of `realisticCard` — four component types, three levels of nesting, and + * content that varies per card. + * + * Must stay a faithful mirror of the schema fixture; `tests/ladder.test.tsx` asserts it does. + */ +export function RealisticCards(): JSX.Element { + return ( + + {postIds().map((id) => { + const c = postContent(id); + return ( + + + + + + + + + {c.badgeLabel} + + + + + + + + + + + + + + + + ); + })} + + ); +} + +/** The same again, binding design-system props as properties. See `HandWrittenCardsPropBound`. */ +export function RealisticCardsPropBound(): JSX.Element { + return ( + + {postIds().map((id) => { + const c = postContent(id); + return ( + + + {/* @ts-expect-error `prop:` is not emitted for regular DS props — see the note above. */} + + + {/* @ts-expect-error `prop:` is not emitted for regular DS props. */} + + {/* @ts-expect-error `prop:` is not emitted for regular DS props. */} + + + {/* @ts-expect-error `prop:` is not emitted for regular DS props. */} + + {c.badgeLabel} + + + {/* @ts-expect-error `prop:` is not emitted for regular DS props. */} + + {/* @ts-expect-error `prop:` is not emitted for regular DS props. */} + + + {/* @ts-expect-error `prop:` is not emitted for regular DS props. */} + + + + {/* @ts-expect-error `prop:` is not emitted for regular DS props. */} + + {/* @ts-expect-error `prop:` is not emitted for regular DS props. */} + + + + {/* @ts-expect-error `prop:` is not emitted for regular DS props. */} + + + + ); + })} + + ); +} + +export const controls: Record JSX.Element> = { + RawDomCards, + PlainSolidCards, + HandWrittenCards, + HandWrittenCardsPropBound, + RealisticCards, + RealisticCardsPropBound, +}; diff --git a/apps/playgrounds/solid/render-bench/src/env.d.ts b/apps/playgrounds/solid/render-bench/src/env.d.ts new file mode 100644 index 00000000..44be7949 --- /dev/null +++ b/apps/playgrounds/solid/render-bench/src/env.d.ts @@ -0,0 +1,6 @@ +/// + +// Side-effect CSS imports have no type declarations. The design tokens ship a raw CSS bundle at the +// `@we/tokens/css` subpath (not a `*.css` specifier, so vite/client's ambient doesn't match it). +declare module '@we/tokens/css'; +declare module '*.css'; diff --git a/apps/playgrounds/solid/render-bench/src/fixtures.ts b/apps/playgrounds/solid/render-bench/src/fixtures.ts new file mode 100644 index 00000000..e6e721b6 --- /dev/null +++ b/apps/playgrounds/solid/render-bench/src/fixtures.ts @@ -0,0 +1,499 @@ +/** + * Benchmark fixtures — the single definition of what gets rendered. + * + * Shared by the browser harness (`main.tsx`) and the headless benchmarks (`bench/`), so the two + * cannot drift. They previously lived in two places and cross-referencing them was only valid by + * convention. + * + * The ladder fixtures (`wcCards` plus the hand-written controls in `controls.tsx`) must all render + * identical content. That equivalence is what makes the comparison mean anything, and it is + * asserted in `tests/ladder.test.tsx` rather than left to reviewer discipline. + */ +import type { SchemaNode } from '@we/schema-shared'; + +/** Values the fixtures read through `$store`. Plain data — this harness has no backend. */ +export const benchStore = { + stringValue: 'hello', + numberValue: 42, + boolTrue: true, + boolFalse: false, + counter: 0, + fruits: [ + { name: 'Apple', color: 'red', emoji: '🍎' }, + { name: 'Banana', color: 'yellow', emoji: '🍌' }, + { name: 'Cherry', color: 'red', emoji: '🍒' }, + { name: 'Grape', color: 'purple', emoji: '🍇' }, + ], + list100: Array.from({ length: 100 }, (_, i) => ({ + name: `Item ${i + 1}`, + category: `Category ${String.fromCharCode(65 + (i % 5))}`, + })), + groups: Array.from({ length: 10 }, (_, g) => ({ + name: `Group ${g + 1}`, + items: Array.from({ length: 10 }, (_, i) => ({ + label: `Item ${g * 10 + i + 1}`, + detail: `detail-${g}-${i}`, + })), + })), +}; + +/** + * Cards in each ladder rung. Shared with `controls.tsx` so the four rungs cannot drift in size. + * + * 400 rather than 100 because `total` is bounded below by the frame the double-`rAF` waits for + * (~32ms on a 60Hz display). At 100 cards, three of the four rungs finished inside that budget and + * reported identical totals despite one of them doing 17ms of JS work — the column could not + * differentiate them at all. At 400 every rung clears the floor, so `total` means something again. + */ +export const LADDER_COUNT = 400; + +/** + * Posts in each realistic-ladder rung. 50 × 16 nodes ≈ 800 nodes, comparable in size to the simple + * ladder's 400 × 3, so the two are measuring the same amount of work in different shapes. + */ +export const REALISTIC_COUNT = 50; + +const range = (n: number) => Array.from({ length: n }, (_, i) => i + 1); + +const grid = (min: string, gap: string) => ({ + display: 'grid', + 'grid-template-columns': `repeat(auto-fill, minmax(${min}, 1fr))`, + gap, +}); + +// --------------------------------------------------------------------------- +// Card factories +// --------------------------------------------------------------------------- + +/** All-static props, no tokens — isolates the cost of walking and mounting. */ +export function staticCard(id: number): SchemaNode { + return { + type: 'Column', + props: { p: '300', gap: '200', bg: 'neutral-0', r: '300', border: '1px solid neutral-200' }, + children: [ + { type: 'we-text', props: { text: `Card ${id}`, fontSize: '400', fontWeight: '600', color: 'neutral-800' } }, + { type: 'we-text', props: { text: `Description for card number ${id}`, fontSize: '300', color: 'neutral-600' } }, + { type: 'we-text', props: { text: `Detail line ${id}`, fontSize: '200', color: 'neutral-400' } }, + ], + }; +} + +/** One or two `$store` / `$concat` tokens per card. */ +export function tokenCard(id: number): SchemaNode { + return { + type: 'Column', + props: { p: '300', gap: '200', bg: 'neutral-0', r: '300' }, + children: [ + { + type: 'we-text', + props: { fontSize: '400', fontWeight: '600', color: 'neutral-800' }, + children: [{ $concat: ['Card ', { $store: 'benchStore.stringValue' }, ` #${id}`] }], + }, + { + type: 'we-text', + props: { + fontSize: '300', + color: { $if: { condition: { $store: 'benchStore.boolTrue' }, then: 'neutral-600', else: 'danger-600' } }, + }, + children: [{ $concat: ['Count: ', { $store: 'benchStore.numberValue' }] }], + }, + ], + }; +} + +/** Deeply composed tokens — `$if($and($eq($store,…), $not(…)))`. */ +export function heavyTokenCard(id: number): SchemaNode { + return { + type: 'Column', + props: { + p: '300', + gap: '200', + r: '300', + bg: { + $if: { + condition: { + $and: [ + { $eq: [{ $store: 'benchStore.stringValue' }, 'hello'] }, + { $not: { $store: 'benchStore.boolFalse' } }, + ], + }, + then: 'neutral-0', + else: 'danger-50', + }, + }, + }, + children: [ + { + type: 'we-text', + props: { + fontSize: '400', + fontWeight: '600', + color: { + $if: { + condition: { + $or: [ + { $eq: [{ $store: 'benchStore.numberValue' }, 42] }, + { $ne: [{ $store: 'benchStore.stringValue' }, 'goodbye'] }, + ], + }, + then: 'primary-700', + else: 'danger-700', + }, + }, + }, + children: [ + { + $concat: [ + 'Heavy #', + `${id}`, + ' — ', + { + $if: { + condition: { $store: 'benchStore.boolTrue' }, + then: { $store: 'benchStore.stringValue' }, + else: 'fallback', + }, + }, + ], + }, + ], + }, + { + type: 'we-text', + props: { fontSize: '300', color: 'neutral-500' }, + children: [ + { + $concat: [ + 'Status: ', + { + $if: { + condition: { + $and: [{ $store: 'benchStore.boolTrue' }, { $not: { $store: 'benchStore.boolFalse' } }], + }, + then: 'active', + else: 'inactive', + }, + }, + ], + }, + ], + }, + ], + }; +} + +/** + * The ladder fixture: one Column + a we-text + a we-button. + * + * `controls.tsx` reimplements exactly this three ways (raw DOM, plain Solid, hand-written Solid + + * design system). Change one, change all four. + */ +export function wcCard(id: number): SchemaNode { + return { + type: 'Column', + props: { p: '200', gap: '200', bg: 'neutral-0', r: '200' }, + children: [ + { type: 'we-text', props: { text: `WC ${id}`, fontSize: '300', color: 'neutral-700' } }, + { type: 'we-button', props: { text: `Action ${id}`, variant: 'outline', size: 'sm' } }, + ], + }; +} + +/** + * Per-post content for the realistic ladder. Varied deliberately: names, body lengths and badge + * variants all differ, so the fixture is not 400 copies of one string. + */ +export const POST_AUTHORS = ['Ada Lovelace', 'Bo', 'Grace Hopper', 'Kai', 'Margaret Hamilton']; +export const POST_BADGES = ['primary', 'success', 'warning', 'neutral']; +export const POST_BODIES = [ + 'A short note.', + 'Something a little longer, with enough text to wrap onto a second line in most layouts.', + 'A middling amount of body copy — more than a sentence, less than an essay.', + 'One line.', +]; + +export function postContent(id: number) { + return { + author: POST_AUTHORS[id % POST_AUTHORS.length], + initials: POST_AUTHORS[id % POST_AUTHORS.length].slice(0, 2), + time: `${(id % 23) + 1}h ago`, + badge: POST_BADGES[id % POST_BADGES.length], + badgeLabel: id % 3 === 0 ? 'New' : 'Updated', + title: `Post ${id} — ${POST_AUTHORS[id % POST_AUTHORS.length].split(' ')[0]}'s update`, + body: POST_BODIES[id % POST_BODIES.length], + likes: `${(id * 7) % 140}`, + comments: `${(id * 3) % 40}`, + }; +} + +/** + * A realistic feed post: 16 nodes, four component types, three levels of nesting, and content that + * varies per card. + * + * The simple ladder card (`wcCard`) is one Column wrapping a text and a button — clean for isolating + * layer costs, but a fair reviewer can object that layer attribution on trivial uniform content may + * not generalise. This exists to answer that: the same four rungs, measured on something shaped like + * a page someone would actually build. + * + * `controls.tsx` reimplements this exactly. Change one, change all of them — + * `tests/ladder.test.tsx` will fail if they diverge. + */ +export function realisticCard(id: number): SchemaNode { + const c = postContent(id); + return { + type: 'Column', + props: { p: '300', gap: '200', bg: 'neutral-0', r: '300', border: '1px solid neutral-200' }, + children: [ + { + type: 'Row', + props: { gap: '200', ay: 'center' }, + children: [ + { type: 'we-avatar', props: { initials: c.initials, size: 'sm' } }, + { + type: 'Column', + props: { gap: '0' }, + children: [ + { type: 'we-text', props: { text: c.author, fontWeight: '600', color: 'neutral-800' } }, + { type: 'we-text', props: { text: c.time, fontSize: '200', color: 'neutral-400' } }, + ], + }, + { type: 'we-badge', props: { variant: c.badge, size: 'sm' }, children: [c.badgeLabel] }, + ], + }, + { type: 'we-text', props: { text: c.title, fontSize: '400', fontWeight: '600', color: 'neutral-900' } }, + { type: 'we-text', props: { text: c.body, fontSize: '300', color: 'neutral-600' } }, + { + type: 'Row', + props: { gap: '300', ay: 'center' }, + children: [ + { + type: 'we-button', + props: { variant: 'ghost', size: 'sm' }, + children: [{ type: 'we-icon', props: { name: 'heart' } }], + }, + { type: 'we-text', props: { text: c.likes, fontSize: '200', color: 'neutral-500' } }, + { + type: 'we-button', + props: { variant: 'ghost', size: 'sm' }, + children: [{ type: 'we-icon', props: { name: 'chat-circle' } }], + }, + { type: 'we-text', props: { text: c.comments, fontSize: '200', color: 'neutral-500' } }, + ], + }, + ], + }; +} + +/** Column/Row only — no custom elements, so it isolates the Solid-component path. */ +export function solidCard(id: number): SchemaNode { + return { + type: 'Column', + props: { p: '200', gap: '100', bg: 'neutral-0', r: '200', border: '1px solid neutral-100' }, + children: [ + { + type: 'Row', + props: { gap: '200', ay: 'center' }, + children: [ + { type: 'Column', props: { width: '8px', height: '8px', r: 'full', bg: 'primary-400' } }, + { + type: 'Column', + children: [{ type: 'we-text', props: { text: `Solid ${id}`, fontSize: '300', color: 'neutral-700' } }], + }, + ], + }, + ], + }; +} + +/** 100 nodes all bound to one `$store` value — the fixture the update benchmark mutates. */ +export function boundCard(id: number): SchemaNode { + return { + type: 'Column', + props: { p: '200', gap: '100', bg: 'neutral-0', r: '200' }, + children: [ + { type: 'we-text', props: { fontSize: '200', color: 'neutral-500' }, children: [`Cell ${id}`] }, + { + type: 'we-text', + props: { fontWeight: '600', color: 'primary-700' }, + children: [{ $concat: ['#', { $store: 'benchStore.counter' }] }], + }, + ], + }; +} + +function deepNest(depth: number, current = 0): SchemaNode { + const child: SchemaNode = + current >= depth + ? { type: 'we-text', props: { text: `Depth ${current}`, fontSize: '200', color: 'primary-600' } } + : deepNest(depth, current + 1); + return { + type: current % 2 === 0 ? 'Column' : 'Row', + props: { p: '100', gap: '100', ...(current === 0 ? { bg: 'neutral-0', r: '300' } : {}) }, + children: [{ type: 'we-text', props: { text: `Level ${current}`, fontSize: '200', color: 'neutral-400' } }, child], + }; +} + +/** Wrap cards in a grid container. */ +export function cardGrid(count: number, factory: (id: number) => SchemaNode, min = '150px', gap = '6px'): SchemaNode { + return { + type: 'Column', + props: { gap: '200', styles: grid(min, gap) }, + children: range(count).map(factory), + }; +} + +// --------------------------------------------------------------------------- +// The suite +// --------------------------------------------------------------------------- + +export type Fixture = { + key: string; + label: string; + /** Schema tree, or `null` for the hand-written controls, which render a component instead. */ + node: SchemaNode | null; + /** Registry key of a control component to render instead of a schema tree. */ + control?: string; + /** Measure a reactive update burst after mount sampling. */ + measuresUpdate?: boolean; + /** Part of a like-for-like ladder: 'simple' (one Column + text + button) or 'realistic' + * (page-shaped posts). Reporting both shows whether the layer costs hold as content gets more + * complex, or only isolate cleanly on trivial uniform content. */ + ladder?: 'simple' | 'realistic'; +}; + +export const fixtures: Fixture[] = [ + { key: 'minimal', label: 'Minimal — measurement floor', node: { type: 'we-text', props: { text: 'One node.' } } }, + + // --- The ladder: identical content, four ways ----------------------------- + { key: 'raw-dom', label: `Raw DOM (${LADDER_COUNT})`, node: null, control: 'RawDomCards', ladder: 'simple' }, + { + key: 'plain-solid', + label: `Plain Solid (${LADDER_COUNT})`, + node: null, + control: 'PlainSolidCards', + ladder: 'simple', + }, + { + key: 'hand-written', + label: `Solid + design system (${LADDER_COUNT})`, + node: null, + control: 'HandWrittenCards', + ladder: 'simple', + }, + { + key: 'hand-written-prop', + label: `Solid + design system, prop: (${LADDER_COUNT})`, + node: null, + control: 'HandWrittenCardsPropBound', + ladder: 'simple', + }, + { key: 'schema', label: `WE templates (${LADDER_COUNT})`, node: cardGrid(LADDER_COUNT, wcCard), ladder: 'simple' }, + + // --- The realistic ladder: same rungs, page-shaped content ---------------- + { + key: 'r-hand-written', + label: `Solid + design system (${REALISTIC_COUNT} posts)`, + node: null, + control: 'RealisticCards', + ladder: 'realistic', + }, + { + key: 'r-hand-written-prop', + label: `Solid + design system, prop: (${REALISTIC_COUNT} posts)`, + node: null, + control: 'RealisticCardsPropBound', + ladder: 'realistic', + }, + { + key: 'r-schema', + label: `WE templates (${REALISTIC_COUNT} posts)`, + node: { + type: 'Column', + props: { + gap: '200', + styles: { display: 'grid', 'grid-template-columns': 'repeat(auto-fill, minmax(320px, 1fr))', gap: '12px' }, + }, + children: Array.from({ length: REALISTIC_COUNT }, (_, i) => realisticCard(i + 1)), + }, + ladder: 'realistic', + }, + + // --- Renderer scaling and feature cost ------------------------------------ + { key: 'static-small', label: 'Static 50', node: cardGrid(50, staticCard, '200px', '8px') }, + { key: 'static-large', label: 'Static 200', node: cardGrid(200, staticCard, '180px', '8px') }, + { key: 'static-extreme', label: 'Static 1000', node: cardGrid(1000, staticCard, '180px', '8px') }, + { key: 'tokens-light', label: 'Tokens light (50)', node: cardGrid(50, tokenCard, '200px', '8px') }, + { key: 'tokens-heavy', label: 'Tokens heavy (50)', node: cardGrid(50, heavyTokenCard, '220px', '8px') }, + { key: 'solid-components', label: 'Solid components (100)', node: cardGrid(100, solidCard) }, + { key: 'deep-nesting', label: 'Deep nesting (30)', node: deepNest(30) }, + { + key: 'each-flat', + label: '$each flat (100)', + node: { + type: 'Column', + props: { gap: '200', styles: grid('200px', '8px') }, + children: [ + { + type: '$each', + props: { items: { $store: 'benchStore.list100' } }, + children: [ + { + type: 'Column', + props: { p: '300', gap: '100', bg: 'neutral-0', r: '300' }, + children: [ + { type: 'we-text', props: { color: 'neutral-700' }, children: ['$item.name'] }, + { type: 'we-text', props: { fontSize: '200', color: 'neutral-400' }, children: ['$item.category'] }, + ], + }, + ], + }, + ], + }, + }, + { + key: 'each-nested', + label: 'Nested $each (10×10)', + node: { + type: 'Column', + props: { gap: '300' }, + children: [ + { + type: '$each', + props: { items: { $store: 'benchStore.groups' }, as: 'group' }, + children: [ + { + type: 'Column', + props: { p: '300', gap: '200', bg: 'neutral-0', r: '300' }, + children: [ + { + type: 'we-text', + props: { fontWeight: '600', color: 'neutral-700', fontSize: '400' }, + children: ['$group.name'], + }, + { + type: '$each', + props: { items: '$group.items', as: 'sub' }, + children: [ + { + type: 'Row', + props: { gap: '200', pl: '300', ay: 'center' }, + children: [ + { type: 'we-text', props: { fontSize: '300' }, children: ['$sub.label'] }, + { + type: 'we-text', + props: { fontSize: '200', color: 'neutral-400' }, + children: ['$sub.detail'], + }, + ], + }, + ], + }, + ], + }, + ], + }, + ], + }, + }, + { key: 'update-perf', label: 'Reactive update (100)', node: cardGrid(100, boundCard), measuresUpdate: true }, +]; diff --git a/apps/playgrounds/solid/render-bench/src/main.tsx b/apps/playgrounds/solid/render-bench/src/main.tsx new file mode 100644 index 00000000..6ac15543 --- /dev/null +++ b/apps/playgrounds/solid/render-bench/src/main.tsx @@ -0,0 +1,276 @@ +/** + * WE render benchmarks — browser harness. + * + * Measures what each layer of WE's UI stack costs, in a real browser, including paint. The four + * "ladder" fixtures render identical content with one layer removed at each step, so the gap + * between adjacent rows is that layer's cost. + * + * Deliberately free of AD4M, stores, an app shell, and any embedded app — 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 also 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 that `portable-ui-slice` proves. + * + * WHY THE CHROME IS PLAIN HTML AND NOT THE DESIGN SYSTEM + * + * Tempting to dogfood `we-button` and `we-text` here. Deliberately not done: the harness must not be + * built from the thing it measures. The status text updates before every sample — as a plain span + * that is a synchronous text write costing microseconds and landing before the clock starts, but as + * a Lit element it is an async microtask update that can land *inside* the measured window. An + * animating `we-spinner` in the previous harness did exactly that, inflating every result in + * proportion to DOM size until it was found and removed. + * + * The element counts depend on this too: `runner.ts` counts the whole document and subtracts a + * baseline, which only holds while the chrome's element count is stable across a sample. + * + * Run: pnpm --filter @we/playground-render-bench dev (http://localhost:3300) + * Prod: pnpm --filter @we/playground-render-bench build && … preview + */ +import '@we/primitives'; // side-effect: defines all we-* custom elements +import '@we/tokens/css'; // design-token CSS variables + +import { RenderSchema } from '@we/schema-solid'; +import { createSignal, For, Show } from 'solid-js'; +import { createStore, reconcile } from 'solid-js/store'; +import { render } from 'solid-js/web'; + +import { controls } from './controls'; +import { benchStore, fixtures } from './fixtures'; +import { registry } from './registry'; +import { type Result, SAMPLES, summarise, timeRender, timeUpdates, WARMUP } from './runner'; + +const [results, setResults] = createStore>({}); +const [status, setStatus] = createSignal(''); +const [progress, setProgress] = createSignal(0); + +// The store the fixtures read through `$store`. `counter` is a signal so the update benchmark has +// something real to invalidate; everything else is static data. +const [counter, setCounter] = createSignal(0); +const stores = { + benchStore: { + ...benchStore, + get counter() { + return counter(); + }, + }, +}; + +/** Where fixtures mount. Kept out of the results UI so the harness never measures its own output. */ +const stage = document.createElement('div'); +document.body.appendChild(stage); + +/** Elements present with nothing mounted — subtracted so counts are fixture-only. */ +const baselineElements = () => document.querySelectorAll('*').length; + +/** + * "Show" — mount a fixture and leave it up, so it can be looked at. + * + * This is the check the benchmark cannot perform on itself: whether a fixture actually renders what + * it claims to. A control that silently rendered nothing would post excellent timings, and only + * eyes catch that. `tests/ladder.test.tsx` guards the four ladder rungs automatically; this covers + * everything else, and lets the rungs be compared visually side by side. + * + * Always torn down before sampling — a fixture left mounted would sit in the element counts and in + * the browser's style and layout work for every subsequent measurement. + */ +const [shown, setShown] = createSignal(null); +let disposeShown: (() => void) | undefined; + +function clearShown() { + disposeShown?.(); + disposeShown = undefined; + setShown(null); +} + +function show(fixtureKey: string) { + const already = shown() === fixtureKey; + clearShown(); + if (already) return; // clicking Show again hides it + disposeShown = mountFixture(fixtureKey); + setShown(fixtureKey); +} + +function mountFixture(fixtureKey: string): () => void { + const fixture = fixtures.find((f) => f.key === fixtureKey)!; + if (fixture.control) { + const Control = controls[fixture.control]; + return render(() => , stage); + } + return render(() => , stage); +} + +async function runOne(fixtureKey: string): Promise { + clearShown(); + const fixture = fixtures.find((f) => f.key === fixtureKey)!; + const samples = []; + for (let i = 0; i < WARMUP + SAMPLES; i++) { + setStatus(`${fixture.label} — sample ${i + 1}/${WARMUP + SAMPLES}`); + const base = baselineElements(); + samples.push(await timeRender(stage, () => mountFixture(fixtureKey), base)); + } + + let updates: number[] = []; + if (fixture.measuresUpdate) { + setStatus(`${fixture.label} — update burst`); + const dispose = mountFixture(fixtureKey); + updates = await timeUpdates(stage, () => setCounter((c) => c + 1)); + dispose(); + } + + // Discard the warm-up sample: one-time JIT and Lit template compilation land there. + return summarise(fixture.key, fixture.label, samples.slice(WARMUP), updates); +} + +/** + * Wipe every result. + * + * `setResults({})` does NOT do this: setting a Solid store to a plain object *merges* it, so an + * empty object merges nothing and every existing key survives. `reconcile` diffs against the new + * value and removes what is missing. + */ +function clearResults() { + setResults(reconcile({})); +} + +async function runAll() { + clearShown(); + clearResults(); + for (const [i, fixture] of fixtures.entries()) { + setProgress(Math.round((i / fixtures.length) * 100)); + setResults(fixture.key, await runOne(fixture.key)); + } + setProgress(0); + setStatus(''); +} + +async function runSingle(fixtureKey: string) { + const result = await runOne(fixtureKey); + setResults(fixtureKey, result); + setStatus(''); +} + +const ms = (n: number) => n.toFixed(1); + +/** Right-aligned numeric cell. Generous horizontal padding — the columns are narrow and the headers + * ran together without it, which makes a results table easy to misread. */ +const num = 'text-align:right;padding:3px 0 3px 18px;white-space:nowrap'; +const numMuted = `${num};color:var(--we-color-neutral-500)`; + +function ResultRow(props: { fixtureKey: string; label: string }) { + const r = () => results[props.fixtureKey] as Result | undefined; + const busy = () => !!status(); + return ( + + + {props.label} + + + {/* Plain {' '} + + + {r() ? ms(r()!.median.total) : ''} + {r() ? ms(r()!.jsWork) : ''} + {r() ? ms(r()!.median.build) : ''} + {r() ? ms(r()!.median.flush) : ''} + {r() ? ms(r()!.median.paint) : ''} + {r()?.median.elements ?? ''} + {r()?.median.customElements ?? ''} + {r() ? `${r()!.spreadPct}%` : ''} + {r()?.updateMs != null ? ms(r()!.updateMs!) : ''} + + ); +} + +function Table(props: { title: string; keys: string[]; note?: string }) { + // Driven by the fixture list, not by what has been measured — otherwise the per-row Run and Show + // buttons would not exist until after a full run, which is when they are least useful. + const rows = () => props.keys.map((k) => fixtures.find((f) => f.key === k)!); + return ( + +

{props.title}

+ +

{props.note}

+
+ + + + + + + + + + + + + + + + + {(f) => } + +
Fixture + totalJS workbuildflushpaintelcustomspreadupdate
+
+ ); +} + +function App() { + const simple = fixtures.filter((f) => f.ladder === 'simple').map((f) => f.key); + const realistic = fixtures.filter((f) => f.ladder === 'realistic').map((f) => f.key); + const rest = fixtures.filter((f) => !f.ladder && f.key !== 'minimal').map((f) => f.key); + + return ( +
+

WE render benchmarks

+

+ Median of {SAMPLES} samples ({WARMUP} discarded as warm-up). JS work = build + flush, measured + directly and trustworthy at any scale. paint spans a double requestAnimationFrame, so it can + never report less than one frame of waiting — see the measurement floor below, and read totals near it with + suspicion. update is JS work only, deliberately excluding the frame wait. +

+ +
+ + + + + {status()} · {progress()}% + + +
+ + +
+
+
+ + ); +} + +render(() => , document.getElementById('root')!); diff --git a/apps/playgrounds/solid/render-bench/src/registry.tsx b/apps/playgrounds/solid/render-bench/src/registry.tsx new file mode 100644 index 00000000..5e8dffc8 --- /dev/null +++ b/apps/playgrounds/solid/render-bench/src/registry.tsx @@ -0,0 +1,24 @@ +/** + * Component registries for the benchmark fixtures. + * + * `we-*` primitives are custom elements — they render as tag strings once `@we/primitives` is + * imported for its side effects, so they need no entry here. Only PascalCase `@we/components` do. + */ +import { Column, Row } from '@we/components/solid'; +import type { ComponentRegistry } from '@we/schema-solid'; +import type { JSX } from 'solid-js'; + +export const registry: ComponentRegistry = { Column, Row }; + +/** + * Isolates the schema walk from the design system: `Column`/`Row` become trivial pass-through divs, + * so what remains is the renderer's own cost. + * + * Note this does NOT stub `we-*` — hyphenated types mount as real custom elements regardless of the + * registry, so any fixture containing them still pays full Lit cost here. Only fixtures built from + * `Column`/`Row` alone give a clean renderer-only reading. The browser ladder is the better + * decomposition; this exists for fast headless before/after checks on renderer changes. + */ +const Passthrough = (props: { children?: JSX.Element }) =>
{props.children}
; + +export const stubRegistry: ComponentRegistry = { Column: Passthrough, Row: Passthrough }; diff --git a/apps/playgrounds/solid/render-bench/src/runner.ts b/apps/playgrounds/solid/render-bench/src/runner.ts new file mode 100644 index 00000000..2bb54b39 --- /dev/null +++ b/apps/playgrounds/solid/render-bench/src/runner.ts @@ -0,0 +1,181 @@ +/** + * The measurement runner. + * + * Renders a fixture, times it in four phases, and reports the median of N samples. + * + * WHY THERE IS NO ROUTER + * + * The previous incarnation of this harness lived inside the main app and navigated between routes + * to switch fixtures. That brought a chain of problems that were all really one problem — a router + * will not re-render the route you are already on: + * + * - repeat samples needed an "/idle" route to bounce through, plus a settle delay + * - a click handler that navigated could silently pre-empt the sampler's own navigation, and the + * run would hang forever on sample 1 + * - the results panel rendered inside the measured route, so the suite measured its own output + * + * Here a fixture is just a value. Swapping it to `null` unmounts cleanly and synchronously; there + * is no navigation, no settle delay, and nothing to race. Every one of those failure modes is + * structurally impossible rather than guarded against. + */ +/** Wall-clock marks for one render. Durations are derived, not measured, so they always sum. */ +export type Phases = { + /** + * Constructing the tree and inserting it — schema walk, prop resolution, DOM creation, insertion, + * custom-element upgrade. + * + * Not split into build/mount as the previous harness did. That split relied on a probe *component + * inside the tree*, whose body ran after its siblings were constructed and whose onMount ran after + * insertion. Here `render()` constructs and inserts in one synchronous call, so there is no + * boundary to measure between them, and a "mount" column would be reporting something else. + */ + build: number; + /** Attached → Lit's async first render and the design-system prop pipeline have flushed. */ + flush: number; + /** + * Flushed → the next frame is committed. + * + * Measured across a double `requestAnimationFrame`, so it CANNOT report less than one frame + * interval of waiting. On a near-empty fixture this reads ~20ms — that is scheduling latency, + * not work. Only interpretable well above one frame; the `minimal` fixture measures the floor so + * it can be stated rather than guessed at. + */ + paint: number; + total: number; +}; + +export type Sample = Phases & { elements: number; customElements: number }; + +export type Result = { + key: string; + label: string; + median: Sample; + /** Trimmed range of warm samples (slowest dropped) as a percentage of the median total. */ + spreadPct: number; + /** build + flush. Directly measured with no `rAF`, so trustworthy at any scale. */ + jsWork: number; + sampleCount: number; + /** Median of the reactive-update burst, for fixtures that measure one. */ + updateMs: number | null; +}; + +/** Discarded: the first render of a fixture pays one-time JIT and Lit template compilation. */ +export const WARMUP = 1; +export const SAMPLES = 5; +const UPDATE_SAMPLES = 5; + +const nextFrame = () => new Promise((r) => requestAnimationFrame(() => r())); +const microtask = () => new Promise((r) => queueMicrotask(r)); + +type LitElement = Element & { updateComplete?: Promise }; + +/** + * Render once and time it. + * + * `mountFixture` must build and attach the tree synchronously, and return a dispose function. + * Everything it does lands in Build + Mount; nothing may be deferred, or the attribution is wrong. + */ +export async function timeRender( + container: HTMLElement, + mountFixture: () => () => void, + baselineElements: number, +): Promise { + const t0 = performance.now(); + const dispose = mountFixture(); + const built = performance.now(); + + // Lit renders on a microtask. Collect the pending updates first, then await them: walking + // thousands of elements to find them costs real time, and it is *instrumentation* — a user never + // pays it. Excluded from every phase, which is why `total` is the sum of the phases rather than + // raw elapsed time. + const pending = Array.from(container.querySelectorAll('*')) + .map((el) => (el as LitElement).updateComplete) + .filter(Boolean); + const collected = performance.now(); + await Promise.all(pending); + await microtask(); + const flushed = performance.now(); + + // A single rAF fires *before* paint. Two spans a committed frame. + await nextFrame(); + await nextFrame(); + const painted = performance.now(); + + const all = document.querySelectorAll('*'); + let customElements = 0; + for (const el of all) if (el.tagName.includes('-')) customElements++; + + const build = built - t0; + const flush = flushed - collected; + const paint = painted - flushed; + dispose(); + return { + build, + flush, + paint, + total: build + flush + paint, + elements: all.length - baselineElements, + customElements, + }; +} + +const median = (xs: T[], by: (x: T) => number): T => + [...xs].sort((a, b) => by(a) - by(b))[Math.floor((xs.length - 1) / 2)]; + +/** + * Median by total rather than per-field, so every reported row is internally coherent. A per-field + * median would produce a row whose phases do not add up to its own total, each field having come + * from a different sample. + */ +export function summarise(key: string, label: string, samples: Sample[], updates: number[]): Result { + const med = median(samples, (s) => s.total); + const totals = [...samples.map((s) => s.total)].sort((a, b) => a - b); + // Drop the slowest before measuring spread: plain min–max over five samples is dominated by a + // single GC pause, which made it useless as the error bar it is meant to be. + const trimmed = totals.length > 2 ? totals.slice(0, -1) : totals; + const spreadPct = + med.total > 0 && trimmed.length ? Math.round(((trimmed[trimmed.length - 1] - trimmed[0]) / med.total) * 100) : 0; + + return { + key, + label, + median: med, + spreadPct, + jsWork: med.build + med.flush, + sampleCount: samples.length, + updateMs: updates.length ? median(updates, (x) => x) : null, + }; +} + +/** + * Time a reactive update burst against an already-mounted fixture, as **JS work**. + * + * Render cost and update cost are different questions: the renderer allocates a memo per prop and, + * on the web-component path, an effect per prop — so a template can render acceptably and still + * update badly. Update cost is also paid during interaction, where users notice it. + * + * Deliberately does NOT wait for paint. An earlier version bracketed a double `requestAnimationFrame` + * and reported ~33ms, which was read as a worrying figure for months. It was the frame floor: the + * same fixture measured this way does about 1ms of actual work. Measuring to the end of the + * microtask keeps this comparable with the `jsWork` column and free of scheduling latency. + */ +export async function timeUpdates(container: HTMLElement, bump: () => void): Promise { + const out: number[] = []; + for (let i = 0; i < WARMUP + UPDATE_SAMPLES; i++) { + const t0 = performance.now(); + bump(); + const synced = performance.now(); + + // Same exclusion as timeRender: the walk is instrumentation, not update cost. + const pending = Array.from(container.querySelectorAll('*')) + .map((el) => (el as LitElement).updateComplete) + .filter(Boolean); + const collected = performance.now(); + await Promise.all(pending); + await microtask(); + const flushed = performance.now(); + + out.push(synced - t0 + (flushed - collected)); + } + return out.slice(WARMUP); +} diff --git a/apps/playgrounds/solid/render-bench/tests/ladder.test.tsx b/apps/playgrounds/solid/render-bench/tests/ladder.test.tsx new file mode 100644 index 00000000..d7c4e713 --- /dev/null +++ b/apps/playgrounds/solid/render-bench/tests/ladder.test.tsx @@ -0,0 +1,176 @@ +/** + * Guards the ladder's equivalence. + * + * The four ladder rungs only mean something if they render the same content. Nothing about the + * code enforces that — `wcCard` lives in fixtures.ts and the three hand-written controls + * reimplement it in controls.tsx, so an edit to one silently invalidates every published ratio + * while still looking entirely plausible. These tests make that failure loud. + * + * Runs in CI via `pnpm test`. The timing benchmarks deliberately do not. + */ +import '@we/primitives'; + +import { RenderSchema } from '@we/schema-solid'; +import { render } from 'solid-js/web'; +import { afterEach, describe, expect, it } from 'vitest'; + +import { + HandWrittenCards, + HandWrittenCardsPropBound, + PlainSolidCards, + RawDomCards, + RealisticCards, + RealisticCardsPropBound, +} from '../src/controls'; +import { benchStore, cardGrid, fixtures, LADDER_COUNT, REALISTIC_COUNT, wcCard } from '../src/fixtures'; +import { registry } from '../src/registry'; + +const stores = { benchStore }; +let dispose: (() => void) | undefined; +let host: HTMLElement | undefined; + +function mount(el: () => unknown) { + host = document.createElement('div'); + document.body.appendChild(host); + dispose = render(el as never, host); + return host; +} + +afterEach(() => { + dispose?.(); + host?.remove(); + dispose = undefined; + host = undefined; +}); + +/** + * Rendered text, descending into shadow roots. + * + * Plain `textContent` cannot compare the rungs: `we-text` and `we-button` render their content + * inside a shadow root, so the light DOM of the design-system rungs is empty while the raw-DOM and + * plain-Solid rungs put their text in ordinary spans. Walking shadow roots puts all four on the + * same footing. + */ +function text(root: ParentNode): string { + let out = ''; + for (const node of Array.from(root.childNodes)) { + if (node.nodeType === Node.TEXT_NODE) out += node.textContent ?? ''; + else if (node instanceof Element) { + if (node.shadowRoot) out += text(node.shadowRoot); + out += text(node); + } + } + return out.replace(/\s+/g, ' ').trim(); +} + +describe('ladder equivalence', () => { + /** + * Mount a rung, let Lit finish, read its text and element counts, unmount. + * + * The `updateComplete` await is required, not defensive: Lit renders on a microtask, so a + * synchronous read finds every design-system rung empty and the comparison passes or fails for + * the wrong reason. + */ + async function probe(el: () => unknown) { + const host = mount(el); + await Promise.all( + Array.from(host.querySelectorAll('*')) + .map((n) => (n as Element & { updateComplete?: Promise }).updateComplete) + .filter(Boolean), + ); + const all = Array.from(host.querySelectorAll('*')); + const out = { + text: text(host), + elements: all.length, + custom: all.filter((n) => n.tagName.includes('-')).length, + }; + dispose?.(); + host.remove(); + dispose = undefined; + return out; + } + + const schemaRung = () => ; + + it('all five rungs render identical content', async () => { + const rungs = { + 'raw DOM': await probe(RawDomCards), + 'plain Solid': await probe(PlainSolidCards), + 'hand-written + DS': await probe(HandWrittenCards), + 'hand-written + DS, prop:': await probe(HandWrittenCardsPropBound), + 'WE templates': await probe(schemaRung), + }; + + // Sanity: the fixture actually rendered 100 cards, so an all-empty match cannot pass. + expect(rungs['raw DOM'].text).toContain('WC 1'); + expect(rungs['raw DOM'].text).toContain(`Action ${LADDER_COUNT}`); + + // The invariant: every rung is compared against the others, not against a hand-written string + // that could itself be wrong. + for (const [name, r] of Object.entries(rungs)) { + expect(r.text, `${name} renders the same content as raw DOM`).toBe(rungs['raw DOM'].text); + } + }); + + it('the design-system rungs mount the same custom elements', async () => { + const hand = await probe(HandWrittenCards); + const propBound = await probe(HandWrittenCardsPropBound); + const schema = await probe(schemaRung); + + // The prop: rung must be structurally identical to the attribute one, or it is measuring less + // work rather than the same work bound differently — which would read as a spurious win. + expect(propBound.custom).toBe(hand.custom); + expect(propBound.elements).toBe(hand.elements); + + // we-text + we-button per card. Equal counts are what make Flush comparable between the two: + // the schema rung's extra elements are all wrapper divs, which are not custom elements. + expect(hand.custom).toBe(LADDER_COUNT * 2); + expect(schema.custom).toBe(hand.custom); + }); + + it('the schema rung creates roughly twice the elements, all of them wrappers', async () => { + const hand = await probe(HandWrittenCards); + const schema = await probe(schemaRung); + + // Documents the renderer's per-node `display: contents` wrapper. If this ratio moves, the + // renderer's DOM shape changed and the published element counts need revisiting. + expect(schema.elements).toBeGreaterThan(hand.elements); + expect(schema.elements / hand.elements).toBeLessThan(2.5); + }); + + // --- the realistic ladder ------------------------------------------------- + + const realisticSchemaRung = () => { + const node = fixtures.find((f) => f.key === 'r-schema')!.node!; + return ; + }; + + it('all three realistic rungs render identical content', async () => { + const rungs = { + 'hand-written + DS': await probe(RealisticCards), + 'hand-written + DS, prop:': await probe(RealisticCardsPropBound), + 'WE templates': await probe(realisticSchemaRung), + }; + + // Sanity: varied content actually rendered, so an all-empty match cannot pass. The fixture + // deliberately varies author, body length and badge label per post. + expect(rungs['hand-written + DS'].text).toContain('Ada Lovelace'); + expect(rungs['hand-written + DS'].text).toContain('Margaret Hamilton'); + expect(rungs['hand-written + DS'].text).toContain('Updated'); + + for (const [name, r] of Object.entries(rungs)) { + expect(r.text, `${name} matches the hand-written rung`).toBe(rungs['hand-written + DS'].text); + } + }); + + it('the realistic rungs mount the same custom elements', async () => { + const hand = await probe(RealisticCards); + const propBound = await probe(RealisticCardsPropBound); + const schema = await probe(realisticSchemaRung); + + // 12 per post: avatar, 2 text, badge, 2 text, 2 button, 2 icon, 2 text. + expect(hand.custom).toBe(REALISTIC_COUNT * 12); + expect(propBound.custom).toBe(hand.custom); + expect(schema.custom).toBe(hand.custom); + }); +}); diff --git a/packages/schema-system/benchmarks/bench/setProperty.probe.ts b/apps/playgrounds/solid/render-bench/tests/setProperty.test.ts similarity index 92% rename from packages/schema-system/benchmarks/bench/setProperty.probe.ts rename to apps/playgrounds/solid/render-bench/tests/setProperty.test.ts index f92dcae5..501e8d19 100644 --- a/packages/schema-system/benchmarks/bench/setProperty.probe.ts +++ b/apps/playgrounds/solid/render-bench/tests/setProperty.test.ts @@ -8,8 +8,9 @@ * name (`--we-spinner-color`, `--we-markdown-gap`). * * NOTE ON LOCATION: this belongs in @we/primitives, but that package has no test infrastructure at - * all. It sits here because this is the only package already wired for happy-dom + primitives. - * Move it when @we/primitives gets a test setup. + * all — nor do @we/components, @we/design-utils or @we/tokens. It sits here because this is the + * nearest home already wired for happy-dom + primitives, and because leaving a shipped behaviour + * change with no test anywhere is worse. Move it when @we/primitives gets a test setup. */ import '@we/primitives'; diff --git a/packages/schema-system/benchmarks/tsconfig.json b/apps/playgrounds/solid/render-bench/tsconfig.json similarity index 51% rename from packages/schema-system/benchmarks/tsconfig.json rename to apps/playgrounds/solid/render-bench/tsconfig.json index c48606d8..65d2ece5 100644 --- a/packages/schema-system/benchmarks/tsconfig.json +++ b/apps/playgrounds/solid/render-bench/tsconfig.json @@ -5,15 +5,12 @@ "moduleResolution": "bundler", "jsx": "preserve", "jsxImportSource": "solid-js", + "types": ["vite/client"], "strict": true, - "esModuleInterop": true, "skipLibCheck": true, - "forceConsistentCasingInFileNames": true, - "resolveJsonModule": true, + "noEmit": true, "isolatedModules": true, - "types": ["vitest/globals"], - "noEmit": true + "esModuleInterop": true }, - "include": ["bench", "vitest.config.ts"], - "exclude": ["node_modules"] + "include": ["src", "bench", "tests", "vite.config.ts", "vitest.config.ts", "vitest.bench.config.ts"] } diff --git a/apps/playgrounds/solid/render-bench/vite.config.ts b/apps/playgrounds/solid/render-bench/vite.config.ts new file mode 100644 index 00000000..0197f69f --- /dev/null +++ b/apps/playgrounds/solid/render-bench/vite.config.ts @@ -0,0 +1,12 @@ +import { defineConfig } from 'vite'; +import solidPlugin from 'vite-plugin-solid'; + +export default defineConfig({ + plugins: [solidPlugin()], + // Single solid-js instance across app + libraries. Load-bearing for the same reason as the + // sibling portable-ui-slice harness: two instances give two owner graphs, and scheduled effects + // silently stop updating while the initial paint still looks correct. + resolve: { dedupe: ['solid-js', 'solid-js/web', 'solid-js/store'] }, + server: { port: 3300 }, + build: { target: 'esnext' }, +}); diff --git a/apps/playgrounds/solid/render-bench/vitest.bench.config.ts b/apps/playgrounds/solid/render-bench/vitest.bench.config.ts new file mode 100644 index 00000000..6cd15f19 --- /dev/null +++ b/apps/playgrounds/solid/render-bench/vitest.bench.config.ts @@ -0,0 +1,22 @@ +import solidPlugin from 'vite-plugin-solid'; +import { defineConfig } from 'vitest/config'; + +/** + * Headless timing benchmarks. Run on demand (`pnpm bench`), never in CI. + * + * They run as ordinary tests with manual timing rather than through `vitest bench`, which is + * experimental and reported NaN here. Manual sampling also lets this use the same + * median-of-N-with-warm-up discipline as the browser harness. + */ +export default defineConfig({ + plugins: [solidPlugin()], + resolve: { + conditions: ['solid', 'development', 'browser'], + dedupe: ['solid-js', 'solid-js/web', 'solid-js/store'], + }, + test: { + environment: 'happy-dom', + include: ['bench/**/*.bench.{ts,tsx}'], + testTimeout: 180_000, + }, +}); diff --git a/apps/playgrounds/solid/render-bench/vitest.config.ts b/apps/playgrounds/solid/render-bench/vitest.config.ts new file mode 100644 index 00000000..8dcdd00a --- /dev/null +++ b/apps/playgrounds/solid/render-bench/vitest.config.ts @@ -0,0 +1,21 @@ +import solidPlugin from 'vite-plugin-solid'; +import { defineConfig } from 'vitest/config'; + +/** + * Correctness tests — these gate CI (`pnpm test` at the repo root recurses into every package). + * Timing benchmarks deliberately live in a separate config: they are noisy on shared runners and + * must never decide whether a merge is allowed. + */ +export default defineConfig({ + plugins: [solidPlugin()], + resolve: { + // 'browser' is required — vitest otherwise resolves solid-js to its server build, which throws + // "Client-only API called on the server side" as soon as the renderer imports AnimateRenderer. + conditions: ['solid', 'development', 'browser'], + dedupe: ['solid-js', 'solid-js/web', 'solid-js/store'], + }, + test: { + environment: 'happy-dom', + include: ['tests/**/*.test.{ts,tsx}'], + }, +}); diff --git a/docs/README.md b/docs/README.md index b50231ab..d5a78773 100644 --- a/docs/README.md +++ b/docs/README.md @@ -17,6 +17,7 @@ How WE is designed and why. - [Package Conventions](architecture/package-conventions.md) — How packages are structured and named in the monorepo - [Why a Meta-App Instead of Many Separate Apps?](architecture/meta-app-vs-separate-apps.md) — Why shared continuity and cumulative evolution matter - [Practical Examples](architecture/examples.md) — Simple examples of how WE ideas work in practice +- [Performance](architecture/performance.md) — What the template system and design system cost, measured against raw DOM and plain Solid ## Guides diff --git a/docs/architecture/performance.md b/docs/architecture/performance.md new file mode 100644 index 00000000..27a7ca8b --- /dev/null +++ b/docs/architecture/performance.md @@ -0,0 +1,384 @@ +# Render performance: what each layer of the stack costs + +A like-for-like measurement of four ways to render the same page — raw DOM, plain Solid, Solid with +the WE design system, and WE's JSON template system — in a real browser, including paint. + +Written for anyone deciding whether to build on WE templates, and for anyone working on WE who needs +to know where the time actually goes. + +--- + +## Summary + +**WE templates cost about 25% more JavaScript work than hand-writing the same page with the same +components, and about 3.4× a hand-rolled DOM page** — one with none of the design system's theming, +states, encapsulation or accessibility. + +On a page of realistic size, none of that is perceptible. Both finish inside a single frame. + +The 25% is measured on a 50-post feed — the fixture closest to a real page. The table below is the +simpler 400-card fixture, because it is the only one carrying raw-DOM and plain-Solid rungs to +compare against. Medians of three runs: + +| Approach | Time to screen | JS work | +| ------------------------------ | -------------- | ---------- | +| Raw DOM (`createElement`) | 32.5ms | 2.5ms | +| Plain Solid JSX | 32.4ms | 0.8ms | +| Solid + WE design system | 97.1ms | 65.8ms | +| **WE templates (JSON schema)** | **110.3ms** | **78.1ms** | + +- **3.4× raw DOM in time to screen, 31× in JavaScript work.** The gap between those two numbers is + the frame floor: ~33ms of every measurement is the browser waiting for its next frame, which + compresses the totals and not the work. +- **Most of that is the design system, not the template system.** Of the 77.3ms the full stack adds + over plain Solid, the design system is 65.0ms and the template layer 12.3ms. +- **Solid itself is free** — 0.8ms, marginally faster than a `createElement` loop. +- **Below roughly 1,000 elements the choice is unobservable.** Everything finishes within one frame, + so a user cannot tell which approach rendered the page. + +What the template layer buys, for that 12ms: templates that are data rather than code — editable at +runtime, authorable by AI, shareable and forkable without a build step, and safe to accept from +untrusted sources. + +--- + +## Does that hold on realistic content? + +The cards above are one `Column` wrapping a text and a button — clean for isolating layer costs, but +a fair objection is that attribution on trivial uniform content may not generalise. + +So the same rungs were measured again on **50 feed posts**: 800 template nodes, seven component +types, three levels of nesting, and content that varies per card. + +| Approach | Time to screen | JS work | +| ------------------------------ | -------------- | ---------- | +| Solid + WE design system | 63.8ms | 37.7ms | +| **WE templates (JSON schema)** | **64.7ms** | **49.7ms** | + +**The template layer costs 12.0ms here against 12.3ms on the simple cards** — nearly identical, +despite 800 nodes versus 1,200 and a structurally different card. That flatness is the finding: the +template layer's cost tracks neither node count nor node complexity over this range. + +The design-system layer's totals are **not** comparable between the two fixtures, because this one is +smaller: 801 elements against 1,201. Per element the two agree closely — 46µs against 54µs — so the +design system costs about the same per unit of work on both, and the raw millisecond gap is size +rather than content. [Where the cost goes](#where-the-cost-goes) works through the normalisation. + +Time to screen is 63.8 against 64.7ms: indistinguishable, because both sit near the frame floor. + +Raw DOM and plain Solid are deliberately absent here. Hand-rolling an avatar, a badge and icon +buttons stops being a fair equivalent of the real components, and both rungs sit at the floor +regardless. + +--- + +## How to read these numbers + +**JS work is the figure that scales.** Build and flush are bracketed by direct `performance.now()` +reads around synchronous and microtask work, so they are trustworthy at any size. + +**Time to screen is what a user waits**, but it carries a floor that compresses every comparison: + +| Fixture | Elements | Total | of which "paint" | +| ------------------- | -------- | ------ | ---------------- | +| Minimal (one node) | 2 | 33.2ms | 33.1ms | +| Raw DOM (400 cards) | 1,201 | 32.5ms | 29.9ms | + +A page with two elements takes the same time as one with 1,201, because both are bounded by the +frame rather than by work. Paint is measured across a double `requestAnimationFrame`, which cannot +report less than one frame interval of waiting. + +The size of that bound is not a mystery: the displays here run at **60 Hz**, so one frame is 16.7ms +and a double `requestAnimationFrame` spans up to two — **33.3ms**. The measured floor is 33.2ms. On a +120 Hz display it would be roughly half that, and every total would shrink accordingly while the JS +figures stayed the same. + +So: **paint is only interpretable well above ~33ms.** Static 1000's 65–72ms across 8,002 elements is +real work; Raw DOM's 29.9ms across 1,201 is a frame boundary. + +--- + +## Raw data + +Medians of three consecutive runs, each itself the median of five samples. All figures in +milliseconds; ranges across runs in brackets. + +**Note:** these are per-field medians, so a column will not sum exactly to its own total — each field +is the median of that field, and they can come from different runs. Robustness per figure was +preferred over a row that adds up. + +### Realistic ladder — 50 posts, 800 template nodes + +| Metric | + DS (attribute) | + DS (`prop:`) | + Templates | +| --------------- | ---------------- | ---------------- | ---------------- | +| Build | 8.6 (8.4–9.0) | 7.8 (7.6–8.7) | 23.4 (21.0–24.1) | +| Flush | 29.1 (28.6–33.9) | 30.3 (29.3–31.8) | 26.1 (25.6–26.5) | +| Paint _(floor)_ | 20.9 (11.9–26.7) | 24.2 (11.7–26.2) | 15.0 (13.7–25.5) | +| **Total** | **63.8 (49–64)** | **64.3 (49–65)** | **64.7 (64–73)** | +| **JS work** | **37.7 (37–43)** | **38.1 (37–41)** | **49.7 (47–50)** | +| DOM elements | 801 | 801 | 1,602 | +| Custom elements | 600 | 600 | 600 | + +Each post is one `Column` containing a `Row` (avatar, nested `Column` of two texts, badge), a title, +a body, and a `Row` of two icon buttons with counts. Author, body length and badge variant vary per +post. + +### Simple ladder — 400 cards, 1,200 template nodes + +| Metric | Raw DOM | Plain Solid | + DS (attribute) | + DS (`prop:`) | + Templates | +| --------------- | -------- | ----------- | ---------------- | ---------------- | ---------------- | +| Build | 2.5 | 0.8 | 13.0 | 11.2 | 36.4 | +| Flush | 0.0 | 0.0 | 52.8 | 48.5 | 40.2 | +| Paint _(floor)_ | 29.9 | 31.6 | 30.9 | 24.2 | 32.2 | +| **Total** | **32.5** | **32.4** | **97.1** | **81.7** | **110.3** | +| **JS work** | **2.5** | **0.8** | **65.8 (65–67)** | **59.2 (57–61)** | **78.1 (74–81)** | +| DOM elements | 1,201 | 1,201 | 1,201 | 1,201 | 2,402 | +| Custom elements | 0 | 0 | 800 | 800 | 800 | + +Element counts were identical across all runs in both ladders. The design-system variants mount the +same custom elements as the template variant; the template variant's extra elements are its per-node +wrapper `div`s — exactly 2.0× in both ladders. + +Phases: **Build** = construct the tree and insert it. **Flush** = Lit's first render and the +design-system prop pipeline. **Paint** = style, layout, raster, plus the frame wait. + +--- + +## Where the cost goes + +JS work attributed to each layer, from the simple ladder (which has the raw-DOM and plain-Solid rungs +the realistic one omits — both sit at the floor regardless): + +| Layer | Simple ladder (1,201 el) | Realistic ladder (801 el) | What it buys | +| --------------- | ------------------------ | ------------------------- | ----------------------------------------------------------------------------------------------- | +| Raw DOM | 2.5ms | — | — | +| Solid | **−1.7ms** | — | Reactivity, components, JSX — and it is _faster_ than a `createElement` loop | +| Design system | **+65.0ms** | **+36.9ms** | Theming, hover/focus/active states, shadow-DOM encapsulation, accessibility, design-token props | +| Template system | **+12.3ms** | **+12.0ms** | Runtime-editable UI, AI-authorable templates, shareable and forkable without a build | + +The realistic ladder has no plain-Solid rung, so its design-system figure subtracts the simple +ladder's 0.8ms Solid baseline rather than one measured on that fixture. At this size the substitution +is worth well under a millisecond, but it is an approximation and not a measurement. + +**The template layer costs almost exactly the same on both fixtures** — 12.3ms and 12.0ms — despite +one having 1,200 nodes and the other 800, and despite the second using seven component types rather +than three. + +The design-system layer looks like it moves a lot with content — 65.0ms against 36.9ms — but almost +all of that is the fixtures being different sizes. Normalised: + +| Fixture | DS cost | Elements | Props | Per element | Per prop | +| -------------------- | ------- | -------- | ----- | ----------- | -------- | +| Simple (400 cards) | 65.0ms | 1,201 | 4,000 | 54µs | 16µs | +| Realistic (50 posts) | 36.9ms | 801 | 1,950 | 46µs | 19µs | + +"Props" counts every design-system prop, including those on `Column` and `Row`, because all of them +drive design-system work. Finding 3 quotes a smaller pair of numbers (2,400 and 1,450) for the same +fixtures — those are the subset that lands on custom elements as HTML attributes. + +The two normalisations disagree in direction — the simple fixture is 17% more expensive per element +and 14% cheaper per prop — which is what you see when there is no real per-unit difference and the +fixtures simply differ in both element count and props per element. Both residuals are inside the +17–27% run-to-run spread of these particular rows, so the honest reading is that **the design system +costs about the same per unit of work on both fixtures**, and the raw millisecond gap is size. + +That makes the design system **5.3× the template system's cost on the simple fixture and 3.1× on the +realistic one**. That ratio is a property of the pages measured, not a constant: it is elements × +props on one side against template nodes on the other, so a page with fewer, more heavily-styled +components would move it. + +--- + +## Findings + +### 1. Solid is free, and marginally faster than hand-rolled DOM + +Plain Solid does **0.8ms** of JS work against raw DOM's **2.5ms** — its compiler turns JSX into +template cloning, which beats a loop of `document.createElement` calls. Both are far below the frame +floor, so their totals are identical and the difference is visible only in JS work. + +There is no performance argument for dropping to imperative DOM. + +### 2. The design system dominates, and it is concentrated in one phase + +Of the design system's cost, **flush is roughly four fifths** — 52.8 of 65.0ms on the simple ladder, +29.1 of 36.9ms on the realistic one. That is the per-element pipeline turning design-token props into +CSS custom properties. + +This is a Lit and design-system cost, paid identically by hand-written TSX. It is not a +template-system cost, and it is the largest single number in this document. + +### 3. `prop:` bindings help on one fixture and not the other — unexplained + +Binding design-system props as DOM properties (Solid's `prop:` directive) rather than as HTML +attributes avoids a round-trip through `attributeChangedCallback` → converter → property → update +request. Whether that matters turns out to depend on the fixture: + +| Ladder | saving per run | median | +| --------- | ------------------ | ---------- | +| Simple | +8.0, +7.5, +4.5ms | **+7.5ms** | +| Realistic | +2.4, +0.1, −0.4ms | **+0.1ms** | + +On page-shaped content it is worth nothing; on the simple fixture it is worth 11% of design-system JS +work. Binding count does not explain the gap — the realistic fixture has ~1,450 attribute bindings +against the simple one's 2,400, which is the wrong ratio and the wrong direction. + +**No recommendation follows from this.** An earlier draft proposed exposing `prop:` variants in the +generated types (they exist today only for the four object-valued state props, so property binding is +not otherwise expressible without a cast). On evidence that does not replicate, that would mean +changing over 800 `we-*` call sites across the repo on the strength of a single fixture. + +Note that finding 5's anomaly is also much larger on the simple ladder than the realistic one. Both +oddities live in the same place and may share a cause. + +### 4. The tax is a range, and drifts between sessions + +| Ladder | per-run | median | +| --------- | ---------------- | -------- | +| Realistic | +16%, +35%, +25% | **+25%** | +| Simple | +16%, +15%, +23% | **+16%** | + +On top of that within-session range, the figure moves between sessions: the simple ladder's tax +against the `prop:` control measured +25% in one session and +32% in another, from identical code. + +Two ways of computing this tax give different answers, and it matters which is quoted. Dividing the +median JS-work figures in the raw-data tables gives **+32%** on the realistic ladder and **+19%** on +the simple one. The figures above instead compute the tax within each run and take the median of +those: **+25%** and **+16%**. + +The per-run figure is the one used throughout. Each run is a paired comparison — both rungs saw the +same browser session, the same thermal state and the same window — whereas dividing medians combines +numbers from different runs, and with a denominator this noisy that inflates the result. Neither is +wrong, but they are not interchangeable, and the gap between them is itself a measure of how unstable +the design-system rows are. + +So the honest statement is **~+25% with a range of roughly +15% to +35%**, not a point value. Both +design-system rungs are the noisiest non-floor-bound rows in the suite (17–27% within-run spread), +and they are the denominator. + +### 5. The template system flushes faster than hand-written code, and we do not know why + +| Ladder | Hand-written flush | Templates flush | Difference | +| --------- | ------------------ | --------------- | ----------- | +| Simple | 52.8ms | 40.2ms | **−12.6ms** | +| Realistic | 29.1ms | 26.1ms | **−3.0ms** | + +The template path mounts twice the elements and still flushes sooner, consistently, on both fixtures +— but far more so on the simple one. + +Property binding is the obvious explanation and finding 3 rules it out as sufficient. The mechanism +is unknown. **It should not be cited as a template-system advantage until it is understood.** + +### 6. Reactive updates are effectively free + +Propagating a single store change across 100 bound nodes costs **0.2ms**, identical in all three runs. + +Measured as JS work — to the end of the microtask, before paint. Anyone reproducing this should do the +same: bracketing an update with a double `requestAnimationFrame` instead reports ~33ms, which is the +frame floor rather than any work the update did. + +--- + +## How it scales + +Measured on the template path: + +| Fixture | Elements | Time to screen | JS work | +| ----------- | -------- | -------------- | ------- | +| Static 50 | 402 | 32.0ms | 8.5ms | +| Static 200 | 1,602 | 61.9ms | 36.2ms | +| Static 1000 | 8,002 | 248.5ms | 182.3ms | + +JS work scales close to linearly — roughly **23µs per element** across a 20× size range. + +Time to screen tells a different story at the small end: Static 50 sits at the floor, so its 8.5ms of +JS work is invisible to a user. The crossover is around 1,000–1,500 elements, below which the approach +you choose does not affect what anyone perceives. + +--- + +## Is this getting better? + +The design system is the target, not the template system — it is three to five times the cost, and +roughly four fifths of it is one per-element pipeline turning design-token props into CSS custom +properties. + +Recent work there cut flush by 27–42% by skipping redundant CSSOM writes. One further reduction is +identified and unimplemented: all 79 non-state design-system props are registered for attribute +reflection, where roughly seven need it. + +On the template system, the largest untried idea is resolving static props at template-install time so +the render path only walks dynamic ones. 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. + +Paint is irreducible for a given element count; the only lever is rendering fewer elements. + +Attribution measurements behind these, including approaches tried and rejected, are in the +[benchmark harness README](../../apps/playgrounds/solid/render-bench/README.md). + +--- + +## What this does not tell you + +- **Measured on a fast desktop, and nowhere else.** A Ryzen 9 7950X with 64 GB of RAM is close to a + best case. Ratios should travel better than absolute figures, but neither has been checked on a + low-end laptop, a phone, or anything thermally constrained — which is exactly where a UI framework's + cost matters most. Treat the millisecond figures as a favourable bound. +- **Viewport size was not controlled.** Paint only rasterises visible content, so window dimensions + affect it. Every fixture in a given run saw the same window, so comparisons hold, but the absolute + paint figures are not reproducible without matching the window. +- **Two page shapes, both list-like.** The realistic ladder is a feed of posts — richer than the simple + cards, but still a repeated unit in a grid. Neither fixture covers images, long-form text, deeply + asymmetric layouts, or a page assembled from many different sections. +- **No comparison against other stacks.** Everything here compares WE against hand-written WE, which + answers "what do templates cost" but gives no calibration against React, Vue, or a mainstream + component library. +- **The design-system rungs are the least stable measurements** in the suite (17–27% within-run + spread), and they are the denominator of every tax figure. +- **Interaction beyond a single store update is unmeasured.** Finding 6 covers one signal changing + across 100 bound nodes. Larger reactive graphs, list reordering and query-driven updates are not + characterised. + +--- + +## Method + +- **Harness:** `apps/playgrounds/solid/render-bench` — a standalone app with no AD4M, no stores, no app + shell and no embedded apps. None of those are things the renderer depends on, and a team adopting WE + brings their own shell rather than ours. The harness cannot build if the renderer or design system + ever acquires an AD4M dependency, so it doubles as a portability guard. +- **Machine:** AMD Ryzen 9 7950X (16 cores / 32 threads, Zen 4), 64 GB RAM, NVIDIA RTX 5070, Linux + Mint 22.2 (kernel 6.17), displays at 60 Hz. A fast desktop — see the caveat above. +- **Build:** production (`vite build` + `preview`), verified free of Lit's development-mode build. + Dev-server figures are materially different and are not used here. +- **Browser:** Chrome 148, incognito window (extensions disabled), kept focused for the whole run — + `requestAnimationFrame` is throttled in background tabs. Measuring inside VS Code's built-in browser + was ~30% slower and should not be used. +- **Sampling:** each fixture renders 6 times; the first is discarded as warm-up and the median of the + remaining 5 reported, with a trimmed range (slowest dropped) as an error bar. Three consecutive full + runs; figures above are medians across those runs. +- **Instrumentation excluded:** the walk that collects pending Lit updates is measurement overhead a + user never pays, so it is charged to no phase. +- **Equivalence:** every ladder rung is asserted to render identical content and mount identical + custom elements by `tests/ladder.test.tsx`, which runs in CI. The schema fixtures and the + hand-written controls are separate implementations, so a silent divergence would otherwise + invalidate every ratio here while still looking plausible. + +A second, headless harness (`bench/`) runs the same fixtures under happy-dom in seconds. It cannot see +paint and overstates flush by roughly 2.7×, so it is used as a filter during development — a +regression means stop, a win is a hypothesis — and never as a source of published figures. + +**Reproduce:** +`pnpm --filter @we/playground-render-bench build && pnpm --filter @we/playground-render-bench preview` + +--- + +## Bottom line + +Adopting WE templates costs **~+25% in JavaScript work** versus hand-writing the same page with the +same design system — roughly **12ms** for a 50-post feed, with a run-to-run range of +15% to +35%. +That cost is stable across content shape: the same 12ms appeared on a structurally different fixture. + +**The design system is 84% of the full stack's cost, not the template system** — so a team weighing +adoption is mostly weighing the design system, which they would also pay for by hand. diff --git a/packages/app-framework/src/frameworks/solid/components/BenchmarkTimer.tsx b/packages/app-framework/src/frameworks/solid/components/BenchmarkTimer.tsx deleted file mode 100644 index a4d70b79..00000000 --- a/packages/app-framework/src/frameworks/solid/components/BenchmarkTimer.tsx +++ /dev/null @@ -1,78 +0,0 @@ -import { Row } from '@we/components/solid'; -import { onMount } from 'solid-js'; - -/** Raw timestamps handed back to testStore, which derives the phase durations. - * Mirrors `BenchMarks` in testStore.ts. */ -type BenchMarks = { - createdAt: number; - mountedAt: number; - flushedAt: number; - paintedAt: number; - elements: number; - customElements: number; -}; - -type BenchmarkTimerProps = { - /** Receives the raw marks for this render. */ - onComplete: (marks: BenchMarks) => void; - /** Label / route name for this benchmark */ - label?: string; -}; - -/** - * BenchmarkTimer — placed as the last child in a benchmark route. - * - * A dumb probe: it stamps four checkpoints and hands them back raw, leaving the store to derive - * durations. It deliberately does NOT know when navigation started, because it can't — its own body - * runs only after every preceding sibling has been walked and built, which is precisely what makes - * that first boundary measurable. - * - * The checkpoints, and what each interval isolates: - * - * navigation ─▶ createdAt schema walk, token resolution, detached DOM construction - * createdAt ─▶ mountedAt DOM insertion + custom-element upgrade - * mountedAt ─▶ flushedAt Lit's async first render and updated() hooks - * flushedAt ─▶ paintedAt style, layout, paint - * - * Two timing details this depends on: - * - * - Lit updates on a microtask, while Solid's onMount still runs inside the same synchronous task - * as construction. A `queueMicrotask` after onMount therefore lands after Lit has flushed its - * first render — which is what separates per-instance DS-prop work from the browser's paint. - * - `requestAnimationFrame` fires *before* paint, not after. A single rAF (what this component - * previously used) excluded paint from the measurement entirely. The second rAF is the standard - * approximation for "the previous frame has been committed". - */ -export function BenchmarkTimer(props: BenchmarkTimerProps) { - const createdAt = performance.now(); - - onMount(() => { - const mountedAt = performance.now(); - queueMicrotask(() => { - const flushedAt = performance.now(); - requestAnimationFrame(() => { - requestAnimationFrame(() => { - const paintedAt = performance.now(); - // Counted here rather than in the store so the counts describe the frame that was - // actually measured. Single pass, both figures. - const all = document.querySelectorAll('*'); - let customElements = 0; - for (const el of all) if (el.tagName.includes('-')) customElements++; - props.onComplete({ createdAt, mountedAt, flushedAt, paintedAt, elements: all.length, customElements }); - }); - }); - }); - }); - - const label = () => (props.label ? `${props.label} ` : ''); - - return ( - - - Succesfully rendered - - {label()} - - - ); -} diff --git a/packages/app-framework/src/frameworks/solid/layouts/TemplateLayout.tsx b/packages/app-framework/src/frameworks/solid/layouts/TemplateLayout.tsx index 94651a03..2640a096 100644 --- a/packages/app-framework/src/frameworks/solid/layouts/TemplateLayout.tsx +++ b/packages/app-framework/src/frameworks/solid/layouts/TemplateLayout.tsx @@ -94,12 +94,12 @@ const shellViews: Record = { settings: { schema: settingsTemplate }, 'schema-tests': { schema: schemaTestsTemplate, - stores: (base, shellRouteStore) => { + stores: (base) => { const [schemaState, setSchemaState] = createStore(deepClone(schemaTestsTemplate)); const mutations = schemaMutationActions(schemaState, setSchemaState); return { templateStore: { ...base.templateStore, ...mutations }, - testStore: createTestStore(base.adamStore.testPerspective, (to) => shellRouteStore.navigate(to)), + testStore: createTestStore(base.adamStore.testPerspective), $schema: schemaState, }; }, diff --git a/packages/app-framework/src/frameworks/solid/registries/componentRegistry.tsx b/packages/app-framework/src/frameworks/solid/registries/componentRegistry.tsx index 594797ee..20aa4fc9 100644 --- a/packages/app-framework/src/frameworks/solid/registries/componentRegistry.tsx +++ b/packages/app-framework/src/frameworks/solid/registries/componentRegistry.tsx @@ -54,7 +54,6 @@ import type { ComponentRegistry } from '@we/schema-solid'; import { CesiumGlobe, CollapsibleSidebar, GraphWidget, mockGraphData, SpaceSidebarWidget } from '@we/widgets/solid'; import WeCube from '../components/3d/WeCube'; -import { BenchmarkTimer } from '../components/BenchmarkTimer'; import { AiPanel } from '../components/editor/AiPanel'; import { DesignToolbar } from '../components/editor/DesignToolbar'; import { RightPanelContainer } from '../components/editor/RightPanelContainer'; @@ -130,7 +129,6 @@ export const componentRegistry: ComponentRegistry = { DesignToolbar, // Testing - BenchmarkTimer, RerenderLog, // 3D diff --git a/packages/app-framework/src/shared/schemas/shell/SchemaTests.schema.ts b/packages/app-framework/src/shared/schemas/shell/SchemaTests.schema.ts index ee5484b4..800bb300 100644 --- a/packages/app-framework/src/shared/schemas/shell/SchemaTests.schema.ts +++ b/packages/app-framework/src/shared/schemas/shell/SchemaTests.schema.ts @@ -6,14 +6,35 @@ * with routes to the individual test templates. * * Routes: - * /benchmark/* — performance benchmark suite * /tokens/* — schema token integration tests * /mutations/* — updateSchema diffing engine tests + * /queries/* — every $query shape through the QueryIR, against real AD4M * /routing/* — $routes token and multi-level routing tests + * + * WHY THESE LIVE IN THE APP (and the render benchmarks do not) + * + * The rule is: **move what the environment corrupts, keep what the environment validates.** + * + * Render benchmarks moved out to `apps/playgrounds/solid/render-bench` because the surrounding app + * changed their *results* — AD4M subscriptions, the embedded-app iframe and dev-mode frameworks all + * distorted the numbers, and a team adopting WE brings their own shell rather than ours. + * + * These four are correctness tests, so the environment cannot change whether they pass. For two of + * them the app is not incidental, it is the system under test: + * + * - Tokens verifies design tokens against the *live theme system*. Standing this up in an + * isolated harness would mean a stub theme setup — testing something that isn't what + * ships. + * - Routing verifies $routes against the app's *actual* router configuration. + * - Queries validates the AD4M adapter against a real node. In a local-first app the backend is + * the user's own install (SDNA versions, perspective state), which cannot be + * reproduced elsewhere — so being able to run this in situ has real diagnostic value. + * - Mutations needs templateStore. + * + * So moving any of these to the neutral harness would weaken them rather than tidy them. */ import type { RouteSchema, SchemaNode, TemplateSchema } from '@we/schema-shared'; -import { schemaBenchmarkTemplate } from './tests/SchemaBenchmark.schema.ts'; import { schemaMutationsTemplate } from './tests/SchemaMutations.schema.ts'; import { schemaQueriesTemplate } from './tests/SchemaQueries.schema.ts'; import { schemaRoutingTemplate } from './tests/SchemaRouting.schema.ts'; @@ -24,13 +45,6 @@ import { schemaTokensTemplate } from './tests/SchemaTokens.schema.ts'; // --------------------------------------------------------------------------- const sections = [ - { - id: 'benchmarks', - label: 'Benchmarks', - description: 'Performance benchmark suite for schema renderer', - icon: 'timer', - path: '/benchmarks', - }, { id: 'tokens', label: 'Tokens', @@ -106,7 +120,6 @@ export const schemaTestsTemplate: TemplateSchema = { description: 'Schema test suites — benchmark, tokens, mutations, routing', icon: 'flask', stores: { testStore: true, templateStore: true }, - components: ['BenchmarkTimer'], }, type: 'Column', props: { width: '100%', minHeight: '100%', ax: 'center', bg: 'neutral-50' }, @@ -144,8 +157,7 @@ export const schemaTestsTemplate: TemplateSchema = { }, ], routes: [ - { path: '/', redirect: '/benchmarks' }, - testRoute('/benchmarks', schemaBenchmarkTemplate), + { path: '/', redirect: '/tokens' }, testRoute('/tokens', schemaTokensTemplate), testRoute('/mutations', schemaMutationsTemplate), testRoute('/queries', schemaQueriesTemplate), diff --git a/packages/app-framework/src/shared/schemas/shell/tests/SchemaBenchmark.schema.ts b/packages/app-framework/src/shared/schemas/shell/tests/SchemaBenchmark.schema.ts deleted file mode 100644 index 07da50a8..00000000 --- a/packages/app-framework/src/shared/schemas/shell/tests/SchemaBenchmark.schema.ts +++ /dev/null @@ -1,1493 +0,0 @@ -/** - * Schema Benchmark Template - * - * Performance benchmark suite for measuring schema renderer speed. - * Each route stress-tests a different aspect of the rendering pipeline - * and displays the measured render time. - * - * Routes: - * / — dashboard with results summary - * /static-small — 50 static nodes (baseline) - * /static-large — 200+ static nodes (scaling test) - * /static-extreme — 1000 static nodes (scaling test) - * /tokens-light — 50 nodes with 1-2 $store reads each - * /tokens-heavy — 50 nodes with deeply composed tokens - * /each-flat — $each with 100 items - * /each-nested — nested $each (10 groups × 10 items) - * /web-components — 100 web component nodes (we-text, we-button) - * /solid-components — 100 Solid component nodes (Column, Row) - * /deep-nesting — 30-level deep nesting - * /mixed-realistic — ~70-node dashboard with representative token mix - * /update-perf — 100 nodes bound to one $store value; measures the update path - * /idle — near-empty bounce target the runner parks on between samples - * - * Timing: BenchmarkTimer sits at the end of each route and stamps four checkpoints, which - * testStore turns into phase durations: - * - * Build navigation → timer constructed — schema walk, token resolution, DOM construction - * Mount timer constructed → onMount — DOM insertion + custom-element upgrade - * Flush onMount → microtask — Lit's async first render and updated() hooks - * Paint microtask → 2nd rAF — style, layout, paint - * - * The Build boundary works precisely *because* the timer is the last child: its component body - * cannot run until every preceding sibling has been built. An earlier version measured only from - * that point onward, which excluded the entire schema walk from the result — the reason Tokens - * Heavy used to score faster than Tokens Light despite doing strictly more token work. - * - * Sampling: every run takes 6 samples and reports the median of the last 5. The first is discarded - * as warm-up — it is genuinely different for the first route of a session (Lit template - * compilation, the class-level CSSStyleSheet), and discarding it is what makes a single route's - * run comparable to a Run All run. It is not displayed: measured across five sessions its own - * spread reached 32% on one route, so as a reported figure it was noise wearing the label of a - * finding. - * - * Each result instead carries a `spread` — the trimmed range of the warm samples, dropping the - * single slowest so one GC pause doesn't dominate — so every median comes with its own error bar. - * A delta smaller than a route's spread means nothing. Measured spread runs from ~1% on the small - * paint-bound routes to ~25% on Static Large, and varies that much run to run on the same route, - * which is why it is judged against absolute bands rather than a per-route baseline (see - * spreadColor). `heap` is the JS heap while parked on /idle just before the route rendered; a - * figure that climbs down the results list indicates accumulation across routes rather than any - * property of an individual route. - * - * Baselines: BASELINE_US_PER_ELEMENT below records each route's measured µs/element, and the colour - * coding compares against it — green to +20%, amber to +50%, red beyond. It exists so the page - * flags a *regression* rather than being permanently red, which is what both earlier threshold - * schemes were. - * - * That is a source-recorded reference for colouring only, not a persisted runtime baseline. An - * earlier version pinned results to localStorage and showed a percentage delta; it was removed - * because verifying a change forces a reload, and cross-session drift (~10%, and up to ~14% after - * a reboot) is far larger than within-session spread — so it reported drift as signal. Verifying a - * change still means comparing two sets of results directly, from settled run 3s. - */ -import type { SchemaNode, TemplateSchema } from '@we/schema-shared'; - -/** Base path when mounted under the testing template */ -export const benchmarkBasePath = '/benchmarks'; - -// --------------------------------------------------------------------------- -// Helpers — generate benchmark content programmatically -// --------------------------------------------------------------------------- - -/** Timer placed at the end of each benchmark route. - * Stamps four checkpoints and hands them to testStore, which derives the phase durations. */ -function timer(label: string): SchemaNode { - return { - type: 'BenchmarkTimer', - props: { - label, - onComplete: { $action: 'testStore.benchRecordRender' }, - }, - }; -} - -/** - * Recorded µs-per-element for each route, measured on a settled run 3 after the `setProperty` - * write-tracking fix in @we/primitives. This is the reference the colour coding compares against. - * - * Per-route rather than one global number, because a single threshold cannot work here. Fixed - * per-render overhead dominates small routes — Deep Nesting and Mixed Realistic sit at ~165µs/el - * with ~140 elements, while Static Extreme manages 64µs/el across 8015 — so any global value either - * paints the small routes permanently red or lets the large ones regress hugely without warning. - * Two earlier attempts failed exactly that way: absolute-ms thresholds left Static Extreme always - * red, and the first µs/el thresholds (40/80) left almost everything amber. A threshold that is - * always red carries no information. - * - * These are machine-specific. If you run the suite on different hardware and everything reads red, - * re-record rather than assuming a regression. - */ -const BASELINE_US_PER_ELEMENT: Record = { - 'static-small': 75, - 'static-large': 64, - 'static-extreme': 64, - 'tokens-light': 83, - 'tokens-heavy': 103, - 'each-flat': 66, - 'each-nested': 66, - 'web-components': 89, - 'solid-components': 57, - 'deep-nesting': 165, - 'mixed-realistic': 167, - 'update-perf': 71, -}; - -/** - * Colour a route's µs/element against its own recorded baseline. - * - * Bands are set from observed run-to-run variation, which reaches ~16% on the noisier routes - * (Static Large, Nested $each) even between settled runs. Green therefore extends to +20% so normal - * drift doesn't cry wolf; red starts at +50%, comfortably above noise and well below the kind of - * regression worth catching — the shared-memo change measured +60–160% and would light up red. - */ -function benchColor(routeKey: string, shade: string): Record { - const path = `testStore.benchResults.${routeKey}.usPerElement`; - const baseline = BASELINE_US_PER_ELEMENT[routeKey] ?? 0; - return { - $if: { - condition: { $lt: [{ $store: path }, Math.round(baseline * 1.2)] }, - then: `success-${shade}`, - else: { - $if: { - condition: { $lt: [{ $store: path }, Math.round(baseline * 1.5)] }, - then: `warning-${shade}`, - else: `danger-${shade}`, - }, - }, - }, - }; -} - -/** Same bands, but against `benchLastResult` — used on an individual route's own page. */ -function benchLastColor(routeKey: string, shade: string): Record { - const path = 'testStore.benchLastResult.usPerElement'; - const baseline = BASELINE_US_PER_ELEMENT[routeKey] ?? 0; - return { - $if: { - condition: { $lt: [{ $store: path }, Math.round(baseline * 1.2)] }, - then: `success-${shade}`, - else: { - $if: { - condition: { $lt: [{ $store: path }, Math.round(baseline * 1.5)] }, - then: `warning-${shade}`, - else: `danger-${shade}`, - }, - }, - }, - }; -} - -/** - * One phase row: label, duration, share of total. - * - * The share is emphasised rather than colour-coded. A phase taking 53% of the total isn't "bad" — - * it's just where the time goes — so red/amber/green would be reading a judgment into a number that - * doesn't carry one. Semantic colour is reserved for figures that do: µs/element and spread. - */ -function phaseRow(label: string, base: string, hint: string): SchemaNode { - return { - type: 'Row', - props: { gap: '200', ay: 'center', width: '100%' }, - children: [ - { type: 'we-text', props: { fontSize: '200', color: 'neutral-500', width: '52px' }, children: [label] }, - { - type: 'we-text', - props: { fontSize: '200', fontWeight: '600', color: 'neutral-800', width: '64px', textAlign: 'right' }, - children: [{ $concat: [{ $store: `${base}.value` }, 'ms'] }], - }, - { - type: 'we-text', - props: { - fontSize: '200', - fontWeight: '700', - width: '44px', - textAlign: 'right', - // The dominant phase is the finding; the rest are context. - color: { - $if: { - condition: { $gt: [{ $store: `${base}.share` }, 35] }, - then: 'primary-700', - else: 'neutral-400', - }, - }, - }, - children: [{ $concat: [{ $store: `${base}.share` }, '%'] }], - }, - { type: 'we-text', props: { fontSize: '100', color: 'neutral-400' }, children: [hint] }, - ], - }; -} - -/** Compact phase line for the dashboard cards — label, ms, share, one per line. */ -function cardPhaseRow(label: string, key: string, routeKey: string): SchemaNode { - const value = `testStore.benchResults.${routeKey}.phase.${key}.value`; - const share = `testStore.benchResults.${routeKey}.phase.${key}.share`; - return { - type: 'Row', - props: { gap: '200', ay: 'center', width: '100%' }, - children: [ - { type: 'we-text', props: { fontSize: '200', color: 'neutral-500', width: '44px' }, children: [label] }, - { - type: 'we-text', - props: { fontSize: '200', fontWeight: '600', color: 'neutral-700', width: '56px', textAlign: 'right' }, - children: [{ $concat: [{ $store: value }, 'ms'] }], - }, - { - type: 'we-text', - props: { - fontSize: '200', - fontWeight: '700', - width: '40px', - textAlign: 'right', - color: { - $if: { - condition: { $gt: [{ $store: share }, 35] }, - then: 'primary-700', - else: 'neutral-400', - }, - }, - }, - children: [{ $concat: [{ $store: share }, '%'] }], - }, - ], - }; -} - -/** - * Spread answers "can I trust this run's median?", so unlike µs/element it is *not* compared to a - * per-route baseline. Two reasons: - * - * - The question is absolute, not relative. A route that habitually varies 25% would be painted - * green by a baseline while still meaning "don't trust this number". - * - Spread's own run-to-run variance exceeds the quantity itself — Static Large has measured - * 7/12/15/19/24/26% across runs. Baselining that would encode whichever run happened to be - * recorded, which is a lottery rather than a reference. - * - * Bands are calibrated from measurement rather than guessed (the previous 4%/8% predated any data - * and left most routes permanently amber, which carries no information). They are set by what a - * given spread lets you *detect*: the µs/element danger band starts at +50%, so a spread up to ~20% - * still leaves room to see a regression worth acting on. Beyond that the median is unreliable and - * the run should be repeated. - */ -function spreadColor(routeKey: string): Record { - const path = `testStore.benchResults.${routeKey}.spreadPct`; - return { - $if: { - condition: { $lt: [{ $store: path }, 10] }, - then: 'success-600', - else: { - $if: { condition: { $lt: [{ $store: path }, 20] }, then: 'warning-600', else: 'danger-600' }, - }, - }, - }; -} - -/** A static card with N text props — no tokens */ -function staticCard(id: number): SchemaNode { - return { - type: 'Column', - props: { - p: '300', - gap: '200', - bg: 'neutral-0', - r: '300', - border: '1px solid neutral-200', - }, - children: [ - { type: 'we-text', props: { text: `Card ${id}`, fontSize: '400', fontWeight: '600', color: 'neutral-800' } }, - { type: 'we-text', props: { text: `Description for card number ${id}`, fontSize: '300', color: 'neutral-600' } }, - { type: 'we-text', props: { text: `Detail line ${id}`, fontSize: '200', color: 'neutral-400' } }, - ], - }; -} - -/** A card that reads from $store for its values */ -function tokenCard(id: number): SchemaNode { - return { - type: 'Column', - props: { - p: '300', - gap: '200', - bg: 'neutral-0', - r: '300', - }, - children: [ - { - type: 'we-text', - props: { - fontSize: '400', - fontWeight: '600', - color: 'neutral-800', - }, - children: [{ $concat: ['Card ', { $store: 'testStore.stringValue' }, ` #${id}`] }], - }, - { - type: 'we-text', - props: { - fontSize: '300', - color: { - $if: { - condition: { $store: 'testStore.boolTrue' }, - then: 'neutral-600', - else: 'danger-600', - }, - }, - }, - children: [{ $concat: ['Count: ', { $store: 'testStore.numberValue' }] }], - }, - ], - }; -} - -/** A card with deeply composed tokens — $if($and($eq($store,…), $not(…))) */ -function heavyTokenCard(id: number): SchemaNode { - return { - type: 'Column', - props: { - p: '300', - gap: '200', - bg: { - $if: { - condition: { - $and: [ - { $eq: [{ $store: 'testStore.stringValue' }, 'hello'] }, - { $not: { $store: 'testStore.boolFalse' } }, - ], - }, - then: 'neutral-0', - else: 'danger-50', - }, - }, - r: '300', - }, - children: [ - { - type: 'we-text', - props: { - fontSize: '400', - fontWeight: '600', - color: { - $if: { - condition: { - $or: [ - { $eq: [{ $store: 'testStore.numberValue' }, 42] }, - { $ne: [{ $store: 'testStore.stringValue' }, 'goodbye'] }, - ], - }, - then: 'primary-700', - else: 'danger-700', - }, - }, - }, - children: [ - { - $concat: [ - 'Heavy #', - `${id}`, - ' — ', - { - $if: { - condition: { $store: 'testStore.boolTrue' }, - then: { $store: 'testStore.stringValue' }, - else: 'fallback', - }, - }, - ], - }, - ], - }, - { - type: 'we-text', - props: { - fontSize: '300', - color: 'neutral-500', - }, - children: [ - { - $concat: [ - 'Status: ', - { - $if: { - condition: { $and: [{ $store: 'testStore.boolTrue' }, { $not: { $store: 'testStore.boolFalse' } }] }, - then: 'active', - else: 'inactive', - }, - }, - ' | Count: ', - { $store: 'testStore.numberValue' }, - ], - }, - ], - }, - ], - }; -} - -/** Build N levels of nesting: Column → Row → Column → Row → ... */ -function deepNest(depth: number, current: number = 0): SchemaNode { - const isColumn = current % 2 === 0; - const child: SchemaNode = - current >= depth - ? { type: 'we-text', props: { text: `Depth ${current}`, fontSize: '200', color: 'primary-600' } } - : deepNest(depth, current + 1); - - return { - type: isColumn ? 'Column' : 'Row', - props: { p: '100', gap: '100', ...(current === 0 ? { bg: 'neutral-0', r: '300' } : {}) }, - children: [{ type: 'we-text', props: { text: `Level ${current}`, fontSize: '200', color: 'neutral-400' } }, child], - }; -} - -/** A static web component card (we-text, we-button) */ -function wcCard(id: number): SchemaNode { - return { - type: 'Column', - props: { p: '200', gap: '200', bg: 'neutral-0', r: '200' }, - children: [ - { type: 'we-text', props: { text: `WC ${id}`, fontSize: '300', color: 'neutral-700' } }, - { type: 'we-button', props: { text: `Action ${id}`, variant: 'outline', size: 'sm' } }, - ], - }; -} - -/** A static Solid component card (Column, Row — no web components) */ -function solidCard(id: number): SchemaNode { - return { - type: 'Column', - props: { - p: '200', - gap: '100', - bg: 'neutral-0', - r: '200', - border: '1px solid neutral-100', - }, - children: [ - { - type: 'Row', - props: { gap: '200', ay: 'center' }, - children: [ - { type: 'Column', props: { width: '8px', height: '8px', r: 'full', bg: 'primary-400' } }, - { - type: 'Column', - children: [{ type: 'we-text', props: { text: `Solid ${id}`, fontSize: '300', color: 'neutral-700' } }], - }, - ], - }, - ], - }; -} - -// --------------------------------------------------------------------------- -// Generate route content -// --------------------------------------------------------------------------- - -function generateCards(count: number, factory: (id: number) => SchemaNode): SchemaNode[] { - return Array.from({ length: count }, (_, i) => factory(i + 1)); -} - -/** Build a full benchmark route: Column wrapper + back button + timer + content */ -function benchRoute(path: string, title: string, children: SchemaNode[]) { - // '/static-small' -> 'static-small', matching the keys in BASELINE_US_PER_ELEMENT. - const routeKey = path.slice(1); - return { - path, - type: 'Column', - props: { width: '100%', height: '100%', gap: '300', bg: 'neutral-50', overflow: 'auto' }, - children: [ - { - type: 'Row', - props: { gap: '300', ay: 'center', pb: '300' }, - children: [ - { - type: 'we-button', - props: { - variant: 'ghost', - size: 'sm', - onClick: { $action: 'routeStore.navigate', args: [benchmarkBasePath] }, - }, - children: [{ type: 'we-icon', props: { name: 'arrow-left', size: 'sm' } }], - }, - { type: 'we-text', props: { text: title, fontSize: '600', fontWeight: '600', color: 'neutral-800' } }, - ], - }, - // No sampling-status indicator here on purpose. It previously used we-spinner, which - // animates — continuous compositor work inside the measured window, on every one of the 72 - // renders a Run All performs. Progress now lives in the run overlay instead, which is static. - // - // Last completed result for this route — full phase breakdown. - { - type: '$if', - props: { - condition: { $store: 'testStore.benchLastResult.median' }, - then: { - type: 'Column', - props: { - gap: '100', - p: '300', - bg: benchLastColor(routeKey, '50'), - r: '300', - mb: '300', - }, - children: [ - { - type: 'Row', - props: { gap: '200', ay: 'center', pb: '100' }, - children: [ - { - type: 'we-icon', - props: { name: 'clock', color: benchLastColor(routeKey, '600') }, - }, - { - type: 'we-text', - props: { - fontWeight: '700', - color: benchLastColor(routeKey, '700'), - }, - children: [{ $concat: [{ $store: 'testStore.benchLastResult.median.total' }, 'ms total'] }], - }, - { - type: 'we-text', - props: { fontSize: '200', color: 'neutral-500' }, - children: [ - { - $concat: [ - 'median of ', - { $store: 'testStore.benchLastResult.sampleCount' }, - ' · ', - { $store: 'testStore.benchLastResult.usPerElement' }, - 'µs/element · ', - { $store: 'testStore.benchLastResult.usPerCustomElement' }, - 'µs/custom element', - ], - }, - ], - }, - ], - }, - phaseRow('Build', 'testStore.benchLastResult.phase.build', 'schema walk + token resolution'), - phaseRow('Mount', 'testStore.benchLastResult.phase.mount', 'insertion + custom-element upgrade'), - phaseRow('Flush', 'testStore.benchLastResult.phase.flush', 'Lit first render + updated()'), - phaseRow('Paint', 'testStore.benchLastResult.phase.paint', 'style, layout, paint'), - { - type: 'Row', - props: { gap: '200', ay: 'center', pt: '100' }, - children: [ - { - type: 'we-text', - props: { fontSize: '100', color: 'neutral-400' }, - children: [ - { - $concat: [ - { $store: 'testStore.benchLastResult.median.elements' }, - ' elements · ', - { $store: 'testStore.benchLastResult.median.customElements' }, - ' custom · spread ', - { $store: 'testStore.benchLastResult.spreadLow' }, - '–', - { $store: 'testStore.benchLastResult.spreadHigh' }, - 'ms · heap ', - { $store: 'testStore.benchLastResult.heapMb' }, - 'mb', - ], - }, - ], - }, - ], - }, - ], - }, - }, - }, - ...children, - timer(path.slice(1)), - ], - }; -} - -// --------------------------------------------------------------------------- -// Route: Static Small (50 cards) -// --------------------------------------------------------------------------- -const staticSmallRoute = benchRoute('/static-small', 'Static Small — 50 nodes', [ - { - type: 'we-text', - props: { text: '50 static cards, all string props, zero tokens', fontSize: '300', color: 'neutral-500' }, - }, - { - type: 'Column', - props: { - gap: '200', - styles: { display: 'grid', 'grid-template-columns': 'repeat(auto-fill, minmax(200px, 1fr))', gap: '8px' }, - }, - children: generateCards(50, staticCard), - }, -]); - -// --------------------------------------------------------------------------- -// Route: Static Large (200 cards) -// --------------------------------------------------------------------------- -const staticLargeRoute = benchRoute('/static-large', 'Static Large — 200 nodes', [ - { - type: 'we-text', - props: { - text: '200 static cards — tests scaling of static prop overhead', - fontSize: '300', - color: 'neutral-500', - }, - }, - { - type: 'Column', - props: { - gap: '200', - styles: { display: 'grid', 'grid-template-columns': 'repeat(auto-fill, minmax(180px, 1fr))', gap: '8px' }, - }, - children: generateCards(200, staticCard), - }, -]); - -// --------------------------------------------------------------------------- -// Route: Static Extreme (1000 cards) -// --------------------------------------------------------------------------- -const staticExtremeRoute = benchRoute('/static-extreme', 'Static Extreme — 1000 nodes', [ - { - type: 'we-text', - props: { - text: '1000 static cards — tests scaling of static prop overhead', - fontSize: '300', - color: 'neutral-500', - }, - }, - { - type: 'Column', - props: { - gap: '200', - styles: { display: 'grid', 'grid-template-columns': 'repeat(auto-fill, minmax(180px, 1fr))', gap: '8px' }, - }, - children: generateCards(1000, staticCard), - }, -]); - -// --------------------------------------------------------------------------- -// Route: Tokens Light (50 cards with $store reads) -// --------------------------------------------------------------------------- -const tokensLightRoute = benchRoute('/tokens-light', 'Tokens Light — $store reads', [ - { - type: 'we-text', - props: { text: '50 cards each with 1-2 $store and $concat tokens', fontSize: '300', color: 'neutral-500' }, - }, - { - type: 'Column', - props: { - gap: '200', - styles: { display: 'grid', 'grid-template-columns': 'repeat(auto-fill, minmax(200px, 1fr))', gap: '8px' }, - }, - children: generateCards(50, tokenCard), - }, -]); - -// --------------------------------------------------------------------------- -// Route: Tokens Heavy (50 cards with deeply composed tokens) -// --------------------------------------------------------------------------- -const tokensHeavyRoute = benchRoute('/tokens-heavy', 'Tokens Heavy — deep composition', [ - { - type: 'we-text', - props: { - text: '50 cards each with $if($and($eq($store,…), $not(…))) chains', - fontSize: '300', - color: 'neutral-500', - }, - }, - { - type: 'Column', - props: { - gap: '200', - styles: { display: 'grid', 'grid-template-columns': 'repeat(auto-fill, minmax(220px, 1fr))', gap: '8px' }, - }, - children: generateCards(50, heavyTokenCard), - }, -]); - -// --------------------------------------------------------------------------- -// Route: $each Flat (100 items) -// --------------------------------------------------------------------------- -const eachFlatRoute = benchRoute('/each-flat', '$each Flat — 100 items', [ - { - type: 'we-text', - props: { text: 'Single $each loop rendering 100 simple cards', fontSize: '300', color: 'neutral-500' }, - }, - { - type: 'Column', - props: { - gap: '200', - styles: { display: 'grid', 'grid-template-columns': 'repeat(auto-fill, minmax(200px, 1fr))', gap: '8px' }, - }, - children: [ - { - type: '$each', - props: { items: { $store: 'testStore.benchList100' } }, - children: [ - { - type: 'Column', - props: { p: '300', gap: '100', bg: 'neutral-0', r: '300' }, - children: [ - { type: 'we-text', props: { color: 'neutral-700' }, children: ['$item.name'] }, - { type: 'we-text', props: { fontSize: '200', color: 'neutral-400' }, children: ['$item.category'] }, - ], - }, - ], - }, - ], - }, -]); - -// --------------------------------------------------------------------------- -// Route: $each Nested (10 groups × 10 items) -// --------------------------------------------------------------------------- -const eachNestedRoute = benchRoute('/each-nested', 'Nested $each — 10×10', [ - { - type: 'we-text', - props: { text: '10 groups with 10 items each — tests context spreading', fontSize: '300', color: 'neutral-500' }, - }, - { - type: 'Column', - props: { gap: '300' }, - children: [ - { - type: '$each', - props: { items: { $store: 'testStore.benchGroups' }, as: 'group' }, - children: [ - { - type: 'Column', - props: { p: '300', gap: '200', bg: 'neutral-0', r: '300' }, - children: [ - { - type: 'we-text', - props: { fontWeight: '600', color: 'neutral-700', fontSize: '400' }, - children: ['$group.name'], - }, - { - type: '$each', - props: { items: '$group.items', as: 'sub' }, - children: [ - { - type: 'Row', - props: { gap: '200', pl: '300', ay: 'center' }, - children: [ - { type: 'we-text', props: { color: 'neutral-400' }, children: ['•'] }, - { type: 'we-text', props: { fontSize: '300' }, children: ['$sub.label'] }, - { - type: 'we-text', - props: { fontSize: '200', color: 'neutral-400' }, - children: ['$sub.detail'], - }, - ], - }, - ], - }, - ], - }, - ], - }, - ], - }, -]); - -// --------------------------------------------------------------------------- -// Route: Web Components (100 we-text + we-button) -// --------------------------------------------------------------------------- -const wcRoute = benchRoute('/web-components', 'Web Components — 100 nodes', [ - { - type: 'we-text', - props: { - text: '100 we-text + we-button pairs — isolates per-prop createEffect overhead', - fontSize: '300', - color: 'neutral-500', - }, - }, - { - type: 'Column', - props: { - gap: '200', - styles: { display: 'grid', 'grid-template-columns': 'repeat(auto-fill, minmax(150px, 1fr))', gap: '6px' }, - }, - children: generateCards(100, wcCard), - }, -]); - -// --------------------------------------------------------------------------- -// Route: Solid Components (100 Column + Row) -// --------------------------------------------------------------------------- -const solidRoute = benchRoute('/solid-components', 'Solid Components — 100 nodes', [ - { - type: 'we-text', - props: { - text: '100 Column + Row nodes — same layout, reactive spread path', - fontSize: '300', - color: 'neutral-500', - }, - }, - { - type: 'Column', - props: { - gap: '200', - styles: { display: 'grid', 'grid-template-columns': 'repeat(auto-fill, minmax(150px, 1fr))', gap: '6px' }, - }, - children: generateCards(100, solidCard), - }, -]); - -// --------------------------------------------------------------------------- -// Route: Deep Nesting (30 levels) -// --------------------------------------------------------------------------- -const deepNestRoute = benchRoute('/deep-nesting', 'Deep Nesting — 30 levels', [ - { - type: 'we-text', - props: { text: 'Column → Row → Column chain, 30 levels deep', fontSize: '300', color: 'neutral-500' }, - }, - deepNest(30), -]); - -// --------------------------------------------------------------------------- -// Route: Mixed Realistic (~70 nodes, representative token mix) -// --------------------------------------------------------------------------- -const mixedRealisticRoute = benchRoute('/mixed-realistic', 'Mixed Realistic — ~70 nodes', [ - { - type: 'we-text', - props: { text: 'Dashboard-like layout with representative token mix', fontSize: '300', color: 'neutral-500' }, - }, - // Welcome header with $store - { - type: 'Column', - props: { width: '100%', p: '400', gap: '200', bg: 'neutral-0', r: '400' }, - children: [ - { - type: 'we-text', - props: { fontSize: '700', fontWeight: '600', color: 'neutral-900' }, - children: [{ $concat: ['Welcome, ', { $store: 'testStore.stringValue' }] }], - }, - { - type: 'we-text', - props: { fontSize: '400', color: 'neutral-600' }, - children: [{ $concat: ['Counter: ', { $store: 'testStore.counter' }] }], - }, - ], - }, - // Stat cards — 4 static cards - { - type: 'Row', - props: { width: '100%', gap: '300', wrap: true }, - children: [ - ...[ - { label: 'Active Spaces', value: '12', change: '+2 this week', color: 'primary-500' }, - { label: 'Messages', value: '24', change: '5 unread', color: 'blue-500' }, - { label: 'Quests', value: '7', change: '3 due', color: 'green-500' }, - { label: 'Notifications', value: '18', change: 'New today', color: 'orange-500' }, - ].map((stat) => ({ - type: 'Column', - props: { - flex: '1', - minWidth: '160px', - p: '400', - gap: '200', - bg: 'neutral-0', - r: '400', - borderLeft: `4px solid ${stat.color}`, - }, - children: [ - { type: 'we-text', props: { text: stat.label, fontSize: '300', color: 'neutral-600' } }, - { type: 'we-text', props: { text: stat.value, fontSize: '800', fontWeight: '700', color: 'neutral-900' } }, - { type: 'we-text', props: { text: stat.change, fontSize: '300', color: stat.color } }, - ], - })), - ], - }, - // Two column layout - { - type: 'Row', - props: { width: '100%', gap: '400', ax: 'start' }, - children: [ - // Left — activity list with $each - { - type: 'Column', - props: { flex: '2', gap: '300' }, - children: [ - { - type: 'we-text', - props: { text: 'Recent Activity', fontWeight: '600', color: 'neutral-800' }, - }, - { - type: 'Column', - props: { gap: '200' }, - children: [ - { - type: '$each', - props: { items: { $store: 'testStore.fruits' } }, - children: [ - { - type: 'Row', - props: { p: '300', gap: '300', bg: 'neutral-0', r: '300', ay: 'center' }, - children: [ - { type: 'we-text', children: ['$item.emoji'] }, - { - type: 'Column', - props: { flex: '1', gap: '100' }, - children: [ - { - type: 'we-text', - props: { color: 'neutral-800' }, - children: ['$item.name'], - }, - { - type: 'we-text', - props: { fontSize: '200', color: 'neutral-500' }, - children: ['$item.color'], - }, - ], - }, - ], - }, - ], - }, - ], - }, - ], - }, - // Right — quick actions + conditional content - { - type: 'Column', - props: { flex: '1', gap: '300' }, - children: [ - { - type: 'we-text', - props: { text: 'Quick Actions', fontWeight: '600', color: 'neutral-800' }, - }, - { - type: 'Column', - props: { gap: '200' }, - children: [ - { - type: 'we-button', - props: { - text: 'Toggle State', - variant: 'primary', - width: '100%', - onClick: { $action: 'testStore.toggle' }, - }, - }, - { - type: 'we-button', - props: { - text: 'Increment Counter', - variant: 'secondary', - width: '100%', - onClick: { $action: 'testStore.increment' }, - }, - }, - { type: 'we-button', props: { text: 'Action Three', variant: 'outline', width: '100%' } }, - { type: 'we-button', props: { text: 'Action Four', variant: 'ghost', width: '100%' } }, - ], - }, - // Conditional section - { - type: '$if', - props: { - condition: { $store: 'testStore.toggleValue' }, - then: { - type: 'Column', - props: { p: '300', gap: '200', bg: 'success-50', r: '300' }, - children: [ - { type: 'we-text', props: { fontWeight: '600', color: 'success-700' }, children: ['Toggle is ON'] }, - { - type: 'we-text', - props: { fontSize: '300', color: 'success-600' }, - children: ['This section appears conditionally'], - }, - ], - }, - else: { - type: 'Column', - props: { p: '300', gap: '200', bg: 'neutral-100', r: '300' }, - children: [ - { - type: 'we-text', - props: { fontWeight: '600', color: 'neutral-600' }, - children: ['Toggle is OFF'], - }, - { - type: 'we-text', - props: { fontSize: '300', color: 'neutral-500' }, - children: ['Toggle the state to show content'], - }, - ], - }, - }, - }, - // Events list — static - { - type: 'Column', - props: { gap: '300', pt: '200' }, - children: [ - { - type: 'we-text', - props: { text: 'Upcoming', fontWeight: '600', color: 'neutral-800' }, - }, - ...['Team Standup — 10:00 AM', 'Design Review — 2:00 PM', 'Sprint Planning — Friday'].map((event) => ({ - type: 'Column', - props: { p: '300', bg: 'neutral-0', r: '300' }, - children: [{ type: 'we-text', props: { text: event, fontSize: '300', color: 'neutral-700' } }], - })), - ], - }, - ], - }, - ], - }, -]); - -// --------------------------------------------------------------------------- -// Route: Reactive Update (100 nodes bound to one $store value) -// --------------------------------------------------------------------------- -const updatePerfRoute = benchRoute('/update-perf', 'Reactive Update — 100 bound nodes', [ - { - type: 'we-text', - props: { - text: '100 nodes bound to a single $store value — measures the update path, not mount', - fontSize: '300', - color: 'neutral-500', - }, - }, - { - type: 'Column', - props: { - gap: '200', - styles: { display: 'grid', 'grid-template-columns': 'repeat(auto-fill, minmax(150px, 1fr))', gap: '6px' }, - }, - children: generateCards(100, (id) => ({ - type: 'Column', - props: { p: '200', gap: '100', bg: 'neutral-0', r: '200' }, - children: [ - { type: 'we-text', props: { fontSize: '200', color: 'neutral-500' }, children: [`Cell ${id}`] }, - { - type: 'we-text', - props: { fontWeight: '600', color: 'primary-700' }, - children: [{ $concat: ['#', { $store: 'testStore.counter' }] }], - }, - ], - })), - }, -]); - -// --------------------------------------------------------------------------- -// Route: Idle — the bounce target between repeat samples -// -// Median-of-N needs the same route rendered several times, but navigating to the path you are -// already on is a no-op — no remount, nothing to measure. The runner therefore bounces through -// here between samples. Kept deliberately near-empty so the previous route's teardown lands on a -// cheap page, outside the next measurement window. -// --------------------------------------------------------------------------- -const idleRoute = { - path: '/idle', - type: 'Column', - props: { width: '100%', height: '100%', p: '400', bg: 'neutral-50' }, - children: [ - { - type: 'we-text', - props: { color: 'neutral-400', fontSize: '200' }, - children: ['Settling between samples…'], - }, - ], -}; - -// --------------------------------------------------------------------------- -// Dashboard route — results summary + navigation -// -// Exported so testStore's runner builds its queue from exactly this list — a route can't be added -// to the dashboard and silently skipped by Run All. -// --------------------------------------------------------------------------- -export const benchmarkRoutes: { - path: string; - key: string; - label: string; - nav: string; - measuresUpdate?: boolean; -}[] = [ - { path: '/static-small', key: 'static-small', label: 'Static Small (50)', nav: `${benchmarkBasePath}/static-small` }, - { path: '/static-large', key: 'static-large', label: 'Static Large (200)', nav: `${benchmarkBasePath}/static-large` }, - { - path: '/static-extreme', - key: 'static-extreme', - label: 'Static Extreme (1000)', - nav: `${benchmarkBasePath}/static-extreme`, - }, - { path: '/tokens-light', key: 'tokens-light', label: 'Tokens Light', nav: `${benchmarkBasePath}/tokens-light` }, - { path: '/tokens-heavy', key: 'tokens-heavy', label: 'Tokens Heavy', nav: `${benchmarkBasePath}/tokens-heavy` }, - { path: '/each-flat', key: 'each-flat', label: '$each Flat (100)', nav: `${benchmarkBasePath}/each-flat` }, - { path: '/each-nested', key: 'each-nested', label: 'Nested $each (10×10)', nav: `${benchmarkBasePath}/each-nested` }, - { - path: '/web-components', - key: 'web-components', - label: 'Web Components (100)', - nav: `${benchmarkBasePath}/web-components`, - }, - { - path: '/solid-components', - key: 'solid-components', - label: 'Solid Components (100)', - nav: `${benchmarkBasePath}/solid-components`, - }, - { path: '/deep-nesting', key: 'deep-nesting', label: 'Deep Nesting (30)', nav: `${benchmarkBasePath}/deep-nesting` }, - { - path: '/mixed-realistic', - key: 'mixed-realistic', - label: 'Mixed Realistic', - nav: `${benchmarkBasePath}/mixed-realistic`, - }, - { - path: '/update-perf', - key: 'update-perf', - label: 'Reactive Update (100)', - nav: `${benchmarkBasePath}/update-perf`, - measuresUpdate: true, - }, -]; - -/** - * Persistent header — lives on the template root, above the route outlet, so the title and controls - * stay visible while the runner navigates between routes. Previously this sat inside the dashboard - * route, which meant it vanished the moment a run started and took the Run All button with it. - */ -const benchHeader: SchemaNode = { - type: 'Column', - props: { gap: '300', mb: '400', bg: 'neutral-50' }, - children: [ - { - type: 'we-text', - props: { fontSize: '700', fontWeight: '700', color: 'primary-800' }, - children: ['Renderer Benchmarks'], - }, - { - type: 'we-text', - props: { color: 'neutral-600' }, - children: [ - 'Each run takes 6 samples (first discarded as warm-up) and reports the median, split by phase. ' + - 'The headline µs/element figure is coloured against each route’s own recorded baseline — ' + - 'green within +20%, amber to +50%, red beyond — so colour means regression, not size. ' + - 'Reboot before measuring a change, then compare settled run 3s.', - ], - }, - { - type: 'Row', - props: { gap: '200', py: '200', wrap: true }, - children: [ - { - type: 'we-button', - props: { - text: 'Run All', - variant: 'primary', - gradient: true, - loading: { $store: 'testStore.benchRunning' }, - disabled: { $store: 'testStore.benchRunning' }, - onClick: { $action: 'testStore.benchRunAll' }, - }, - }, - { - type: 'we-button', - props: { - text: 'Clear All Results', - variant: 'secondary', - disabled: { $store: 'testStore.benchRunning' }, - onClick: { $action: 'testStore.benchClearResults' }, - }, - }, - ], - }, - { type: 'we-divider' }, - ], -}; - -const dashboardRoute = { - path: '/', - type: 'Column', - props: { width: '100%', height: '100%', gap: '400', bg: 'neutral-50', overflow: 'auto' }, - children: [ - // Benchmark navigation grid - { - type: 'Column', - props: { - gap: '200', - styles: { display: 'grid', 'grid-template-columns': 'repeat(auto-fill, minmax(250px, 1fr))', gap: '12px' }, - }, - children: benchmarkRoutes.map((route) => ({ - type: 'Column', - props: { - p: '400', - gap: '200', - bg: 'neutral-0', - r: '400', - cursor: 'pointer', - border: '1px solid neutral-200', - hoverProps: { bg: 'primary-25', borderColor: 'primary-300' }, - // Both the card and the Run button go through benchRun rather than navigating directly. - // A bare navigate would render the route without a sampling session, so the timer would - // have no navigation timestamp to measure Build against and the result would be dropped. - onClick: { $action: 'testStore.benchRun', args: [route.key] }, - }, - children: [ - { - type: 'Row', - props: { gap: '200', ay: 'center', ax: 'between', width: '100%' }, - children: [ - { type: 'we-text', props: { text: route.label, fontWeight: '600', color: 'neutral-800' } }, - { - type: 'we-button', - props: { - text: 'Run', - variant: 'primary', - size: 'sm', - onClick: { $action: 'testStore.benchRun', args: [route.key] }, - }, - }, - ], - }, - { type: 'we-text', props: { text: route.path, fontSize: '300', color: 'neutral-400' } }, - // Last result for this route — headline is the normalised per-element figure, since - // total ms alone can't be compared between a 70-node and a 4000-node route. - { - type: '$if', - props: { - condition: { $store: `testStore.benchResults.${route.key}.median` }, - then: { - type: 'Column', - props: { gap: '100', pt: '100' }, - children: [ - { - type: 'Row', - props: { gap: '200', ay: 'center' }, - children: [ - { - type: 'we-text', - props: { - fontSize: '400', - fontWeight: '700', - color: benchColor(route.key, '600'), - }, - children: [ - { $concat: [{ $store: `testStore.benchResults.${route.key}.usPerElement` }, 'µs/el'] }, - ], - }, - { - type: 'we-text', - props: { fontSize: '300', color: 'neutral-500' }, - children: [ - { $concat: [{ $store: `testStore.benchResults.${route.key}.median.total` }, 'ms total'] }, - ], - }, - ], - }, - // Phases, one per line — the previous single dot-separated run-on was unreadable - // and made it impossible to see at a glance which phase dominates. - { - type: 'Column', - props: { gap: '0', pt: '100', pb: '100' }, - children: [ - cardPhaseRow('build', 'build', route.key), - cardPhaseRow('mount', 'mount', route.key), - cardPhaseRow('flush', 'flush', route.key), - cardPhaseRow('paint', 'paint', route.key), - ], - }, - { - type: 'we-text', - props: { fontSize: '100', color: 'neutral-400' }, - children: [ - { - $concat: [ - { $store: `testStore.benchResults.${route.key}.median.elements` }, - ' el · ', - { $store: `testStore.benchResults.${route.key}.median.customElements` }, - ' custom · heap ', - { $store: `testStore.benchResults.${route.key}.heapMb` }, - 'mb', - ], - }, - ], - }, - { - type: 'we-text', - props: { fontSize: '100', fontWeight: '600', color: spreadColor(route.key) }, - children: [ - { - $concat: [ - 'spread ', - { $store: `testStore.benchResults.${route.key}.spreadLow` }, - '–', - { $store: `testStore.benchResults.${route.key}.spreadHigh` }, - 'ms (', - { $store: `testStore.benchResults.${route.key}.spreadPct` }, - '%)', - ], - }, - ], - }, - // Only the update-measuring route populates this. - { - type: '$if', - props: { - condition: { $store: `testStore.benchResults.${route.key}.updateMs` }, - then: { - type: 'we-text', - props: { fontSize: '200', fontWeight: '600', color: 'primary-600' }, - children: [ - { - $concat: ['update ', { $store: `testStore.benchResults.${route.key}.updateMs` }, 'ms'], - }, - ], - }, - }, - }, - ], - }, - }, - }, - ], - })), - }, - ], -}; - -// --------------------------------------------------------------------------- -// Full template export -// --------------------------------------------------------------------------- -/** - * Full-viewport cover shown while the runner is sampling. - * - * A Run All is 12 routes × 6 samples = 72 renders, each bouncing through /idle — 144 navigations - * of visible thrash. This hides that behind a stable progress panel. - * - * Two constraints make this safe to measure through, and both are load-bearing: - * - * 1. It **covers**, it does not hide. `display: none` would skip layout and paint entirely and - * `visibility: hidden` would skip paint — either would collapse the Paint phase to nothing and - * silently invalidate every number the suite produces. The route underneath stays in normal - * flow, laid out and painted; this simply sits on top of it. - * 2. The progress bar is **determinate and static** — no spinner, no CSS animation. An animating - * element would add continuous compositor work inside the measured window on all 72 samples. - * - * The overlay is mounted during the /idle baseline capture as well as at paint, so its own - * elements cancel out of the element-count delta rather than inflating it. - * - * Residual risk: a browser may skip painting fully-occluded content. If Paint drops noticeably - * versus the pre-overlay runs, that's occlusion culling and this needs to come back out. - * - * Scoped with `position: absolute` inside the route-outlet wrapper rather than `fixed` to the - * viewport, so the persistent header — and the app shell around it — stay visible and usable while - * a run is in progress. Only the thrashing part is covered. - * - * `position: fixed`, sized by the viewport. That is the load-bearing decision and it is about - * measurement, not aesthetics: a fixed element is out of flow entirely, so it cannot change the - * layout — and therefore cannot change what the browser paints — for the route being measured. - * Every in-flow alternative can. - * - * Three earlier attempts failed, all for the same underlying reason: they sized the overlay against - * the route-outlet wrapper, whose height swings between a near-empty /idle and a full route on - * every one of the 144 navigations a Run All performs. - * - `height: 100%` + vertical centring → panel jumps between samples (the wrapper's height varies). - * - `height: 100%` + `minHeight: 100vh` + a 100vh sticky child → stable, but a full extra screen - * tall with dead space to scroll through. - * - Top-anchoring → stable, but cannot be centred. - * Sizing against the viewport removes the dependency rather than compensating for it. - * - * `top` is a hardcoded pixel offset, which is the one genuinely unsatisfying part. CSS cannot say - * "start where the header ends" for a fixed element — it can only reference the viewport — so - * covering exactly the cards region while staying viewport-stable requires knowing that distance - * up front. BENCH_OVERLAY_TOP is that measurement: the shell nav plus the benchmark header. If the - * overlay ever starts too low (route content visible above it) or too high (clipping the header), - * this is the number to adjust, and nothing else needs to change. - * - * Note also that `ax`/`ay` are literal x/y axes, not main/cross — see mapFlexAxes in - * @we/design-utils, where a column maps ay -> justify-content and ax -> align-items. - */ -const BENCH_OVERLAY_TOP = '380px'; - -const runOverlay: SchemaNode = { - type: '$if', - props: { - condition: { $store: 'testStore.benchStatus' }, - then: { - type: 'Column', - props: { - position: 'fixed', - // top + bottom rather than a height: the box then spans from below the header to the - // bottom of the viewport, so it is exactly the remaining screen space with nothing to - // scroll past — and its size still never depends on the route rendering behind it. - top: BENCH_OVERLAY_TOP, - bottom: '0', - left: '0', - right: '0', - zIndex: 'modal', - bg: 'neutral-50', - ax: 'center', - ay: 'center', - px: '500', - overflow: 'hidden', - }, - children: [ - { - type: 'Column', - props: { gap: '300', width: '100%', maxWidth: '420px', ax: 'center' }, - children: [ - { - type: 'we-text', - props: { fontSize: '600', fontWeight: '700', color: 'primary-800' }, - children: ['Running benchmarks'], - }, - { - type: 'we-text', - props: { fontSize: '300', color: 'neutral-500' }, - children: [{ $store: 'testStore.benchRouteProgress' }], - }, - { - type: 'we-progress-bar', - props: { value: { $store: 'testStore.benchProgress' }, max: 100, width: '100%' }, - }, - { - type: 'we-text', - props: { fontSize: '300', fontWeight: '600', color: 'neutral-700' }, - children: [{ $store: 'testStore.benchStatus' }], - }, - { - type: 'we-text', - props: { fontSize: '200', color: 'neutral-400', textAlign: 'center' }, - children: ['Each route renders 6 times; the first is discarded as warm-up.'], - }, - ], - }, - ], - }, - }, -}; - -export const schemaBenchmarkTemplate: TemplateSchema = { - meta: { - name: 'Schema Benchmark', - description: 'Performance benchmark suite for schema renderer', - icon: 'timer', - stores: ['testStore'], - components: ['BenchmarkTimer'], - }, - type: 'Column', - props: { width: '100%', height: '100%', bg: 'neutral-50' }, - children: [ - benchHeader, - // Route outlet. Deliberately carries no height, flex or overflow constraints: this box wraps - // the content being measured, and constraining it would change that content's layout — and so - // what the browser paints — invalidating comparison against every run recorded so far. The run - // overlay is `position: fixed` precisely so it needs nothing from this element. - { - type: 'Column', - props: { width: '100%' }, - children: [{ type: '$routes' }, runOverlay], - }, - ], - routes: [ - dashboardRoute, - staticSmallRoute, - staticLargeRoute, - staticExtremeRoute, - tokensLightRoute, - tokensHeavyRoute, - eachFlatRoute, - eachNestedRoute, - wcRoute, - solidRoute, - deepNestRoute, - mixedRealisticRoute, - updatePerfRoute, - idleRoute, - { - path: '*', - type: 'Column', - props: { p: '500' }, - children: [{ type: 'we-text', props: { text: 'Benchmark route not found' } }], - }, - ], -}; diff --git a/packages/app-framework/src/shared/schemas/shell/tests/index.ts b/packages/app-framework/src/shared/schemas/shell/tests/index.ts index af4a363a..a7d98236 100644 --- a/packages/app-framework/src/shared/schemas/shell/tests/index.ts +++ b/packages/app-framework/src/shared/schemas/shell/tests/index.ts @@ -1,4 +1,3 @@ -export { schemaBenchmarkTemplate } from './SchemaBenchmark.schema'; export { schemaTokensTemplate } from './SchemaTokens.schema'; export { schemaMutationsTemplate } from './SchemaMutations.schema'; export { schemaMutationActions } from './SchemaMutations.actions'; diff --git a/packages/app-framework/src/shared/schemas/shell/tests/testStore.ts b/packages/app-framework/src/shared/schemas/shell/tests/testStore.ts index 8088ad81..a6b5fb91 100644 --- a/packages/app-framework/src/shared/schemas/shell/tests/testStore.ts +++ b/packages/app-framework/src/shared/schemas/shell/tests/testStore.ts @@ -5,8 +5,6 @@ import { queryIRFlag } from '@shared/queryIRFlag'; import { registerModel } from '@shared/registries/modelRegistry'; import { type Accessor, createEffect, createSignal } from 'solid-js'; -import { benchmarkBasePath, benchmarkRoutes } from './SchemaBenchmark.schema'; - // --------------------------------------------------------------------------- // Test model — lightweight AD4M model for $query testing // --------------------------------------------------------------------------- @@ -27,104 +25,11 @@ export class TestItem extends Ad4mModel { @HasMany(() => TestChild, { through: 'we://test_child' }) children: string[] = []; } -// --------------------------------------------------------------------------- -// Benchmark types -// --------------------------------------------------------------------------- - -/** Raw timestamps reported by BenchmarkTimer. All are `performance.now()` values, not durations — - * the store derives the phase durations, so the timer stays a dumb probe with no notion of when - * navigation started. */ -export type BenchMarks = { - /** Timer component body ran — i.e. every preceding sibling node has been walked and built. */ - createdAt: number; - /** Solid onMount — the tree is attached to the document. */ - mountedAt: number; - /** Microtask checkpoint — Lit's async first render + updated() have flushed. */ - flushedAt: number; - /** Second rAF — style, layout and paint for the frame have been committed. */ - paintedAt: number; - /** Total elements in the document at paint time (route content + constant shell chrome). */ - elements: number; - /** Of those, custom elements (tag name contains a hyphen). */ - customElements: number; -}; - -/** One measured render, split into phases. Durations in ms; counts are route content only - * (the shell chrome baseline captured on /idle has already been subtracted). */ -export type BenchSample = { - /** Navigation → tree built. Schema walk + token resolution + detached DOM construction. */ - build: number; - /** Tree built → attached. DOM insertion and custom-element upgrade. */ - mount: number; - /** Attached → Lit flushed. Per-instance DS prop computation and CSSOM writes. */ - flush: number; - /** Lit flushed → painted. Style, layout, paint. */ - paint: number; - /** Navigation → painted. */ - total: number; - elements: number; - customElements: number; -}; - -/** Aggregated result for one route. */ -export type BenchResult = { - label: string; - /** Median across the warm samples — the headline number. */ - median: BenchSample | null; - /** Fastest and slowest warm sample totals. Displayed as a spread so every result carries its own - * error bar: measured within-run spread ranges from 0.4% (Update Perf) to ~8% (Solid Components), - * so a delta smaller than a route's own spread means nothing. */ - spreadLow: number; - spreadHigh: number; - /** Each phase paired with its share of the median total, so the UI can render one row per phase - * from a single path. - * - * Share is deliberately what gets emphasis, not absolute ms: Static Extreme's 563ms build is not - * "worse" than Deep Nesting's 9.4ms, it is 60× the work. And a share carries no quality - * judgment — so the dominant phase is highlighted rather than painted red. */ - phase: { - build: { value: number; share: number }; - mount: { value: number; share: number }; - flush: { value: number; share: number }; - paint: { value: number; share: number }; - }; - /** Spread as a percentage of the median total. This one *is* a quality signal: a wide spread - * means the route's own noise exceeds the deltas we'd be trying to read from it. */ - spreadPct: number; - /** How many warm samples contributed to `median`. */ - sampleCount: number; - /** JS heap in MB while parked on /idle, immediately before this route rendered. - * - * Here to test a specific hypothesis: across consecutive runs, custom-element-heavy routes - * ($each Flat, Nested $each, Web Components) drift *slower* while Solid Components drifts - * faster, with the rise concentrated in flush and paint. That pattern fits heap accumulation - * rather than CPU state — and the primitives carry a candidate, a retained per-instance - * `_prevDSSnapshot` JSON string. If this figure climbs down the list, that's the confirmation. - * - * Chrome-only (`performance.memory` is non-standard); 0 elsewhere. */ - heapMb: number; - /** Median total ÷ element count, in µs. The only figure comparable across routes. */ - usPerElement: number; - /** Median total ÷ custom-element count, in µs. */ - usPerCustomElement: number; - /** Median duration of a reactive update burst, for routes that measure one. */ - updateMs: number | null; -}; - -/** A route enqueued for measurement. */ -type BenchTarget = { - key: string; - path: string; - label: string; - /** When set, the runner stays on the route after mount sampling and measures reactive updates. */ - measuresUpdate?: boolean; -}; - // --------------------------------------------------------------------------- // Store factory — test-oriented signals for integration test template // --------------------------------------------------------------------------- -export function createTestStore(testPerspective: Accessor, navigate: (to: string) => void) { +export function createTestStore(testPerspective: Accessor) { registerModel('TestItem', TestItem as any); registerModel('TestChild', TestChild as any); @@ -143,35 +48,6 @@ export function createTestStore(testPerspective: Accessor(null); - const [benchResults, setBenchResults] = createSignal>({}); - const [benchStatus, setBenchStatus] = createSignal(''); - // Overall session progress, 0–100. Drives a *determinate* bar in the run overlay — deliberately - // not a spinner, since an animating element would add continuous compositor work inside the - // measured window on every sample. - const [benchProgress, setBenchProgress] = createSignal(0); - const [benchRouteProgress, setBenchRouteProgress] = createSignal(''); - /** Boolean form of benchStatus, for `loading`/`disabled` props that need a real boolean. */ - const benchRunning = () => benchStatus() !== ''; - - // Runner state. Deliberately plain `let` rather than signals — nothing renders from it, and - // making it reactive would re-run the route being measured mid-sample. - let benchQueue: BenchTarget[] = []; - let benchCurrent: BenchTarget | null = null; - let benchPending: BenchSample[] = []; - let benchNavStartedAt = 0; - let benchBaseline = { elements: 0, customElements: 0 }; - let benchIdleHeapMb = 0; - let benchUpdatePending: number[] = []; - let benchTotalRoutes = 0; - let benchDoneRoutes = 0; - // ---- List data (for $each) ---- const fruits = [ { name: 'Apple', color: 'red', emoji: '🍎' }, @@ -211,20 +87,6 @@ export function createTestStore(testPerspective: Accessor ({ - name: `Item ${i + 1}`, - category: `Category ${String.fromCharCode(65 + (i % 5))}`, - })); - - const benchGroups = Array.from({ length: 10 }, (_, g) => ({ - name: `Group ${g + 1}`, - items: Array.from({ length: 10 }, (_, i) => ({ - label: `Item ${g * 10 + i + 1}`, - detail: `detail-${g}-${i}`, - })), - })); - // ---- Actions ---- function increment() { setCounter((c) => c + 1); @@ -239,259 +101,6 @@ export function createTestStore(testPerspective: Accessor ({ - key: r.key, - path: r.nav, - label: r.label, - measuresUpdate: r.measuresUpdate, - })); - - /** One pass over the document for both counts. Called on /idle (baseline) and at paint. */ - function benchCountDom(): { elements: number; customElements: number } { - const all = document.querySelectorAll('*'); - let custom = 0; - for (const el of all) if (el.tagName.includes('-')) custom++; - return { elements: all.length, customElements: custom }; - } - - /** `performance.memory` is a non-standard Chrome extension — absent elsewhere, hence the guard. */ - function benchHeapMb(): number { - const mem = (performance as Performance & { memory?: { usedJSHeapSize: number } }).memory; - return mem ? Math.round(mem.usedJSHeapSize / 1048576) : 0; - } - - function benchShare(part: number | undefined, total: number | undefined): number { - if (!part || !total) return 0; - return Math.round((part / total) * 100); - } - - function benchMedian(values: number[]): number { - if (values.length === 0) return 0; - const sorted = [...values].sort((a, b) => a - b); - return sorted[Math.floor((sorted.length - 1) / 2)]; - } - - /** Returns the sample whose `total` is the median, rather than taking a per-field median across - * samples. A per-field median would produce a row whose phases don't add up to its own total — - * each field could come from a different run. This keeps every reported row internally coherent. */ - function benchMedianSample(samples: BenchSample[]): BenchSample | null { - if (samples.length === 0) return null; - const sorted = [...samples].sort((a, b) => a.total - b.total); - return sorted[Math.floor((sorted.length - 1) / 2)]; - } - - function benchRun(key: string) { - const target = benchTargets.find((t) => t.key === key); - if (target) benchStart([target]); - } - - function benchRunAll() { - setBenchResults({}); - benchStart(benchTargets); - } - - function benchStart(targets: BenchTarget[]) { - benchQueue = [...targets]; - benchTotalRoutes = targets.length; - benchDoneRoutes = 0; - setBenchProgress(0); - benchNextTarget(); - } - - function benchNextTarget() { - const next = benchQueue.shift(); - if (!next) { - benchCurrent = null; - benchNavStartedAt = 0; - setBenchStatus(''); - setBenchRouteProgress(''); - setBenchProgress(0); - setTimeout(() => navigate(benchmarkBasePath), BENCH_IDLE_SETTLE_MS); - return; - } - benchCurrent = next; - benchPending = []; - benchUpdatePending = []; - benchNextSample(); - } - - function benchNextSample() { - const target = benchCurrent; - if (!target) return; - const perRoute = BENCH_WARMUP + BENCH_SAMPLES; - setBenchStatus(`${target.label} — render sample ${benchPending.length + 1}/${perRoute}`); - setBenchRouteProgress(`Route ${benchDoneRoutes + 1} of ${benchTotalRoutes}`); - // Progress across the whole session, counting part-finished routes so the bar advances - // smoothly rather than jumping once per route. - const fraction = (benchDoneRoutes + benchPending.length / perRoute) / Math.max(benchTotalRoutes, 1); - setBenchProgress(Math.round(fraction * 100)); - - // Bounce through /idle first. Navigating to the path we're already on is a no-op, so a repeat - // sample would otherwise never remount and we'd measure nothing. Parking on a near-empty route - // also means the previous route's teardown happens here, outside the measured window. - navigate(benchIdlePath); - setTimeout(() => { - // Baseline is captured while /idle is mounted, so subtracting it from the paint-time count - // leaves route content only — the surrounding shell chrome is constant and cancels out. - benchBaseline = benchCountDom(); - // Sampled here rather than at paint: the question is whether heap is *accumulating* across - // routes, and /idle is the only comparable point — same near-empty page every time, so a - // rising figure down the results list is growth rather than a difference in route size. - benchIdleHeapMb = benchHeapMb(); - benchNavStartedAt = performance.now(); - navigate(target.path); - }, BENCH_IDLE_SETTLE_MS); - } - - /** Called by BenchmarkTimer once a route has painted. */ - function benchRecordRender(marks: BenchMarks) { - // No active session (someone navigated straight to a route URL) — there's no navigation - // timestamp to measure `build` against, so recording it would report a garbage figure derived - // from whenever the last session happened to start. Drop it rather than publish a wrong number. - if (!benchCurrent || !benchNavStartedAt) return; - - // Rounded at capture so every downstream consumer (display, medians, baseline JSON) agrees on - // the same value — rounding at display time only would let the median pick one sample while the - // UI showed a different rounding of it. - const round = (n: number) => Math.round(n * 10) / 10; - benchPending.push({ - build: round(marks.createdAt - benchNavStartedAt), - mount: round(marks.mountedAt - marks.createdAt), - flush: round(marks.flushedAt - marks.mountedAt), - paint: round(marks.paintedAt - marks.flushedAt), - total: round(marks.paintedAt - benchNavStartedAt), - elements: marks.elements - benchBaseline.elements, - customElements: marks.customElements - benchBaseline.customElements, - }); - - if (benchPending.length < BENCH_WARMUP + BENCH_SAMPLES) { - benchNextSample(); - return; - } - // Render sampling done. Update-measuring routes stay mounted for the burst below. - if (benchCurrent.measuresUpdate) { - benchRunUpdateBurst(); - return; - } - benchFinalize(); - } - - /** - * Reactive-update measurement, run against the already-mounted route. - * - * Mount cost and update cost are separate questions: the renderer allocates a memo per prop and, - * on the web-component path, an effect per prop as well — so a template can mount acceptably and - * still update badly. Flipping one store signal and measuring through to the next committed paint - * is the only figure that exercises that path. - */ - function benchRunUpdateBurst() { - if (benchUpdatePending.length >= BENCH_WARMUP + BENCH_UPDATE_SAMPLES) { - // Drop the warm-up burst for the same reason as the render warm-up above. - benchUpdatePending = benchUpdatePending.slice(BENCH_WARMUP); - benchFinalize(); - return; - } - setBenchStatus(`${benchCurrent?.label} — update sample ${benchUpdatePending.length + 1}`); - const startedAt = performance.now(); - setCounter((c) => c + 1); - requestAnimationFrame(() => - requestAnimationFrame(() => { - benchUpdatePending.push(performance.now() - startedAt); - benchRunUpdateBurst(); - }), - ); - } - - function benchFinalize() { - const target = benchCurrent; - if (!target) return; - // The warm-up sample is still discarded — it is genuinely different for the first route of a - // session — but no longer displayed. Measured across five runs its spread reached 32% on a - // single route, so as a *reported* figure it was noise wearing the label of a finding. - const [, ...warm] = benchPending; - const median = benchMedianSample(warm); - - // Trimmed range: sort the warm samples and drop the single slowest before measuring spread. - // - // Plain min–max over five samples is dominated by one outlier, which made it useless as an - // error bar — the one job it has. Measured across three consecutive runs, Static Small reported - // 6% / 25% / 45% spread while its median moved less than 5% (42.5 / 42.3 / 40.5ms): four - // samples sat near 38–41ms and a lone 55.9ms set the range. Those outliers line up with the GC - // pauses visible in the heap figures, so trimming one sample removes the pause without hiding - // genuine instability — a route that is really unstable is unstable in more than one sample. - const sortedTotals = [...warm.map((s) => s.total)].sort((a, b) => a - b); - const totals = sortedTotals.length > 2 ? sortedTotals.slice(0, -1) : sortedTotals; - - setBenchResults((prev) => ({ - ...prev, - [target.key]: { - label: target.label, - median, - spreadLow: totals.length ? Math.min(...totals) : 0, - spreadHigh: totals.length ? Math.max(...totals) : 0, - spreadPct: - median && median.total > 0 && totals.length - ? Math.round(((Math.max(...totals) - Math.min(...totals)) / median.total) * 100) - : 0, - phase: { - build: { value: median?.build ?? 0, share: benchShare(median?.build, median?.total) }, - mount: { value: median?.mount ?? 0, share: benchShare(median?.mount, median?.total) }, - flush: { value: median?.flush ?? 0, share: benchShare(median?.flush, median?.total) }, - paint: { value: median?.paint ?? 0, share: benchShare(median?.paint, median?.total) }, - }, - heapMb: benchIdleHeapMb, - sampleCount: warm.length, - // µs, so small per-element figures stay legible as integers. - usPerElement: median && median.elements > 0 ? Math.round((median.total * 1000) / median.elements) : 0, - usPerCustomElement: - median && median.customElements > 0 ? Math.round((median.total * 1000) / median.customElements) : 0, - updateMs: benchUpdatePending.length ? Math.round(benchMedian(benchUpdatePending) * 10) / 10 : null, - }, - })); - benchDoneRoutes++; - benchNextTarget(); - } - - function benchClearResults() { - setBenchResults({}); - setBenchLastResult(null); - setBenchStatus(''); - setBenchRouteProgress(''); - setBenchProgress(0); - benchTotalRoutes = 0; - benchDoneRoutes = 0; - benchQueue = []; - benchCurrent = null; - benchPending = []; - benchUpdatePending = []; - benchNavStartedAt = 0; - } - - // No in-app baseline. It was tried and removed: the workflow it existed for is - // pin → change code → *reload* → re-run → read delta, and the reload is precisely where the - // ~10% cross-session drift lives (Static Small settled 72.2 → 66.3 → 65.1 → 64.2 → 64.0 across - // sessions while varying only 1.7% within one). A stored baseline would therefore have reported - // drift as if it were signal. Comparing two pasted result sets is both simpler and honest about - // what it's comparing. - async function createTestItem() { const p = perspective(); if (!p) return; @@ -597,8 +206,6 @@ export function createTestStore(testPerspective: Accessor }; - -describe('happy-dom capability guard', () => { - it('supports the DOM APIs Lit requires', () => { - expect(typeof customElements).not.toBe('undefined'); - expect(typeof Element.prototype.attachShadow).toBe('function'); - expect('adoptedStyleSheets' in ShadowRoot.prototype).toBe(true); - - // Constructable stylesheets specifically — what Lit's `static styles` relies on. - const sheet = new CSSStyleSheet(); - sheet.replaceSync('.x { color: red }'); - expect(sheet.cssRules.length).toBe(1); - }); - - it('upgrades a real we-text and runs the DS prop pipeline', async () => { - expect(customElements.get('we-text')).toBeTruthy(); - - const el = document.createElement('we-text') as LitElement; - el.setAttribute('color', 'neutral-800'); - document.body.appendChild(el); - // Lit renders on a microtask; updateComplete is how we know the first render finished. - await el.updateComplete; - - // A shadow root proves the element upgraded; an inline style proves updateAllCustomVars ran. - // The second is the one that matters — flush is ~83% that function, so if it silently stopped - // executing the flush numbers would collapse and look like a spectacular optimisation. - expect(el.shadowRoot).toBeTruthy(); - expect((el.getAttribute('style') ?? '').length).toBeGreaterThan(0); - - el.remove(); - }); - - it('exposes the real Solid layout components', () => { - expect(typeof Column).toBe('function'); - expect(typeof Row).toBe('function'); - }); -}); diff --git a/packages/schema-system/benchmarks/bench/renderTree.bench.tsx b/packages/schema-system/benchmarks/bench/renderTree.bench.tsx deleted file mode 100644 index 50136d68..00000000 --- a/packages/schema-system/benchmarks/bench/renderTree.bench.tsx +++ /dev/null @@ -1,184 +0,0 @@ -/** - * Headless render benchmark for the schema system. - * - * WHY THIS PACKAGE EXISTS - * - * Iterating against the in-app suite (SchemaBenchmark.schema.ts) means edit → rebuild → reload → - * run 12 routes three times → read results. That loop is slow enough to encourage guessing, and - * guessing already cost a 2.5x Build regression that reached the app before being caught. - * - * It lives in its own package rather than in @we/schema-solid because measuring the real cost needs - * the real design system, and @we/schema-solid must not depend on it — the renderer is a thin - * adapter over an *injected* registry, and knowing nothing about the DS is what keeps it portable. - * Nothing depends on this package, so it is free to depend on both. - * - * WHY BOTH REGISTRIES - * - * The same fixtures are rendered twice: once through stub components, once through the real ones. - * - * stub — the schema walk in isolation - * real — the walk plus everything it causes downstream (buildLayoutStyles, Lit reactive-property - * setters, the ~59 CSSOM writes per DS element) - * - * The gap between them is the point. A change that consolidated per-prop memos measured +6% against - * stubs and +160% in the real app, because the cost lived entirely in what the per-prop effects then - * did. A stub-only harness is structurally blind to that whole class of change — which is the class - * most renderer optimisations fall into. - * - * SCOPE - * - * Build ✅ schema walk, prop resolution, reactive allocation, DOM creation - * Flush ✅ Lit's async first render + DS prop pipeline (drained via updateComplete) - * Paint ✗ happy-dom has no layout engine - * - * Paint is ~30% of total in the real suite, so this is not a replacement for it. Treat a result - * here as a filter: a regression means stop, a win is a hypothesis to confirm in the app on a - * settled run 3. - * - * Note Lit runs in dev mode here, as it does in the app's dev server — absolute numbers are not - * production figures, but comparisons between two versions of the renderer are valid. - * - * Run: pnpm --filter @we/schema-bench bench - */ -// Side-effect import: defines we-text, we-button and the rest as custom elements. -import '@we/primitives'; - -import { Column, Row } from '@we/components/solid'; -import type { SchemaNode } from '@we/schema-shared'; -import type { ComponentRegistry } from '@we/schema-solid'; -import { RenderSchema } from '@we/schema-solid'; -import type { JSX } from 'solid-js'; -import { createStore } from 'solid-js/store'; -import { render } from 'solid-js/web'; -import { describe, expect, it } from 'vitest'; - -/** Discarded — the first builds pay one-time JIT and Lit template compilation. */ -const WARMUP = 2; -const SAMPLES = 5; - -/** Renderer in isolation: no style computation, no custom elements. */ -const Passthrough = (props: { children?: JSX.Element }) =>
{props.children}
; -const STUB_REGISTRY: ComponentRegistry = { Column: Passthrough, Row: Passthrough }; - -/** The real thing — same components the app renders through. */ -const REAL_REGISTRY: ComponentRegistry = { Column, Row }; - -const stores = { - testStore: { stringValue: 'hello', numberValue: 42, boolTrue: true, boolFalse: false }, -}; - -/** Mirrors staticCard in SchemaBenchmark.schema.ts: 1 Column + 3 we-text, all-static props. */ -function staticCard(id: number): SchemaNode { - return { - type: 'Column', - props: { p: '300', gap: '200', bg: 'neutral-0', r: '300', border: '1px solid neutral-200' }, - children: [ - { type: 'we-text', props: { text: `Card ${id}`, fontSize: '400', fontWeight: '600', color: 'neutral-800' } }, - { type: 'we-text', props: { text: `Description ${id}`, fontSize: '300', color: 'neutral-600' } }, - { type: 'we-text', props: { text: `Detail ${id}`, fontSize: '200', color: 'neutral-400' } }, - ], - }; -} - -/** Mirrors tokenCard: same shape, but props and children carry $store / $if / $concat tokens. */ -function tokenCard(id: number): SchemaNode { - return { - type: 'Column', - props: { p: '300', gap: '200', bg: 'neutral-0', r: '300' }, - children: [ - { - type: 'we-text', - props: { - fontSize: '400', - color: { $if: { condition: { $store: 'testStore.boolTrue' }, then: 'neutral-600', else: 'danger-600' } }, - }, - children: [{ $concat: ['Card ', { $store: 'testStore.stringValue' }, ` #${id}`] }], - }, - { - type: 'we-text', - props: { fontSize: '300', color: 'neutral-500' }, - children: [{ $concat: ['Count: ', { $store: 'testStore.numberValue' }] }], - }, - ], - }; -} - -function tree(count: number, factory: (id: number) => SchemaNode): SchemaNode { - return { - type: 'Column', - props: { width: '100%', gap: '200' }, - children: Array.from({ length: count }, (_, i) => factory(i + 1)), - }; -} - -type Sample = { build: number; flush: number }; - -/** Lit exposes `updateComplete` on upgraded elements; plain DOM nodes don't. */ -type MaybeLitElement = Element & { updateComplete?: Promise }; - -/** One full render. Build is the synchronous walk; flush drains Lit's async first render. */ -async function timeRender(node: SchemaNode, registry: ComponentRegistry): Promise { - const container = document.createElement('div'); - document.body.appendChild(container); - const [schema] = createStore(node); - - const t0 = performance.now(); - const dispose = render(() => , container); - const built = performance.now(); - - // Collect first, then time the await — walking 8000 elements to find pending updates is itself - // significant work and would otherwise be charged to flush. - 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 }; -} - -function median(values: number[]): number { - const sorted = [...values].sort((a, b) => a - b); - return sorted[Math.floor((sorted.length - 1) / 2)]; -} - -async function measure(node: SchemaNode, registry: ComponentRegistry): Promise { - for (let i = 0; i < WARMUP; i++) await timeRender(node, registry); - const samples: Sample[] = []; - for (let i = 0; i < SAMPLES; i++) samples.push(await timeRender(node, registry)); - return { - build: median(samples.map((s) => s.build)), - flush: median(samples.map((s) => s.flush)), - }; -} - -async function compare(label: string, node: SchemaNode) { - const stub = await measure(node, STUB_REGISTRY); - const real = await measure(node, REAL_REGISTRY); - const amplification = stub.build > 0 ? (real.build + real.flush) / stub.build : 0; - - console.log( - `${label.padEnd(30)}` + - `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 ` + - `(${amplification.toFixed(1)}x stub)`, - ); -} - -describe('schema render (headless)', () => { - it('static trees', async () => { - await compare('static 50 (200 nodes)', tree(50, staticCard)); - await compare('static 200 (800 nodes)', tree(200, staticCard)); - await compare('static 1000 (4000 nodes)', tree(1000, staticCard)); - expect(true).toBe(true); - }); - - it('token trees', async () => { - await compare('token 50 (150 nodes)', tree(50, tokenCard)); - await compare('token 200 (600 nodes)', tree(200, tokenCard)); - expect(true).toBe(true); - }); -}); diff --git a/packages/schema-system/benchmarks/package.json b/packages/schema-system/benchmarks/package.json deleted file mode 100644 index 30b0a002..00000000 --- a/packages/schema-system/benchmarks/package.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "private": true, - "name": "@we/schema-bench", - "version": "0.1.0", - "description": "Headless render benchmarks for the schema system, measured through both a stub and the real design-system registry", - "type": "module", - "scripts": { - "bench": "vitest run", - "probe": "vitest run --reporter=verbose" - }, - "devDependencies": { - "@we/components": "workspace:*", - "@we/primitives": "workspace:*", - "@we/schema-shared": "workspace:*", - "@we/schema-solid": "workspace:*", - "happy-dom": "^20.8.4", - "solid-js": "^1.9.5", - "vite-plugin-solid": "^2.11.11", - "vitest": "^4.1.0" - } -} diff --git a/packages/schema-system/benchmarks/vitest.config.ts b/packages/schema-system/benchmarks/vitest.config.ts deleted file mode 100644 index c1d068d5..00000000 --- a/packages/schema-system/benchmarks/vitest.config.ts +++ /dev/null @@ -1,19 +0,0 @@ -import solidPlugin from 'vite-plugin-solid'; -import { defineConfig } from 'vitest/config'; - -export default defineConfig({ - plugins: [solidPlugin()], - resolve: { - // 'solid' first so @we/schema-solid resolves to its src/ rather than a possibly stale dist/ — - // the whole point is measuring the renderer as it currently is. 'browser' is required because - // vitest otherwise picks solid-js's server build, which throws "Client-only API called on the - // server side" as soon as the renderer imports AnimateRenderer. - conditions: ['solid', 'development', 'browser'], - }, - test: { - environment: 'happy-dom', - include: ['bench/**/*.{bench,probe}.{ts,tsx}'], - // Building thousands of nodes repeatedly is well past the 5s default. - testTimeout: 180_000, - }, -}); diff --git a/packages/schema-system/frameworks/solid/src/SchemaRenderer.tsx b/packages/schema-system/frameworks/solid/src/SchemaRenderer.tsx index 07efd48e..120734c7 100644 --- a/packages/schema-system/frameworks/solid/src/SchemaRenderer.tsx +++ b/packages/schema-system/frameworks/solid/src/SchemaRenderer.tsx @@ -888,6 +888,14 @@ export function RenderSchema({ node, stores, registry, context = {}, children }: // Create per-prop memos — each prop resolves independently, // isolating its reactive dependencies. Static props still read from // the store reactively so that updateSchema mutations are tracked. + // + // MEASURED DEAD END — one memo per prop looks wasteful for a node whose props are all static + // literals, and consolidating them into a single shared memo per node is the obvious fix. It was + // tried and measured **slower**: roughly +6% headless, and worse in the browser. The per-read + // indirection and the object allocation it introduced outweighed every memo it removed. Do not + // retry without a materially different approach — resolving static props at template-install time + // (a schema pre-compilation step) is the direction that has not been tried. + // See docs/architecture/performance.md for how these costs were measured. // resolveProp is called INSIDE the memo so that plain-value resolvers // ($not, $eq, $ne, $and, $or) correctly track signal dependencies. const propMemos: Record unknown> = {}; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e7a76fb5..bc38554a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -203,6 +203,46 @@ importers: specifier: ^4.1.0 version: 4.1.10(@types/node@24.13.2)(@vitest/coverage-v8@4.1.10)(happy-dom@20.10.6)(jsdom@27.4.0)(vite@6.4.3(@types/node@24.13.2)(sass@1.101.0)(tsx@4.23.0)(yaml@2.9.0)) + apps/playgrounds/solid/render-bench: + dependencies: + '@we/components': + specifier: workspace:* + version: link:../../../../packages/design-system/4-components + '@we/design-utils': + specifier: workspace:* + version: link:../../../../packages/design-system/utils + '@we/primitives': + specifier: workspace:* + version: link:../../../../packages/design-system/3-primitives + '@we/schema-shared': + specifier: workspace:* + version: link:../../../../packages/schema-system/shared + '@we/schema-solid': + specifier: workspace:* + version: link:../../../../packages/schema-system/frameworks/solid + '@we/tokens': + specifier: workspace:* + version: link:../../../../packages/design-system/1-tokens + solid-js: + specifier: ^1.9.5 + version: 1.9.14 + devDependencies: + happy-dom: + specifier: ^20.8.4 + version: 20.10.6 + typescript: + specifier: ^5.9.3 + version: 5.9.3 + vite: + specifier: ^6.0.7 + version: 6.4.3(@types/node@24.13.2)(sass@1.101.0)(tsx@4.23.0)(yaml@2.9.0) + vite-plugin-solid: + specifier: ^2.11.11 + version: 2.11.12(solid-js@1.9.14)(vite@6.4.3(@types/node@24.13.2)(sass@1.101.0)(tsx@4.23.0)(yaml@2.9.0)) + vitest: + specifier: ^4.1.0 + version: 4.1.10(@types/node@24.13.2)(@vitest/coverage-v8@4.1.10)(happy-dom@20.10.6)(jsdom@27.4.0)(vite@6.4.3(@types/node@24.13.2)(sass@1.101.0)(tsx@4.23.0)(yaml@2.9.0)) + apps/we-electron: dependencies: '@apollo/client': @@ -822,33 +862,6 @@ importers: specifier: ^8.5.1 version: 8.5.1(postcss@8.5.16)(tsx@4.23.0)(typescript@5.9.3)(yaml@2.9.0) - packages/schema-system/benchmarks: - devDependencies: - '@we/components': - specifier: workspace:* - version: link:../../design-system/4-components - '@we/primitives': - specifier: workspace:* - version: link:../../design-system/3-primitives - '@we/schema-shared': - specifier: workspace:* - version: link:../shared - '@we/schema-solid': - specifier: workspace:* - version: link:../frameworks/solid - happy-dom: - specifier: ^20.8.4 - version: 20.10.6 - solid-js: - specifier: ^1.9.5 - version: 1.9.14 - vite-plugin-solid: - specifier: ^2.11.11 - version: 2.11.12(solid-js@1.9.14)(vite@7.3.6(@types/node@24.13.2)(sass@1.101.0)(tsx@4.23.0)(yaml@2.9.0)) - vitest: - specifier: ^4.1.0 - version: 4.1.10(@types/node@24.13.2)(@vitest/coverage-v8@4.1.10)(happy-dom@20.10.6)(jsdom@27.4.0)(vite@7.3.6(@types/node@24.13.2)(sass@1.101.0)(tsx@4.23.0)(yaml@2.9.0)) - packages/schema-system/frameworks/solid: dependencies: '@we/design-types':