From 2b435a66ab40214933defe45c57a9352489343de Mon Sep 17 00:00:00 2001 From: jhweir Date: Tue, 21 Jul 2026 15:25:13 +0100 Subject: [PATCH 1/6] refactor(app-framework): remove the in-app benchmark suite MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The benchmark suite lived inside the app it was measuring, and the app kept contaminating the measurements. An animating spinner inflated every figure in proportion to DOM size; the results panel rendered inside the measured route and so measured itself; switching fixtures went through the router, which will not re-render the route you are already on, so repeat samples needed an "/idle" route to bounce through and a click handler that navigated could silently pre-empt the sampler and hang a run on sample 1. Those are not bugs to fix one at a time — they follow from where the suite was mounted. It now lives in apps/playgrounds/solid/render-bench, where a fixture is a value rather than a route. Removing the suite leaves createTestStore's `navigate` parameter dead — its only use was the "/idle" bouncing above — so it goes too, along with its argument at the one call site. The Tokens, Mutations, Queries and Routing tests deliberately stay: the environment that corrupted the timings is the same environment that validates those. The principle is recorded in SchemaTests.schema.ts. Co-Authored-By: Claude Opus 4.8 --- .../solid/components/BenchmarkTimer.tsx | 78 - .../solid/layouts/TemplateLayout.tsx | 4 +- .../solid/registries/componentRegistry.tsx | 2 - .../schemas/shell/SchemaTests.schema.ts | 36 +- .../shell/tests/SchemaBenchmark.schema.ts | 1493 ----------------- .../src/shared/schemas/shell/tests/index.ts | 1 - .../shared/schemas/shell/tests/testStore.ts | 407 +---- 7 files changed, 27 insertions(+), 1994 deletions(-) delete mode 100644 packages/app-framework/src/frameworks/solid/components/BenchmarkTimer.tsx delete mode 100644 packages/app-framework/src/shared/schemas/shell/tests/SchemaBenchmark.schema.ts 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 Date: Tue, 21 Jul 2026 15:25:34 +0100 Subject: [PATCH 2/6] refactor(bench): replace the benchmarks package with a dedicated harness app MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit packages/schema-system/benchmarks ran headless against happy-dom. That is the right tool for a fast regression filter, but it cannot answer the question the benchmarks were being asked. happy-dom has no layout engine, so paint is invisible, and its JS CSSOM implementation overstates flush by roughly 2.7x versus a real browser — a change that looked like +6% there measured +160% in a browser, because the cost lived in what the per-prop effects went on to do rather than in the walk itself. apps/playgrounds/solid/render-bench keeps both. The headless benchmarks move across unchanged as the fast filter, with their standing explicitly downgraded: a regression there means stop, a win there is only a hypothesis, and no figure from that file is ever published. Alongside them is a browser harness that measures build, flush and paint against a real layout engine and reports the median of five warm samples. What the harness adds over what it replaces: - A ladder of rungs rendering identical output through progressively fewer layers — raw DOM, plain Solid, Solid over design-system components, and the same again binding props as DOM properties rather than attributes. The difference between adjacent rungs is that layer's cost. - tests/ladder.test.tsx, which asserts the rungs stay equivalent. A silent divergence would make every published ratio wrong while still looking plausible, so this runs in CI as a correctness test. - A second ladder on page-shaped content — 50 feed posts, five component types, three levels of nesting — because attribution derived from trivial uniform cards is fairly open to the charge that it does not generalise. - A `minimal` fixture that measures the frame floor rather than leaving it to be guessed at. Paint is bracketed by a double requestAnimationFrame and so cannot report less than one frame interval of waiting. Benchmarks are kept out of CI (vitest.bench.config.ts, separate from vitest.config.ts) — timings on shared runners are noise and must not decide whether a merge is allowed. Co-Authored-By: Claude Opus 4.8 --- apps/playgrounds/solid/render-bench/README.md | 99 ++++ .../render-bench/bench/headless.bench.tsx | 111 ++++ .../playgrounds/solid/render-bench/index.html | 12 + .../solid/render-bench/package.json | 30 ++ .../solid/render-bench/src/controls.tsx | 256 +++++++++ .../solid/render-bench/src/env.d.ts | 6 + .../solid/render-bench/src/fixtures.ts | 499 ++++++++++++++++++ .../solid/render-bench/src/main.tsx | 276 ++++++++++ .../solid/render-bench/src/registry.tsx | 24 + .../solid/render-bench/src/runner.ts | 181 +++++++ .../solid/render-bench/tests/ladder.test.tsx | 176 ++++++ .../render-bench/tests/setProperty.test.ts | 5 +- .../solid/render-bench}/tsconfig.json | 11 +- .../solid/render-bench/vite.config.ts | 12 + .../solid/render-bench/vitest.bench.config.ts | 22 + .../solid/render-bench/vitest.config.ts | 21 + .../benchmarks/bench/environment.probe.ts | 56 -- .../benchmarks/bench/renderTree.bench.tsx | 184 ------- .../schema-system/benchmarks/package.json | 21 - .../schema-system/benchmarks/vitest.config.ts | 19 - pnpm-lock.yaml | 67 ++- 21 files changed, 1772 insertions(+), 316 deletions(-) create mode 100644 apps/playgrounds/solid/render-bench/README.md create mode 100644 apps/playgrounds/solid/render-bench/bench/headless.bench.tsx create mode 100644 apps/playgrounds/solid/render-bench/index.html create mode 100644 apps/playgrounds/solid/render-bench/package.json create mode 100644 apps/playgrounds/solid/render-bench/src/controls.tsx create mode 100644 apps/playgrounds/solid/render-bench/src/env.d.ts create mode 100644 apps/playgrounds/solid/render-bench/src/fixtures.ts create mode 100644 apps/playgrounds/solid/render-bench/src/main.tsx create mode 100644 apps/playgrounds/solid/render-bench/src/registry.tsx create mode 100644 apps/playgrounds/solid/render-bench/src/runner.ts create mode 100644 apps/playgrounds/solid/render-bench/tests/ladder.test.tsx rename packages/schema-system/benchmarks/bench/setProperty.probe.ts => apps/playgrounds/solid/render-bench/tests/setProperty.test.ts (92%) rename {packages/schema-system/benchmarks => apps/playgrounds/solid/render-bench}/tsconfig.json (51%) create mode 100644 apps/playgrounds/solid/render-bench/vite.config.ts create mode 100644 apps/playgrounds/solid/render-bench/vitest.bench.config.ts create mode 100644 apps/playgrounds/solid/render-bench/vitest.config.ts delete mode 100644 packages/schema-system/benchmarks/bench/environment.probe.ts delete mode 100644 packages/schema-system/benchmarks/bench/renderTree.bench.tsx delete mode 100644 packages/schema-system/benchmarks/package.json delete mode 100644 packages/schema-system/benchmarks/vitest.config.ts 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/packages/schema-system/benchmarks/bench/environment.probe.ts b/packages/schema-system/benchmarks/bench/environment.probe.ts deleted file mode 100644 index fae26ce3..00000000 --- a/packages/schema-system/benchmarks/bench/environment.probe.ts +++ /dev/null @@ -1,56 +0,0 @@ -/** - * Environment guard — asserts happy-dom actually supports what the benchmarks depend on. - * - * The measurements in this package are only meaningful if the real design system genuinely - * executes here. Lit needs constructable stylesheets (`new CSSStyleSheet()`, `adoptedStyleSheets`) - * and shadow DOM, and happy-dom's support for those is partial in principle. If any of it silently - * stopped working, the benchmarks would keep reporting numbers that measured nothing — so this - * asserts rather than logs. - * - * Run: pnpm --filter @we/schema-bench probe - */ -// Side-effect import: defines we-text, we-button and the rest as custom elements. Static rather -// than a dynamic `await import()` — @we/primitives' type entry is a globals declaration file -// rather than a module, so a dynamic import doesn't typecheck. -import '@we/primitives'; - -import { Column, Row } from '@we/components/solid'; -import { describe, expect, it } from 'vitest'; - -type LitElement = HTMLElement & { updateComplete?: Promise }; - -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/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': From eb6d621ecc8bb682f3262baabf5e322c67aaaaba Mon Sep 17 00:00:00 2001 From: jhweir Date: Tue, 21 Jul 2026 15:25:52 +0100 Subject: [PATCH 3/6] docs(schema-solid): record a measured dead end at the per-prop memo loop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 a browser, because the per-read indirection and the object allocation it introduced outweighed every memo it removed. That result is invisible in the code, so the next person to read this loop will have the same idea and spend the same time disproving it. Recorded in place, along with the one direction that has not been tried: resolving static props at template-install time. Co-Authored-By: Claude Opus 4.8 --- .../schema-system/frameworks/solid/src/SchemaRenderer.tsx | 8 ++++++++ 1 file changed, 8 insertions(+) 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> = {}; From 81fc26a9e845229b66e1345c4f1db3f9c2c260a7 Mon Sep 17 00:00:00 2001 From: jhweir Date: Tue, 21 Jul 2026 15:25:52 +0100 Subject: [PATCH 4/6] docs: add a render performance report MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Prompted by a colleague asking how much more WE costs to run relative to Solid and raw DOM. Answering that honestly needed more than a ratio, because the obvious way to measure it is misleading in both directions. Headline: WE templates cost about 25% more JavaScript work than hand-writing the same page with the same components, and about 3.4x a hand-rolled DOM page that has none of the design system's theming, states, encapsulation or accessibility. Four fifths of the full stack cost is the design system rather than the template layer — so a team weighing adoption is mostly weighing the design system, which they would also pay for by hand. The report leads with the whole-stack comparison, because that is what a reader evaluating adoption actually wants, and treats the design-system/template split as supporting attribution rather than the opening claim. Two things it is careful about: - Roughly 33ms of every total is the frame floor, not work. A double requestAnimationFrame on a 60Hz display spans up to two frame intervals — 33.3ms predicted, 33.2ms measured against a two-element fixture. Totals are therefore compressed and JS work is the figure that scales. An earlier reading of an update burst at "33ms" was this floor; the real cost is 0.2ms. - Every figure is a median of three runs in a clean browser profile, with the hardware stated, run-to-run ranges given rather than single numbers, and the noisiest rows flagged as such. It also records what the measurements do not explain — templates flushing faster than hand-written components, and a property-versus-attribute binding gap that moves with the fixture. A recommendation about the latter was withdrawn once it failed to reproduce on realistic content. Co-Authored-By: Claude Opus 4.8 --- docs/README.md | 1 + docs/architecture/performance.md | 342 +++++++++++++++++++++++++++++++ 2 files changed, 343 insertions(+) create mode 100644 docs/architecture/performance.md 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..6b80c7fc --- /dev/null +++ b/docs/architecture/performance.md @@ -0,0 +1,342 @@ +# 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. + +Rendering 400 identical cards, four ways. 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, five 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. The design-system layer, by +contrast, moves a great deal with content (36.9ms versus 65.0ms). + +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 | Realistic ladder | 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 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 being five component types deep +rather than two. The design-system layer, by contrast, scales with how much styling the content +demands: 65.0ms for 1,201 elements of simple cards, 36.9ms for 801 elements of richer ones. + +That makes the design system **3–5× the template system's cost**, depending on the page. + +--- + +## 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 653 call sites on the strength of one 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. + +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 78 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. + +**Four fifths of the total stack cost is the design system, not the template system** — so a team +weighing adoption is mostly weighing the design system, which they would also pay for by hand. From 6e892bc647c50dd6778a88e094a588d39db6369d Mon Sep 17 00:00:00 2001 From: jhweir Date: Tue, 21 Jul 2026 15:32:33 +0100 Subject: [PATCH 5/6] docs: correct the design-system layer's cross-fixture comparison MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The report claimed the design-system layer "moves a great deal with content", citing 65.0ms on the simple ladder against 36.9ms on the realistic one. That compared raw milliseconds across fixtures with different element counts — 1,201 against 801 — and read a size difference as a content one. Worse, it invited the reasonable reaction that the simpler fixture was somehow costing more. Normalised, the two nearly agree, and the two available normalisations disagree in direction: the simple fixture is 17% more expensive per element and 14% cheaper per prop. That is the signature of no real per-unit effect, the fixtures simply differing in both element count and props per element. Both residuals are inside the 17–27% run-to-run spread of these particular rows. Re-measuring would not have helped — the gap is deterministic, not noise, and reproduces at any sample count. Now states the size caveat before quoting the two totals rather than after, carries element counts in the layer table so the difference is visible at a glance, and adds a normalised per-element/per-prop table. The design-system-to-template ratio is also now given as 5.3x and 3.1x rather than "3–5x", which clipped the top of its own range, and is labelled a property of the pages measured rather than a constant. Co-Authored-By: Claude Opus 4.8 --- docs/architecture/performance.md | 45 +++++++++++++++++++++++--------- 1 file changed, 33 insertions(+), 12 deletions(-) diff --git a/docs/architecture/performance.md b/docs/architecture/performance.md index 6b80c7fc..312fc104 100644 --- a/docs/architecture/performance.md +++ b/docs/architecture/performance.md @@ -54,8 +54,13 @@ three levels of nesting, and content that varies per card. | **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. The design-system layer, by -contrast, moves a great deal with content (36.9ms versus 65.0ms). +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. @@ -142,19 +147,35 @@ design-system prop pipeline. **Paint** = style, layout, raster, plus the frame w 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 | Realistic ladder | 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 | +| 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 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 being five component types deep -rather than two. The design-system layer, by contrast, scales with how much styling the content -demands: 65.0ms for 1,201 elements of simple cards, 36.9ms for 801 elements of richer ones. - -That makes the design system **3–5× the template system's cost**, depending on the page. +rather than two. + +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 | + +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. --- From e69293a4a53abab4a3f3d2ee8cfb7200411bd1dd Mon Sep 17 00:00:00 2001 From: jhweir Date: Tue, 21 Jul 2026 15:38:33 +0100 Subject: [PATCH 6/6] docs: fix eight inaccuracies found in a full pass over the performance report MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Verified every countable claim against the source rather than trusting the prose. Eight were wrong or unclear: - Component-type counts were understated. The realistic fixture uses seven component types and the simple one three, not "five" and "two". Counted from the hand-written controls. - The +25% headline sat directly above a table of the simple fixture, from which a reader divides +19%. The 25% is the realistic feed's figure; the summary now says so before the table rather than leaving the mismatch to be discovered. - Two ways of computing the tax were being mixed silently. Dividing the median JS-work figures gives +32% realistic and +19% simple; the per-run medians quoted throughout give +25% and +16%. Both appear in the document and a reviewer would find the contradiction immediately. Finding 4 now states both, and why the paired per-run figure is the one used — each run compares rungs under the same session and thermal state, whereas dividing medians combines runs and an unstable denominator inflates the result. - The realistic ladder's design-system figure subtracts the simple ladder's 0.8ms Solid baseline, because that ladder has no plain-Solid rung. Small at this size, but it is an approximation and was presented as a measurement. - The normalisation table's prop counts (4,000 / 1,950) and finding 3's binding counts (2,400 / 1,450) describe the same fixtures and were unreconciled. The latter is the subset landing on custom elements as HTML attributes; now said. - "653 call sites" could not be reproduced — the scope it was counted under is lost. Replaced with over 800 we-* call sites, which a grep reproduces. - "78 design-system props" is 79 non-state props, counted from the key arrays with spreads resolved. - "Four fifths" was used for two different quantities: flush as a share of the design system's cost (81%, correct) and the design system as a share of the whole stack (84%, understated). The latter is now given as a percentage. All derived figures re-checked arithmetically and reconciled with the raw-data tables. Co-Authored-By: Claude Opus 4.8 --- docs/architecture/performance.md | 41 ++++++++++++++++++++++++-------- 1 file changed, 31 insertions(+), 10 deletions(-) diff --git a/docs/architecture/performance.md b/docs/architecture/performance.md index 312fc104..27a7ca8b 100644 --- a/docs/architecture/performance.md +++ b/docs/architecture/performance.md @@ -16,7 +16,9 @@ states, encapsulation or accessibility. On a page of realistic size, none of that is perceptible. Both finish inside a single frame. -Rendering 400 identical cards, four ways. Medians of three runs: +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 | | ------------------------------ | -------------- | ---------- | @@ -45,8 +47,8 @@ untrusted sources. 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, five component types, -three levels of nesting, and content that varies per card. +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 | | ------------------------------ | -------------- | ---------- | @@ -154,9 +156,13 @@ the realistic one omits — both sit at the floor regardless): | 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 being five component types deep -rather than two. +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: @@ -166,6 +172,10 @@ all of that is the fixtures being different sizes. Normalised: | 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 @@ -216,7 +226,7 @@ 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 653 call sites on the strength of one fixture. +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. @@ -231,6 +241,17 @@ oddities live in the same place and may share a cause. 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. @@ -283,8 +304,8 @@ roughly four fifths of it is one per-element pipeline turning design-token props properties. Recent work there cut flush by 27–42% by skipping redundant CSSOM writes. One further reduction is -identified and unimplemented: all 78 design-system props are registered for attribute reflection, -where roughly seven need it. +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 @@ -359,5 +380,5 @@ Adopting WE templates costs **~+25% in JavaScript work** versus hand-writing 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. -**Four fifths of the total stack cost is the design system, not the template system** — so a team -weighing adoption is mostly weighing the design system, which they would also pay for by hand. +**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.