diff --git a/apps/playgrounds/solid/portable-ui-slice/vite.config.ts b/apps/playgrounds/solid/portable-ui-slice/vite.config.ts index c67114f4..6598a8b9 100644 --- a/apps/playgrounds/solid/portable-ui-slice/vite.config.ts +++ b/apps/playgrounds/solid/portable-ui-slice/vite.config.ts @@ -3,14 +3,19 @@ import solidPlugin from 'vite-plugin-solid'; export default defineConfig({ plugins: [solidPlugin()], - // Single solid-js instance across app + libraries. + // Single solid-js instance across app + libraries. Load-bearing: two instances give two owner + // graphs, and scheduled effects (the $query effect) silently stop updating — the initial paint + // still looks correct, so it fails quietly. resolve: { dedupe: ['solid-js', 'solid-js/web', 'solid-js/store'] }, - // NOTE: no alias needed. @we/schema-solid ships a "solid" export condition (→ its JSX source), - // which vite-plugin-solid resolves and compiles in this app's Solid toolchain — a single compiler - // + single runtime, so scheduled reactivity (the $query effect) works. This is exactly what an - // external consumer does; the pre-built dist is a fallback for non-Solid toolchains and remains - // suspect for reactivity. - // TODO: root-cause + fix why the esbuild-plugin-solid dist breaks scheduled effects downstream. + // No alias needed: @we/schema-solid ships a "solid" export condition (→ its JSX source), which + // vite-plugin-solid compiles in this app's own toolchain. That's what a Solid-toolchain consumer + // gets, so it's what this harness should exercise. + // + // The pre-built dist works too — verified in-browser 2026-07-20 (initial paint + live reactivity) + // and guarded headlessly by schema-solid's distReactivity.test.tsx, which runs the feed cases + // against both entry points. A long-standing TODO here claimed esbuild-plugin-solid's dist broke + // scheduled effects downstream; it did not reproduce by any route, and the likeliest history is + // that the real cause was a duplicate solid-js instance, fixed by the dedupe above. server: { port: 3200, fs: { allow: ['../../../..'] }, diff --git a/packages/app-framework/src/frameworks/solid/components/BenchmarkTimer.tsx b/packages/app-framework/src/frameworks/solid/components/BenchmarkTimer.tsx index 0339f39c..a4d70b79 100644 --- a/packages/app-framework/src/frameworks/solid/components/BenchmarkTimer.tsx +++ b/packages/app-framework/src/frameworks/solid/components/BenchmarkTimer.tsx @@ -1,29 +1,66 @@ 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 = { - /** Callback to record the render duration and route name */ - onComplete: (duration: number, routeName: string) => void; + /** 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. - * Records performance.now() at creation time, then measures elapsed - * time at onMount+rAF (after all siblings painted). * - * The elapsed time is passed back via the `onComplete` callback so it - * can be displayed elsewhere on the page (e.g. in a results summary). - * This component itself only renders a "Rendered" confirmation badge. + * 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(() => { - requestAnimationFrame(() => { - const elapsed = performance.now() - createdAt; - props.onComplete(elapsed, props.label ?? ''); + 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 }); + }); + }); }); }); diff --git a/packages/app-framework/src/frameworks/solid/layouts/TemplateLayout.tsx b/packages/app-framework/src/frameworks/solid/layouts/TemplateLayout.tsx index 6a261027..94651a03 100644 --- a/packages/app-framework/src/frameworks/solid/layouts/TemplateLayout.tsx +++ b/packages/app-framework/src/frameworks/solid/layouts/TemplateLayout.tsx @@ -227,6 +227,12 @@ export function TemplateLayout(props: ParentProps & { stores: Stores }) { height="100%" zIndex={11} overflow="auto" + // Reserve the scrollbar gutter permanently. Without this, any shell view whose + // content crosses the viewport height gains and loses its scrollbar as content + // changes, and every reflow shifts the whole page horizontally. Most visible in the + // benchmark runner, which swaps between routes of wildly different heights, but it + // affects any shell view with variable-length content. + scrollbarGutter="stable" > diff --git a/packages/app-framework/src/frameworks/solid/providers/TemplateProvider.tsx b/packages/app-framework/src/frameworks/solid/providers/TemplateProvider.tsx index 80479824..82d8c408 100644 --- a/packages/app-framework/src/frameworks/solid/providers/TemplateProvider.tsx +++ b/packages/app-framework/src/frameworks/solid/providers/TemplateProvider.tsx @@ -1,7 +1,7 @@ import type { PerspectiveProxy } from '@coasys/ad4m'; -import { createAd4mQueryAdapter } from '@shared/ad4mAdapter'; +import { createAd4mDataBindings } from '@shared/ad4mAdapter'; import { queryIRFlag } from '@shared/queryIRFlag'; -import { getModel, getModelForPerspective } from '@shared/registries/modelRegistry'; +import { getModel } from '@shared/registries/modelRegistry'; import { shellRegistry } from '@shared/registries/shellRegistry'; import { componentRegistry as registry } from '@solid/registries/componentRegistry'; import { @@ -78,19 +78,19 @@ export default function TemplateProvider() { routeStore, consoleStore, model: modelStore, - $getModel: getModel, - $getModelForPerspective: getModelForPerspective, + // Host wiring, not backend adaptation — any backend would wire these the same way, so they stay + // here rather than pretending to be AD4M-specific. $onError: (msg: string) => toastService.error(msg), $useQueryIR: queryIRFlag.enabled, // reactive; default from the seed, live-toggled via testStore - // Neutral identity/dataset vocabulary: templates say `$me` / `$currentDataset`, not - // `adamStore.me` / `adamStore.currentPerspective`, so they stay backend-neutral (a NextGraph host - // injects its own). Both are just the AD4M store's signals; templates read `$me.did`. + // Template-facing vocabulary (templates read `$me.did`), as opposed to the renderer-facing + // bindings below: the renderer never reads `$me` itself, it resolves like any `$store` path. $me: adamStore.me, - $currentDataset: adamStore.currentPerspective, - // The AD4M query adapter — the renderer routes each QueryIR through it (plan + lower). Keeps all - // AD4M-specific query lowering + capability quirks here, out of the agnostic renderer. Given the - // perspective's model manifest so it can resolve a `scope` drill-down to AD4M's `parent` predicate. - $queryAdapter: createAd4mQueryAdapter(() => adamStore.currentPerspectiveModels()), + // Everything the *renderer* needs to read data, from the AD4M adapter — model resolution, the + // dataset handle, the identity directory, and the query adapter. One artifact, so another + // backend has a single thing to implement and this provider stays about rendering. + // `adamStore` satisfies `Ad4mAdapterDeps` structurally, so it can be passed whole while the + // adapter still only sees — and can only reach for — the four accessors it declares. + ...createAd4mDataBindings(adamStore), }; // Resolves a dot-path string like 'adamStore.rootPerspective' against the stores object. diff --git a/packages/app-framework/src/frameworks/solid/types.ts b/packages/app-framework/src/frameworks/solid/types.ts index 54a89293..a73d2e0a 100644 --- a/packages/app-framework/src/frameworks/solid/types.ts +++ b/packages/app-framework/src/frameworks/solid/types.ts @@ -1,6 +1,6 @@ import type { Ad4mModel } from '@coasys/ad4m'; import type { AdamStore, AiStore, AppStore, RouteStore, SpaceStore, TemplateStore, ThemeStore } from '@solid/stores'; -import type { QueryAdapter } from '@we/schema-shared'; +import type { QueryAdapter, RendererStores } from '@we/schema-shared'; // eslint-disable-next-line @typescript-eslint/no-explicit-any type ModelClass = typeof Ad4mModel & (new (...args: any[]) => Ad4mModel); @@ -22,7 +22,24 @@ export type ModelStore = { delete: (modelName: string, id: string, options?: { perspective?: string }) => Promise; }; -export type Stores = { +/** + * This host's store bag. + * + * Extends {@link RendererStores} rather than restating the neutral bindings, so the renderer's + * contract is checked here, at the host's own declaration. Restating them let the two drift: this + * type had `$getModel` returning AD4M's `ModelClass` (whose `query` takes a `PerspectiveProxy`) + * where the contract asks for the neutral shape, and it omitted bindings the renderer genuinely + * reads. Inheriting means adding a binding to the contract surfaces here as a type error rather + * than at runtime. + * + * Only host-specific members are declared below; everything neutral comes from the contract, and + * the inherited index signature keeps `$store: 'someStore.field'` dot-paths open. + */ +export interface Stores extends RendererStores { + // Restated explicitly: an interface does not pick up an inherited index signature for + // assignability the way a type alias does, so without this `Stores` is not assignable to + // `RendererStores` despite extending it. + [key: string]: unknown; adamStore: AdamStore; aiStore: AiStore; appStore: AppStore; @@ -31,17 +48,7 @@ export type Stores = { templateStore: TemplateStore; routeStore: RouteStore; model?: ModelStore; - $getModel?: (name: string) => ModelClass; - $getModelForPerspective?: (name: string, perspectiveUuid?: string) => ModelClass | undefined; - $onError?: (message: string) => void; - /** Route template queries through the neutral QueryIR — reactive accessor; default from - * `seed.features.useQueryIR` (see `queryIRFlag`). A plain boolean is also accepted. */ - $useQueryIR?: boolean | (() => boolean); - /** Neutral identity — the current agent (templates read `$me.did`). Backed by `adamStore.me` here; - * typed `unknown` (like `$currentDataset`) so the seam stays backend-agnostic. */ + /** Neutral identity — the current agent (templates read `$me.did`). Backed by `adamStore.me`; + * typed `unknown` so the seam stays backend-agnostic. Host-specific: not part of the data contract. */ $me?: () => unknown; - /** Neutral dataset handle — the active perspective (templates use `$currentDataset`). */ - $currentDataset?: () => unknown; - /** Query-execution adapter — the renderer routes each QueryIR through it (plan + lower). */ - $queryAdapter?: QueryAdapter; -} & Record; +} diff --git a/packages/app-framework/src/shared/ad4mAdapter.ts b/packages/app-framework/src/shared/ad4mAdapter.ts index 45ec0d2d..403032e3 100644 --- a/packages/app-framework/src/shared/ad4mAdapter.ts +++ b/packages/app-framework/src/shared/ad4mAdapter.ts @@ -27,9 +27,12 @@ * The real fix is upstream in AD4M; when it lands, grep `sort:under-boolean` / `sort:needs-limit` * and delete these two blocks. */ +import type { PerspectiveProxy } from '@coasys/ad4m'; import type { AdapterCapabilities, CapabilityGap, + ModelClass as RendererModelClass, + RendererDataBindings, QueryAdapter, QueryIR, QueryOptions, @@ -39,6 +42,100 @@ import type { import { irToFlatQuery, planQuery, whereUsesCombinator } from '@we/schema-shared'; import type { ModelManifestEntry } from './AdamStore'; +import { getModel, getModelForPerspective, type ModelClass as Ad4mModelClass } from './registries/modelRegistry'; + +/** + * Adapt an AD4M model class to the renderer's neutral {@link RendererModelClass}. + * + * `Ad4mModel`'s statics are `query(perspective: PerspectiveProxy, query?: TypedQuery)` — the same + * two operations the renderer needs, under a backend-specific signature. This is the mapping every + * host owes the contract: the renderer depends on the *shape* (`query`/`findAll` over a dataset and + * options), so each backend maps its own onto it. + * + * It is a pure signature map with no data conversion, because a dataset handle is opaque to the + * renderer and round-trips untouched — the `PerspectiveProxy` handed out by `$currentDataset` is the + * very object arriving back here. (Had the contract insisted on a structural `{ id }` handle, this + * would instead have to flatten the proxy and re-resolve it on every query.) + */ +export function toRendererModel(Model: Ad4mModelClass): RendererModelClass { + return { + query: (dataset, opts) => + Model.query(dataset as PerspectiveProxy, opts as Parameters[1]) as ReturnType< + RendererModelClass['query'] + >, + findAll: (dataset, opts, ctl) => + // Called through a widened signature on purpose: `@coasys/ad4m` types `findAll` as + // `(perspective, query?)`, with no third argument — yet the renderer passes an abort-signal + // options object, and both the renderer and WE's own docs describe it as forwarded to the + // executor's cancel machinery. The published `.d.ts` and the documented runtime disagree. + // Cast rather than drop it: silently removing the signal would disable cancellation of + // in-flight queries. Worth confirming against the executor — if it is genuinely unsupported, + // the abort is already a no-op and the renderer's AbortController buys nothing. + (Model.findAll as unknown as (p: unknown, q: unknown, c?: unknown) => unknown)(dataset, opts, ctl) as ReturnType< + RendererModelClass['findAll'] + >, + }; +} + +/** + * What the AD4M adapter needs from the host to satisfy the data contract. + * + * Declared structurally rather than as `AdamStore` on purpose: that interface lives under + * `frameworks/solid/`, and this module is framework-agnostic. Naming the four accessors it actually + * uses also states the adapter's real dependency surface, and makes it stubbable without a store. + */ +export interface Ad4mAdapterDeps { + /** The perspective queries run against — handed to the renderer as an opaque dataset handle. */ + currentPerspective: () => PerspectiveProxy | null; + /** SHACL models of the current perspective, incl. synced foreign ones; used to resolve `scope`. */ + currentPerspectiveModels: () => ModelManifestEntry[]; + /** + * Reactive agent-profile cache. Must be *read inside* the accessor so `$agent`'s effect re-runs + * when a fetched profile lands. Typed by the only field this adapter needs — a `did` to match on — + * rather than the host's concrete profile type, which keeps this module free of app-layer imports. + */ + agents: () => Array<{ did?: string }>; + /** Ask AD4M to fetch a profile this client hasn't cached. */ + fetchAgent: (did: string) => Promise | void; +} + +/** + * The AD4M implementation of the renderer's data contract — `RendererDataBindings` minus the members + * that aren't backend-specific. + * + * This is the artifact another backend copies: everything a host must supply for the renderer to read + * data, in one place. `$onError` and `$useQueryIR` are deliberately excluded — surfacing an error to + * the UI and toggling the IR are host concerns any backend would wire the same way, so they stay with + * the app rather than pretending to be AD4M-specific. + * + * Note what is *not* here: no query lowering, no capability quirks, no model-shape mapping. Those are + * `createAd4mQueryAdapter` and `toRendererModel` above — this only composes them. + */ +export function createAd4mDataBindings( + deps: Ad4mAdapterDeps, +): Pick< + RendererDataBindings, + '$getModel' | '$getModelForPerspective' | '$currentDataset' | '$identities' | '$queryAdapter' +> { + return { + // Adapted, not raw: AD4M's model statics take a `PerspectiveProxy` and AD4M's own query shape, + // so `toRendererModel` maps them onto the neutral `query`/`findAll` the renderer depends on. + $getModel: (name) => toRendererModel(getModel(name)), + $getModelForPerspective: (name, dataset) => { + const model = getModelForPerspective(name, dataset); + return model ? toRendererModel(model) : undefined; + }, + // The renderer treats this as opaque and hands it straight back, so the proxy passes through + // untouched — no flattening to an id, no re-resolution on the way in. + $currentDataset: deps.currentPerspective, + // Identity directory behind the `$agent` block, bound to AD4M's agent cache. + $identities: { + get: (did) => deps.agents().find((a) => a.did === did) as Record | undefined, + fetch: (did) => void deps.fetchAgent(did), + }, + $queryAdapter: createAd4mQueryAdapter(deps.currentPerspectiveModels), + }; +} export const ad4mCapabilities: AdapterCapabilities = { operators: ['eq', 'ne', 'lt', 'lte', 'gt', 'gte', 'in', 'nin', 'contains', 'exists'], diff --git a/packages/app-framework/src/shared/registries/modelRegistry.ts b/packages/app-framework/src/shared/registries/modelRegistry.ts index cbdefa11..0e4a6e46 100644 --- a/packages/app-framework/src/shared/registries/modelRegistry.ts +++ b/packages/app-framework/src/shared/registries/modelRegistry.ts @@ -39,12 +39,19 @@ export function registerDynamicModels(perspectiveUuid: string, models: Record 300ms */ -function benchColor(storePath: string, shade: string): Record { +/** + * 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: storePath }, 50] }, + condition: { $lt: [{ $store: path }, Math.round(baseline * 1.2)] }, then: `success-${shade}`, else: { $if: { - condition: { $lt: [{ $store: storePath }, 150] }, + condition: { $lt: [{ $store: path }, Math.round(baseline * 1.5)] }, then: `warning-${shade}`, else: `danger-${shade}`, }, @@ -59,6 +138,130 @@ function benchColor(storePath: string, shade: string): Record { }; } +/** 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 { @@ -264,6 +467,8 @@ function generateCards(count: number, factory: (id: number) => SchemaNode): Sche /** 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', @@ -285,35 +490,89 @@ function benchRoute(path: string, title: string, children: SchemaNode[]) { { type: 'we-text', props: { text: title, fontSize: '600', fontWeight: '600', color: 'neutral-800' } }, ], }, - // Last render time display + // 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.benchLastRender' }, + condition: { $store: 'testStore.benchLastResult.median' }, then: { - type: 'Row', + type: 'Column', props: { - gap: '200', - ay: 'center', - p: '200', - bg: benchColor('testStore.benchLastRender', '100'), + gap: '100', + p: '300', + bg: benchLastColor(routeKey, '50'), r: '300', mb: '300', }, children: [ { - type: 'we-icon', - props: { name: 'clock', color: benchColor('testStore.benchLastRender', '600'), size: 'sm' }, - }, - { - type: 'we-text', - props: { color: benchColor('testStore.benchLastRender', '700') }, - children: ['Render time:'], + 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: 'we-text', - props: { color: benchColor('testStore.benchLastRender', '700'), fontWeight: '700' }, - children: [{ $concat: [{ $store: 'testStore.benchLastRender' }, 'ms'] }], + 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', + ], + }, + ], + }, + ], }, ], }, @@ -764,10 +1023,73 @@ const mixedRealisticRoute = benchRoute('/mixed-realistic', 'Mixed Realistic — }, ]); +// --------------------------------------------------------------------------- +// 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. // --------------------------------------------------------------------------- -const benchmarkRoutes = [ +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` }, { @@ -799,55 +1121,74 @@ const benchmarkRoutes = [ label: 'Mixed Realistic', nav: `${benchmarkBasePath}/mixed-realistic`, }, + { + path: '/update-perf', + key: 'update-perf', + label: 'Reactive Update (100)', + nav: `${benchmarkBasePath}/update-perf`, + measuresUpdate: true, + }, ]; -const dashboardRoute = { - path: '/', +/** + * 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: { width: '100%', height: '100%', gap: '400', bg: 'neutral-50', overflow: 'auto' }, + props: { gap: '300', mb: '400', bg: 'neutral-50' }, children: [ - // Header { - type: 'Column', - props: { gap: '300' }, + 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-text', - props: { fontSize: '700', fontWeight: '700', color: 'primary-800' }, - children: ['Renderer Benchmarks'], - }, - { - type: 'we-text', - props: { color: 'neutral-600' }, - children: ['Click a benchmark to run it. Times are measured from navigation to paint-complete.'], + type: 'we-button', + props: { + text: 'Run All', + variant: 'primary', + gradient: true, + loading: { $store: 'testStore.benchRunning' }, + disabled: { $store: 'testStore.benchRunning' }, + onClick: { $action: 'testStore.benchRunAll' }, + }, }, - // Action buttons { - type: 'Row', - props: { gap: '200', py: '200' }, - children: [ - { - type: 'we-button', - props: { - text: 'Run All', - variant: 'primary', - gradient: true, - onClick: { $action: 'testStore.benchRunAll' }, - }, - }, - { - type: 'we-button', - props: { - text: 'Clear All Results', - variant: 'secondary', - onClick: { $action: 'testStore.benchClearResults' }, - }, - }, - ], + type: 'we-button', + props: { + text: 'Clear All Results', + variant: 'secondary', + disabled: { $store: 'testStore.benchRunning' }, + onClick: { $action: 'testStore.benchClearResults' }, + }, }, - { type: 'we-divider' }, ], }, + { 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', @@ -865,7 +1206,10 @@ const dashboardRoute = { cursor: 'pointer', border: '1px solid neutral-200', hoverProps: { bg: 'primary-25', borderColor: 'primary-300' }, - onClick: { $action: 'routeStore.navigate', args: [route.nav] }, + // 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: [ { @@ -879,38 +1223,106 @@ const dashboardRoute = { text: 'Run', variant: 'primary', size: 'sm', - onClick: { $action: 'routeStore.navigate', args: [route.nav] }, + onClick: { $action: 'testStore.benchRun', args: [route.key] }, }, }, ], }, { type: 'we-text', props: { text: route.path, fontSize: '300', color: 'neutral-400' } }, - // Show last result for this route + // 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}` }, + condition: { $store: `testStore.benchResults.${route.key}.median` }, then: { - type: 'Row', - props: { gap: '200', ay: 'center', pt: '100' }, + 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: '300', - fontWeight: '600', - color: benchColor(`testStore.benchResults.${route.key}`, '500'), - }, - children: ['Last run:'], + 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: { - fontSize: '300', - fontWeight: '700', - color: benchColor(`testStore.benchResults.${route.key}`, '500'), + 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'], + }, + ], + }, }, - children: [{ $concat: [{ $store: `testStore.benchResults.${route.key}` }, 'ms'] }], }, ], }, @@ -925,6 +1337,115 @@ const dashboardRoute = { // --------------------------------------------------------------------------- // 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', @@ -934,8 +1455,19 @@ export const schemaBenchmarkTemplate: TemplateSchema = { components: ['BenchmarkTimer'], }, type: 'Column', - props: { width: '100%', height: '100%' }, - children: [{ type: '$routes' }], + 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, @@ -949,6 +1481,8 @@ export const schemaBenchmarkTemplate: TemplateSchema = { solidRoute, deepNestRoute, mixedRealisticRoute, + updatePerfRoute, + idleRoute, { path: '*', type: 'Column', 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 a94ab1a1..8088ad81 100644 --- a/packages/app-framework/src/shared/schemas/shell/tests/testStore.ts +++ b/packages/app-framework/src/shared/schemas/shell/tests/testStore.ts @@ -5,7 +5,7 @@ import { queryIRFlag } from '@shared/queryIRFlag'; import { registerModel } from '@shared/registries/modelRegistry'; import { type Accessor, createEffect, createSignal } from 'solid-js'; -import { benchmarkBasePath } from './SchemaBenchmark.schema'; +import { benchmarkBasePath, benchmarkRoutes } from './SchemaBenchmark.schema'; // --------------------------------------------------------------------------- // Test model — lightweight AD4M model for $query testing @@ -27,6 +27,99 @@ 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 // --------------------------------------------------------------------------- @@ -51,10 +144,33 @@ export function createTestStore(testPerspective: Accessor(null); - const [benchResults, setBenchResults] = createSignal>({}); - let benchQueue: string[] = []; - let benchRunning = false; + // + // Both entry points — a single route's "Run" button and "Run All" — drive the same queue-based + // runner below; an individual run is just a queue of length one. That matters more than it looks: + // the sampling rules (warm-up discard, median-of-N, the /idle bounce) only yield comparable + // numbers if both paths obey them identically, and two parallel implementations would drift. + const [benchLastResult, setBenchLastResult] = createSignal(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 = [ @@ -124,52 +240,258 @@ export function createTestStore(testPerspective: Accessor ({ ...prev, [routeName]: Math.round(duration * 10) / 10 })); + + /** Discarded from the median. The first render of a route pays one-time costs that no later + * render repeats — Lit template compilation and the per-class CSSStyleSheet, plus JIT warm-up. + * Discarding it is also what makes an individual run comparable to a Run All run: in Run All + * only the *first* route is ever truly cold, so without this the same route would score + * differently depending on which button was pressed. */ + const BENCH_WARMUP = 1; + /** Warm samples behind each median. Odd, so the median is a real sample rather than a mean. */ + const BENCH_SAMPLES = 5; + const BENCH_UPDATE_SAMPLES = 5; + /** Time parked on /idle between samples — lets the previous route tear down and gives the + * browser a little breathing room, so teardown cost never lands inside the next measurement. */ + const BENCH_IDLE_SETTLE_MS = 60; + const benchIdlePath = `${benchmarkBasePath}/idle`; + + /** Single source of truth for what Run All covers, shared with the dashboard that lists them — + * so a route can never be added to the UI and silently missed by the runner. */ + const benchTargets: BenchTarget[] = benchmarkRoutes.map((r) => ({ + 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; } - // Auto-advance only during a Run All session - if (!benchRunning) return; - if (benchQueue.length > 0) { - const next = benchQueue.shift()!; - setTimeout(() => navigate(next), 50); - } else { - // All done — return to dashboard - benchRunning = false; - setTimeout(() => navigate(benchmarkBasePath), 50); + 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(); } - function benchClearResults() { - setBenchResults({}); - setBenchLastRender(null); - benchQueue = []; - benchRunning = false; + /** + * 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(); + }), + ); } - const benchAllRoutes = [ - `${benchmarkBasePath}/static-small`, - `${benchmarkBasePath}/static-large`, - `${benchmarkBasePath}/static-extreme`, - `${benchmarkBasePath}/tokens-light`, - `${benchmarkBasePath}/tokens-heavy`, - `${benchmarkBasePath}/each-flat`, - `${benchmarkBasePath}/each-nested`, - `${benchmarkBasePath}/web-components`, - `${benchmarkBasePath}/solid-components`, - `${benchmarkBasePath}/deep-nesting`, - `${benchmarkBasePath}/mixed-realistic`, - ]; + 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 benchRunAll() { + function benchClearResults() { setBenchResults({}); - setBenchLastRender(null); - benchRunning = true; - benchQueue = benchAllRoutes.slice(1); - navigate(benchAllRoutes[0]); + 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; @@ -291,10 +613,15 @@ export function createTestStore(testPerspective: Accessor>(); + +/** + * Set or clear a single `--we-*` custom property. + * + * The early return on the clear path is the point. `updateCustomVars` walks a fixed list of ~59 + * properties on every update and calls this for each, whether or not the corresponding prop is set + * — so a bare `we-text` with no design-system props still issued ~59 `removeProperty` calls, almost + * all of them clearing properties that had never been written. + * + * Measured by ablation on a 3006-element tree: `updateAllCustomVars` accounted for 83% of the flush + * phase (~564ms of ~681ms). Removing a property that was never set cannot change rendered output — + * it is a no-op by definition — so skipping it is behaviour-preserving. + * + * Safe because every one of the 66 call sites passes a prefixed custom-property name and nothing + * outside this module writes `--we-*` inline. If that ever changes, a property written elsewhere + * would not be in this set and would no longer be cleared here. + */ function setProperty(el: HTMLElement, name: string, value?: string) { - if (value !== undefined && value !== null && value !== '') el.style.setProperty(name, value); - else el.style.removeProperty(name); + if (value !== undefined && value !== null && value !== '') { + el.style.setProperty(name, value); + let written = writtenVars.get(el); + if (!written) { + written = new Set(); + writtenVars.set(el, written); + } + written.add(name); + return; + } + + const written = writtenVars.get(el); + if (written === undefined || !written.has(name)) return; + el.style.removeProperty(name); + written.delete(name); } /** diff --git a/packages/design-system/4-components/src/components/feedback/Toast/toast.service.ts b/packages/design-system/4-components/src/components/feedback/Toast/toast.service.ts index 7108f1d3..3db69c2f 100644 --- a/packages/design-system/4-components/src/components/feedback/Toast/toast.service.ts +++ b/packages/design-system/4-components/src/components/feedback/Toast/toast.service.ts @@ -6,13 +6,10 @@ const [toasts, setToasts] = createSignal([]); let counter = 0; -/** Dismissal timers by toast id, so a repeat of a live toast can restart its countdown. */ +/** Dismissal timers by toast id, so a manual dismiss can cancel the pending auto-dismiss. */ const timers = new Map>(); function scheduleDismiss(id: string, duration: number) { - const existing = timers.get(id); - if (existing) clearTimeout(existing); - timers.delete(id); if (duration > 0) { timers.set( id, @@ -28,11 +25,14 @@ function addToast(message: string, variant: ToastVariant = 'info', duration = 40 // // Deliberately scoped to toasts that are *currently visible*: once dismissed, the same message // can appear again, so a genuine later recurrence is still shown rather than suppressed forever. + // + // The repeat deliberately does NOT restart the countdown. A condition that refires faster than + // the duration — a reactive effect re-running per node on every dataset change — would otherwise + // reset the timer indefinitely and pin the toast on screen, unclosable, until whatever is + // reporting it unmounts. Keeping the original deadline means one transient failure reads as + // transient however many times it is reported. const live = toasts().find((t) => t.message === message && t.variant === variant); - if (live) { - scheduleDismiss(live.id, duration); - return live.id; - } + if (live) return live.id; const id = `toast-${++counter}`; const toast: ToastItem = { id, message, variant, duration }; diff --git a/packages/design-system/utils/src/index.ts b/packages/design-system/utils/src/index.ts index ce3e8c2c..9ca51744 100644 --- a/packages/design-system/utils/src/index.ts +++ b/packages/design-system/utils/src/index.ts @@ -101,13 +101,37 @@ export const layerKeyMap: Record = { state: stateKeys, }; -/** Get the combined set of keys for the given layers (deduplicated). */ +/** Memoised results of {@link getKeysForLayers}, keyed by sorted layer set. */ +const keysForLayersCache = new Map(); + +/** + * Get the combined set of keys for the given layers (deduplicated). + * + * Memoised because this sits on a hot path. `DesignSystemMixin` calls it once per class, but ~20 + * primitives (button, text, input, badge, checkbox, …) override `getInstanceProps()` and call it + * again on every invocation — i.e. once per instance per update. Each uncached call allocated a Set + * and spread it into a fresh array of up to 82 keys, so a page rendering 3000 DS elements paid that + * 3000 times for a result that only ever has a handful of distinct values. + * + * Safe to cache: `layerKeyMap` is a module constant, so the result is a pure function of the layer + * set. The returned array is shared rather than copied — callers treat it as read-only + * (`filterProps` takes `readonly string[]` and only filters/maps), and it must stay that way. + * + * The key is sorted so ['layout','visual'] and ['visual','layout'] share an entry; the union itself + * is order-independent. + */ export function getKeysForLayers(layers: DSLayer[]): string[] { + const cacheKey = [...layers].sort().join('|'); + const cached = keysForLayersCache.get(cacheKey); + if (cached) return cached; + const keys = new Set(); for (const layer of layers) { for (const key of layerKeyMap[layer]) keys.add(key); } - return [...keys]; + const result = [...keys]; + keysForLayersCache.set(cacheKey, result); + return result; } // --- Backwards-compatible combined key array --- diff --git a/packages/schema-system/benchmarks/bench/environment.probe.ts b/packages/schema-system/benchmarks/bench/environment.probe.ts new file mode 100644 index 00000000..fae26ce3 --- /dev/null +++ b/packages/schema-system/benchmarks/bench/environment.probe.ts @@ -0,0 +1,56 @@ +/** + * 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 new file mode 100644 index 00000000..50136d68 --- /dev/null +++ b/packages/schema-system/benchmarks/bench/renderTree.bench.tsx @@ -0,0 +1,184 @@ +/** + * 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/bench/setProperty.probe.ts b/packages/schema-system/benchmarks/bench/setProperty.probe.ts new file mode 100644 index 00000000..f92dcae5 --- /dev/null +++ b/packages/schema-system/benchmarks/bench/setProperty.probe.ts @@ -0,0 +1,93 @@ +/** + * Behaviour verification for the write-tracking in `helpers.ts` `setProperty`. + * + * That change skips `removeProperty` for custom properties an element never wrote, which is what + * takes flush from ~681ms to ~192ms on a 3006-element tree. Removing a property that was never set + * is a no-op by definition, so the change should be invisible — these cases prove it, including the + * two names where a primitive writes a custom property directly AND helpers.ts generates the same + * 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. + */ +import '@we/primitives'; + +import { describe, expect, it } from 'vitest'; + +type LitEl = HTMLElement & { updateComplete?: Promise }; + +async function mount(tag: string, props: Record = {}): Promise { + const el = document.createElement(tag) as LitEl; + for (const [k, v] of Object.entries(props)) (el as unknown as Record)[k] = v; + document.body.appendChild(el); + if (el.updateComplete) await el.updateComplete; + return el; +} + +/** Inline custom properties currently set on the element. */ +function customProps(el: HTMLElement): string[] { + const style = el.getAttribute('style') ?? ''; + return style + .split(';') + .map((d) => d.split(':')[0]?.trim()) + .filter((n) => !!n && n.startsWith('--')); +} + +describe('setProperty write-tracking', () => { + it('still writes a custom property when a DS prop is set', async () => { + const el = await mount('we-text', { color: 'neutral-800' }); + const props = customProps(el); + expect(props.length).toBeGreaterThan(0); + expect(el.getAttribute('style')).toContain('color'); + el.remove(); + }); + + it('still clears a custom property that WAS previously written', async () => { + const el = (await mount('we-text', { color: 'neutral-800' })) as LitEl & { color?: string }; + const before = el.getAttribute('style') ?? ''; + expect(before).toContain('color'); + + // Clearing must still remove it — this is the path write-tracking has to keep working. + el.color = ''; + if (el.updateComplete) await el.updateComplete; + + const after = el.getAttribute('style') ?? ''; + expect(after.includes('--we-text-color')).toBe(false); + el.remove(); + }); + + it('leaves no custom properties on an element with no DS props set', async () => { + const el = await mount('we-text'); + expect(customProps(el)).toEqual([]); + el.remove(); + }); + + it('survives repeated set/clear/set cycles', async () => { + const el = (await mount('we-text', { color: 'neutral-800' })) as LitEl & { color?: string }; + for (let i = 0; i < 3; i++) { + el.color = ''; + if (el.updateComplete) await el.updateComplete; + expect((el.getAttribute('style') ?? '').includes('--we-text-color')).toBe(false); + + el.color = 'primary-500'; + if (el.updateComplete) await el.updateComplete; + expect(el.getAttribute('style') ?? '').toContain('--we-text-color'); + } + el.remove(); + }); + + // --- Collision cases: primitive writes the var directly, helpers.ts generates the same name --- + + it('does not clobber --we-markdown-gap written directly by we-markdown', async () => { + const el = await mount('we-markdown', { content: '# hi', markdownGap: '12px' }); + expect(el.getAttribute('style') ?? '').toContain('--we-markdown-gap'); + el.remove(); + }); + + it('does not clobber --we-spinner-color written directly by we-spinner', async () => { + const el = await mount('we-spinner', { color: 'primary-500' }); + expect(el.getAttribute('style') ?? '').toContain('--we-spinner-color'); + el.remove(); + }); +}); diff --git a/packages/schema-system/benchmarks/package.json b/packages/schema-system/benchmarks/package.json new file mode 100644 index 00000000..30b0a002 --- /dev/null +++ b/packages/schema-system/benchmarks/package.json @@ -0,0 +1,21 @@ +{ + "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/tsconfig.json b/packages/schema-system/benchmarks/tsconfig.json new file mode 100644 index 00000000..c48606d8 --- /dev/null +++ b/packages/schema-system/benchmarks/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "bundler", + "jsx": "preserve", + "jsxImportSource": "solid-js", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "isolatedModules": true, + "types": ["vitest/globals"], + "noEmit": true + }, + "include": ["bench", "vitest.config.ts"], + "exclude": ["node_modules"] +} diff --git a/packages/schema-system/benchmarks/vitest.config.ts b/packages/schema-system/benchmarks/vitest.config.ts new file mode 100644 index 00000000..c1d068d5 --- /dev/null +++ b/packages/schema-system/benchmarks/vitest.config.ts @@ -0,0 +1,19 @@ +import solidPlugin from 'vite-plugin-solid'; +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + plugins: [solidPlugin()], + resolve: { + // 'solid' first so @we/schema-solid resolves to its src/ rather than a possibly stale dist/ — + // the whole point is measuring the renderer as it currently is. 'browser' is required because + // vitest otherwise picks solid-js's server build, which throws "Client-only API called on the + // server side" as soon as the renderer imports AnimateRenderer. + conditions: ['solid', 'development', 'browser'], + }, + test: { + environment: 'happy-dom', + include: ['bench/**/*.{bench,probe}.{ts,tsx}'], + // Building thousands of nodes repeatedly is well past the 5s default. + testTimeout: 180_000, + }, +}); diff --git a/packages/schema-system/frameworks/solid/src/SchemaRenderer.tsx b/packages/schema-system/frameworks/solid/src/SchemaRenderer.tsx index 675f0b12..07efd48e 100644 --- a/packages/schema-system/frameworks/solid/src/SchemaRenderer.tsx +++ b/packages/schema-system/frameworks/solid/src/SchemaRenderer.tsx @@ -3,9 +3,11 @@ import type { LocalFieldMeta, LocalStateField, MapProp, + ModelClass, QueryAdapter, QueryDescriptor, QueryStateField, + RendererStores, ValidationRule, } from '@we/schema-shared'; import { @@ -76,8 +78,8 @@ function isEventProp(key: string): boolean { */ function hoistMapQuerySignals( value: unknown, - stores: unknown, - getModel: (name: string) => unknown, + stores: RendererStores, + getModel: (name: string) => ModelClass, context: Record = {}, ): unknown { if (!value || typeof value !== 'object') return value; @@ -184,9 +186,9 @@ const warnedDegradations = new Set(); * makes "does the user learn the query failed?" depend on global handlers rather than on this * layer. Reporting the same failure on each re-run is fine; the toast service collapses repeats. */ -function reportQueryError(stores: unknown, entity: string, err: unknown): void { +function reportQueryError(stores: RendererStores, entity: string, err: unknown): void { const message = err instanceof Error ? err.message : String(err); - const onError = (stores as Record).$onError as ((msg: string) => void) | undefined; + const onError = stores.$onError; const text = `Query on "${entity}" failed: ${message}`; if (onError) onError(text); else console.error('[query]', text, err); @@ -198,9 +200,9 @@ function isAbort(err: unknown): boolean { } /** Reporter passed to {@link routeQueryThroughIR}; its messages are already user-facing. */ -function irErrorReporter(stores: unknown): (msg: string) => void { +function irErrorReporter(stores: RendererStores): (msg: string) => void { return (msg) => { - const onError = (stores as Record).$onError as ((m: string) => void) | undefined; + const onError = stores.$onError; if (onError) onError(msg); else console.error('[query-ir]', msg); }; @@ -265,14 +267,13 @@ function routeQueryThroughIR( function createQuerySignal( descriptor: QueryDescriptor, - stores: unknown, - getModel: (name: string) => unknown, + stores: RendererStores, + getModel: (name: string) => ModelClass, context: Record = {}, ): () => unknown[] { const [items, setItems] = createStore([]); const readItems = () => items; - const getModelForPerspective = (stores as Record).$getModelForPerspective as - ((name: string, uuid?: string) => unknown) | undefined; + const getModelForPerspective = stores.$getModelForPerspective; createEffect(() => { let p: unknown = null; @@ -284,7 +285,7 @@ function createQuerySignal( } else { // The host injects the backend-neutral $currentDataset() (AD4M's currentPerspective, another // backend's equivalent) — no AD4M store reference in the renderer. - const currentDataset = (stores as Record).$currentDataset as (() => unknown) | undefined; + const currentDataset = stores.$currentDataset; p = typeof currentDataset === 'function' ? currentDataset() : null; } if (!p) { @@ -293,32 +294,24 @@ function createQuerySignal( } // Dataset-scoped model lookup: prefer a dataset-specific dynamic model, fall back to the global - // registry. Read `uuid` first (the AD4M PerspectiveProxy exposes it, and also an unrelated - // `id` = subscription id that must NOT win), falling back to the backend-neutral `id` that a - // clean DatasetHandle provides. - // TODO: collapse uuid??id to a single `id` once the AD4M adapter wraps perspectives in DatasetHandles. - const datasetId = ((p as Record).uuid ?? (p as Record).id) as string | undefined; - const dynamicCls = getModelForPerspective ? getModelForPerspective(descriptor.entity, datasetId) : undefined; - let ModelClass: Record unknown>; + // registry. + // The dataset stays opaque here: the host derives whatever key its per-dataset model registry + // needs, since only it knows the concrete handle type. + const dynamicCls = getModelForPerspective ? getModelForPerspective(descriptor.entity, p) : undefined; + let Model: ModelClass; try { - ModelClass = (dynamicCls ?? getModel(descriptor.entity)) as Record unknown>; + Model = dynamicCls ?? getModel(descriptor.entity); } catch { - const onError = (stores as Record).$onError as ((msg: string) => void) | undefined; + const onError = stores.$onError; onError?.(`Model "${descriptor.entity}" is not available in this perspective`); setItems(reconcile([])); return; } - const resolvedParams = deepResolveTokens(descriptor.params, stores as Record, context) as Record< - string, - unknown - >; + const resolvedParams = deepResolveTokens(descriptor.params, stores, context) as Record; const resolvedInclude = descriptor.include !== undefined - ? (deepResolveTokens(descriptor.include, stores as Record, context) as Record< - string, - boolean | Record - >) + ? (deepResolveTokens(descriptor.include, stores, context) as Record>) : undefined; let queryOptions: Record = { ...resolvedParams, @@ -327,9 +320,9 @@ function createQuerySignal( // Route through the QueryIR when enabled. `$useQueryIR` is a reactive accessor (default from // `seed.features.useQueryIR`, live-toggled on the Queries test page); reading it *here*, inside the // effect, makes the query re-run when it flips — so toggling re-routes without a reload. - const irFlag = (stores as Record).$useQueryIR; + const irFlag = stores.$useQueryIR; const useQueryIR = typeof irFlag === 'function' ? (irFlag as () => unknown)() === true : irFlag === true; - const queryAdapter = (stores as Record).$queryAdapter as QueryAdapter | undefined; + const queryAdapter = stores.$queryAdapter; if (useQueryIR && queryAdapter) { // Fail loud: an IR/adapter gap renders nothing and reports, rather than silently reverting to the // raw backend path (which only ever worked because AD4M is both the dialect and the backend). @@ -352,7 +345,7 @@ function createQuerySignal( }); if (descriptor.subscribe) { - const builder = ModelClass.query(p, queryOptions) as { + const builder = Model.query(p, queryOptions) as { subscribe: (cb: (results: unknown[]) => void) => Promise; dispose: () => void; }; @@ -381,7 +374,7 @@ function createQuerySignal( // to the executor's `request.cancel` machinery. const controller = new AbortController(); onCleanup(() => controller.abort()); - (ModelClass.findAll(p, queryOptions, { signal: controller.signal }) as Promise) + (Model.findAll(p, queryOptions, { signal: controller.signal }) as Promise) .then((results) => { if (controller.signal.aborted) return; setItems(reconcile(normalise(results), { key: 'id', merge: true })); @@ -427,7 +420,7 @@ export function RenderSchema({ node, stores, registry, context = {}, children }: const resolveInitial = (raw: unknown): unknown => { if (raw !== null && typeof raw === 'object' && !Array.isArray(raw)) { if (Object.keys(raw).some((k) => k.startsWith('$'))) { - const resolved = resolveProp(raw, stores as Record, context); + const resolved = resolveProp(raw, stores, context); if (typeof resolved === 'function' && REACTIVE_ACCESSOR in (resolved as object)) { return (resolved as () => unknown)(); } @@ -482,7 +475,7 @@ export function RenderSchema({ node, stores, registry, context = {}, children }: !Array.isArray(rawInitial) && Object.keys(rawInitial as object).some((k) => k.startsWith('$')) ) { - const reactiveVal = resolveProp(rawInitial, stores as Record, context, createMemo); + const reactiveVal = resolveProp(rawInitial, stores, context, createMemo); createEffect(() => { const next = typeof reactiveVal === 'function' && REACTIVE_ACCESSOR in (reactiveVal as object) @@ -507,7 +500,7 @@ export function RenderSchema({ node, stores, registry, context = {}, children }: // Each entry runs createQuerySignal at node mount and injects the result array into // $local under the given name — read-only, shared across the entire subtree. if (node.$queries) { - const getModel = (stores as Record).$getModel as ((name: string) => unknown) | undefined; + const getModel = stores.$getModel; if (getModel) { const queryAccessors: Record unknown[]> = {}; for (const [name, field] of Object.entries(node.$queries as Record)) { @@ -651,7 +644,7 @@ export function RenderSchema({ node, stores, registry, context = {}, children }: const rawItems = node.props?.items; if (hasToken(rawItems, '$query', 'object')) { const descriptor = resolveQueryProp(rawItems); - const getModel = (stores as Record).$getModel as ((name: string) => unknown) | undefined; + const getModel = stores.$getModel; if (!getModel) { console.warn('Schema $query: $getModel not found in stores. Did you wire the model registry?'); itemsArray = () => []; @@ -689,9 +682,8 @@ export function RenderSchema({ node, stores, registry, context = {}, children }: const rawItems = node.props?.item; if (hasToken(rawItems, '$query', 'object')) { const descriptor = resolveQueryProp(rawItems); - const getModelFn = (stores as Record).$getModel as ((name: string) => unknown) | undefined; - const getModelForPerspective = (stores as Record).$getModelForPerspective as - ((name: string, uuid?: string) => unknown) | undefined; + const getModelFn = stores.$getModel; + const getModelForPerspective = stores.$getModelForPerspective; if (!getModelFn) { console.warn('Schema $single: $getModel not found in stores. Did you wire the model registry?'); @@ -704,7 +696,7 @@ export function RenderSchema({ node, stores, registry, context = {}, children }: for (const part of parts) target = (target as Record)?.[part]; p = typeof target === 'function' ? (target as () => unknown)() : target; } else { - const currentDataset = (stores as Record).$currentDataset as (() => unknown) | undefined; + const currentDataset = stores.$currentDataset; p = typeof currentDataset === 'function' ? currentDataset() : null; } if (!p) { @@ -712,31 +704,24 @@ export function RenderSchema({ node, stores, registry, context = {}, children }: return; } - const perspectiveUuid = (p as Record).uuid as string | undefined; - const dynamicCls = getModelForPerspective - ? getModelForPerspective(descriptor.entity, perspectiveUuid) - : undefined; - let ModelClass: Record unknown>; + const dynamicCls = getModelForPerspective ? getModelForPerspective(descriptor.entity, p) : undefined; + let Model: ModelClass; try { - ModelClass = (dynamicCls ?? getModelFn(descriptor.entity)) as Record< - string, - (...args: unknown[]) => unknown - >; + Model = dynamicCls ?? getModelFn(descriptor.entity); } catch { - const onError = (stores as Record).$onError as ((msg: string) => void) | undefined; + const onError = stores.$onError; onError?.(`Model "${descriptor.entity}" is not available in this perspective`); setHasItem(false); return; } - const resolvedParams = deepResolveTokens( - descriptor.params, - stores as Record, - effectiveContext, - ) as Record; + const resolvedParams = deepResolveTokens(descriptor.params, stores, effectiveContext) as Record< + string, + unknown + >; const resolvedInclude = descriptor.include !== undefined - ? (deepResolveTokens(descriptor.include, stores as Record, effectiveContext) as Record< + ? (deepResolveTokens(descriptor.include, stores, effectiveContext) as Record< string, boolean | Record >) @@ -749,9 +734,9 @@ export function RenderSchema({ node, stores, registry, context = {}, children }: // backend would skip capability planning entirely — no fail-loud on a genuine gap, no // `degraded` warning, and on a non-AD4M backend it would pass a dialect the adapter // never agreed to read. - const irFlag = (stores as Record).$useQueryIR; + const irFlag = stores.$useQueryIR; const useQueryIR = typeof irFlag === 'function' ? (irFlag as () => unknown)() === true : irFlag === true; - const queryAdapter = (stores as Record).$queryAdapter as QueryAdapter | undefined; + const queryAdapter = stores.$queryAdapter; if (useQueryIR && queryAdapter) { const lowered = routeQueryThroughIR(descriptor.entity, queryOptions, queryAdapter, irErrorReporter(stores)); if (lowered === null) { @@ -776,7 +761,7 @@ export function RenderSchema({ node, stores, registry, context = {}, children }: }; if (descriptor.subscribe) { - const builder = ModelClass.query(p, queryOptions) as { + const builder = Model.query(p, queryOptions) as { subscribe: (cb: (results: unknown[]) => void) => Promise; dispose: () => void; }; @@ -795,7 +780,7 @@ export function RenderSchema({ node, stores, registry, context = {}, children }: // we don't pay its serialise + transit + deserialise tax. const controller = new AbortController(); onCleanup(() => controller.abort()); - (ModelClass.findAll(p, queryOptions, { signal: controller.signal }) as Promise) + (Model.findAll(p, queryOptions, { signal: controller.signal }) as Promise) .then((results) => { if (controller.signal.aborted) return; handleResults(results); @@ -828,20 +813,19 @@ export function RenderSchema({ node, stores, registry, context = {}, children }: createEffect(() => { const rawDid = node.props?.did; // resolveProp handles both '$post.author' strings and token objects like { $local: 'selectedPin.id' } - const did = rawDid ? String(resolveProp(rawDid, stores as Record, effectiveContext) ?? '') : ''; + const did = rawDid ? String(resolveProp(rawDid, stores, effectiveContext) ?? '') : ''; if (!did) return; - const adamStore = (stores as Record).adamStore as - { agents: () => Array>; fetchAgent: (did: string) => Promise } | undefined; - if (!adamStore) return; + const identities = stores.$identities; + if (!identities) return; - // Track agents() so this effect re-runs when a fetch completes - const cached = adamStore.agents().find((a) => a.did === did); + // `get` reads reactively, so this effect re-runs once a `fetch` lands and the profile appears. + const cached = identities.get(did); if (cached) { setAgentStore(reconcile(cached, { merge: true })); setHasAgent(true); } else { - adamStore.fetchAgent(did); + identities.fetch(did); } }); @@ -924,7 +908,7 @@ export function RenderSchema({ node, stores, registry, context = {}, children }: // $query: set up reactive subscription via createSignal + createEffect // instead of createMemo — subscriptions are side effects, not derivations. const descriptor = resolveQueryProp(rawValue); - const getModel = (stores as Record).$getModel as ((name: string) => unknown) | undefined; + const getModel = stores.$getModel; if (!getModel) { console.warn('Schema $query: $getModel not found in stores. Did you wire the model registry?'); propMemos[key] = () => []; @@ -939,7 +923,7 @@ export function RenderSchema({ node, stores, registry, context = {}, children }: // then pass the live signal into resolveMapProp so it re-maps on every update. const mapSpec = (rawValue as { $map: MapProp }).$map; const descriptor = resolveQueryProp(mapSpec.items); - const getModel = (stores as Record).$getModel as ((name: string) => unknown) | undefined; + const getModel = stores.$getModel; if (!getModel) { console.warn('Schema $query: $getModel not found in stores. Did you wire the model registry?'); propMemos[key] = () => []; @@ -965,7 +949,7 @@ export function RenderSchema({ node, stores, registry, context = {}, children }: } }; } else { - const getModel = (stores as Record).$getModel as ((name: string) => unknown) | undefined; + const getModel = stores.$getModel; const raw = getModel ? hoistMapQuerySignals(rawValue, stores, getModel, effectiveContext) : rawValue; propMemos[key] = createMemo(() => { const resolved = resolveProp(raw, stores, effectiveContext, createMemo); diff --git a/packages/schema-system/frameworks/solid/tests/distReactivity.test.tsx b/packages/schema-system/frameworks/solid/tests/distReactivity.test.tsx new file mode 100644 index 00000000..b8ab982a --- /dev/null +++ b/packages/schema-system/frameworks/solid/tests/distReactivity.test.tsx @@ -0,0 +1,125 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +/** + * Guards the **published artifact**, not the source: does `dist/` keep reactivity? + * + * Every other test in this package imports from `src/`, compiled by vite-plugin-solid. A consumer + * without a Solid toolchain instead gets `dist/`, pre-compiled by esbuild-plugin-solid via tsup — + * a different compiler, and a path nothing else covers. The failure mode is silent and severe: JSX + * still renders, so the initial paint looks correct, and only *updates* stop arriving. + * + * The cases below mirror portableSlice.test.tsx exactly and run against both entry points, so a + * dist-vs-src difference is the only variable. The live-mutation case is the load-bearing one — it + * exercises the scheduled `$query` effect, which is what breaks first when two Solid compilers or + * two runtime instances meet. + * + * Requires a prior `pnpm build` — `dist/` is gitignored, so the suite skips itself when absent + * rather than failing a fresh clone for the wrong reason. + */ +import { render } from '@solidjs/testing-library'; +import type { SchemaNode } from '@we/schema-shared'; +import { describe, expect, it } from 'vitest'; + +import { RenderSchema as RenderSchemaSrc } from '../src/SchemaRenderer'; +import { createInMemoryBackend } from './inMemoryDataSource'; + +// The specifier is built at runtime and marked @vite-ignore so vite cannot resolve it statically — +// a literal path makes an unbuilt `dist/` a transform-time failure, which would defeat the skip +// below and fail a fresh clone for the wrong reason. +const distSpecifier = ['..', 'dist', 'index.js'].join('/'); +const RenderSchemaDist: any = await import(/* @vite-ignore */ distSpecifier).then( + (m) => m.RenderSchema, + () => null, +); +const distBuilt = RenderSchemaDist != null; + +const tick = () => new Promise((r) => setTimeout(r, 0)); + +const Stack = (p: any) =>
{p.children}
; +const Field = (p: any) => {p.children}; +const registry: any = { Stack, Field }; + +function seedBackend() { + return createInMemoryBackend({ + uuid: 'in-memory-dataset', + tables: { + Agent: [ + { id: 'a1', name: 'Ada' }, + { id: 'a2', name: 'Bo' }, + ], + Post: [ + { id: 'p1', title: 'Graph theory', content: 'nodes and edges', authorId: 'a1', createdAt: 3 }, + { id: 'p2', title: 'Cooking', content: 'about graphs too', authorId: 'a2', createdAt: 2 }, + { id: 'p3', title: 'Weather', content: 'sunny today', authorId: 'a1', createdAt: 1 }, + ], + }, + relations: { + Post: { author: { type: 'hasOne', target: 'Agent', foreignKey: 'authorId' } }, + }, + }); +} + +const feedTemplate: SchemaNode = { + type: 'Stack', + props: { testid: 'feed' }, + children: [ + { + type: '$each', + props: { + items: { + $query: { + entity: 'Post', + where: { OR: [{ title: { contains: 'graph' } }, { content: { contains: 'graph' } }] }, + order: { createdAt: 'desc' }, + include: { author: true }, + }, + }, + as: 'post', + }, + children: [ + { + type: 'Stack', + props: { testid: 'post' }, + children: [ + { type: 'Field', children: ['$post.title'] }, + { type: 'Field', children: ['by ', '$post.author.name'] }, + ], + }, + ], + }, + ], +}; + +function suite(Renderer: () => any) { + it('resolves the async query and renders the feed', async () => { + const backend = seedBackend(); + const { container } = render(() => Renderer()({ node: feedTemplate, stores: backend.stores, registry })); + await tick(); + + const posts = container.querySelectorAll('[data-testid="post"]'); + expect(posts.length).toBe(2); + expect(posts[0].textContent).toContain('Graph theory'); + expect(posts[0].textContent).toContain('by Ada'); + expect(container.textContent).not.toContain('Weather'); + }); + + it('reacts live when the backend mutates', async () => { + const backend = seedBackend(); + const { container } = render(() => Renderer()({ node: feedTemplate, stores: backend.stores, registry })); + await tick(); + expect(container.querySelectorAll('[data-testid="post"]').length).toBe(2); + + backend.mutate((tables: any) => { + tables.Post.push({ id: 'p4', title: 'Graph databases', content: 'triples', authorId: 'a2', createdAt: 4 }); + }); + await tick(); + + const posts = container.querySelectorAll('[data-testid="post"]'); + expect(posts.length).toBe(3); + expect(posts[0].textContent).toContain('Graph databases'); + }); +} + +// The src baseline: if this fails, the problem is the renderer, not the packaging. +describe('src entry', () => suite(() => RenderSchemaSrc)); + +describe.skipIf(!distBuilt)('dist entry (published artifact)', () => suite(() => RenderSchemaDist)); diff --git a/packages/schema-system/frameworks/solid/tests/queryToken.test.tsx b/packages/schema-system/frameworks/solid/tests/queryToken.test.tsx index 9eb73ba9..c605c565 100644 --- a/packages/schema-system/frameworks/solid/tests/queryToken.test.tsx +++ b/packages/schema-system/frameworks/solid/tests/queryToken.test.tsx @@ -1,6 +1,6 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import { render } from '@solidjs/testing-library'; -import type { SchemaNode } from '@we/schema-shared'; +import type { RendererStores, SchemaNode } from '@we/schema-shared'; import { createRoot, createSignal } from 'solid-js'; import { render as webRender } from 'solid-js/web'; import { describe, expect, it, vi } from 'vitest'; @@ -8,6 +8,12 @@ import { describe, expect, it, vi } from 'vitest'; import { RenderSchema } from '../src/SchemaRenderer'; import type { ComponentRegistry } from '../src/types'; +/** + * Mocks are deliberately loose (vitest stubs don't structurally match `ModelClass`). The + * `RendererStores` contract exists to type-check real hosts at the boundary, not test doubles. + */ +const asStores = (s: object): RendererStores => s as unknown as RendererStores; + // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- @@ -69,7 +75,7 @@ describe('$query token', () => { props: { data: { $query: { entity: 'Post', subscribe: true } } }, }; - const { container } = render(() => ); + const { container } = render(() => ); await tick(); @@ -95,7 +101,7 @@ describe('$query token', () => { props: { data: { $query: { entity: 'Post', subscribe: true } } }, }; - const { container } = render(() => ); + const { container } = render(() => ); await tick(); // Push data from "backend" @@ -126,7 +132,10 @@ describe('$query token', () => { }; const container = document.createElement('div'); - const dispose = webRender(() => , container); + const dispose = webRender( + () => , + container, + ); await tick(); expect(builder.subscribe).toHaveBeenCalledOnce(); @@ -163,7 +172,7 @@ describe('$query token', () => { createRoot((d) => { dispose = d; const container = document.createElement('div'); - webRender(() => , container); + webRender(() => , container); }); await tick(); @@ -191,7 +200,7 @@ describe('$query token', () => { props: { data: { $query: { entity: 'Post', subscribe: true } } }, }; - const { container } = render(() => ); + const { container } = render(() => ); await tick(); // Should NOT have called query — no perspective @@ -218,7 +227,7 @@ describe('$query token', () => { props: { data: { $query: { entity: 'Post', subscribe: false } } }, }; - const { container } = render(() => ); + const { container } = render(() => ); await tick(); expect(MockModel.findAll).toHaveBeenCalledOnce(); @@ -249,7 +258,7 @@ describe('$query token', () => { props: { data: { $query: { entity: 'Post', subscribe: false } } }, }; - const { unmount } = render(() => ); + const { unmount } = render(() => ); await tick(); expect(MockModel.findAll).toHaveBeenCalledOnce(); @@ -283,7 +292,7 @@ describe('$query token', () => { props: { data: { $query: { entity: 'Post', subscribe: false } } }, }; - render(() => ); + render(() => ); await tick(); expect(signals.length).toBe(1); expect(signals[0].aborted).toBe(false); @@ -315,7 +324,7 @@ describe('$query token', () => { // If the catch arm doesn't swallow AbortError, the unhandled rejection // would surface as a test failure. - render(() => ); + render(() => ); await tick(); expect(MockModel.findAll).toHaveBeenCalledOnce(); }); @@ -345,7 +354,7 @@ describe('$query token', () => { }, }; - render(() => ); + render(() => ); await tick(); expect(MockModel.query).toHaveBeenCalledWith( @@ -368,7 +377,7 @@ describe('$query token', () => { props: { data: { $query: { entity: 'Post', subscribe: true } } }, }; - const { container } = render(() => ); + const { container } = render(() => ); await tick(); expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('$getModel not found')); diff --git a/packages/schema-system/shared/src/dataSource.ts b/packages/schema-system/shared/src/dataSource.ts index 763d3137..0bf5db4a 100644 --- a/packages/schema-system/shared/src/dataSource.ts +++ b/packages/schema-system/shared/src/dataSource.ts @@ -14,20 +14,24 @@ import type { AdapterCapabilities, QueryPlan } from './queryCapabilities'; import type { QueryIR } from './queryIR'; /** - * An opaque handle to the bounded dataset a query runs against — the backend-neutral replacement - * for AD4M's `PerspectiveProxy`. + * A handle to the bounded dataset a query runs against — AD4M's `PerspectiveProxy`, NextGraph's + * document/branch, a REST host's collection id. * - * Two ids on purpose (a lesson from AD4M, whose per-agent perspective `uuid`s forced shared data to - * be addressed by `neighbourhood://` URI): - * - `id` — stable LOCAL identity for this client session; what the renderer keys on (dataset-scoped - * model registry, subscription cache, reconciliation). Always present. - * - `uri` — stable GLOBAL identity, present only when the dataset is shared/addressable; for - * references that leave the client (sharing, links, persistence). + * **Genuinely opaque: the renderer never looks inside one.** It obtains a handle from + * `$currentDataset` (or a `dataset:` path), checks it is present, and hands it back to the host via + * `ModelClass.query` / `findAll` and `$getModelForPerspective`. Only the host that minted a handle + * ever interprets it. + * + * Typed `unknown` rather than a structural `{ id, uri }` on purpose. A structural shape would force + * every backend to *destroy* its native handle and then reconstruct it on the way back — AD4M would + * flatten a `PerspectiveProxy` to an id and re-resolve it through a lookup on every query — all to + * satisfy fields nothing reads. The contract should state what the renderer actually requires, and + * of a dataset it requires only that it round-trips. + * + * Anything a host needs *from* a handle (dataset-scoped model registries, subscription caches) it + * derives itself, in the host, where the concrete type is known. */ -export interface DatasetHandle { - id: string; - uri?: string; -} +export type DatasetHandle = unknown; /** * Query options passed through to a model handle. Currently the AD4M-flavored shape (opaque @@ -104,16 +108,51 @@ export interface QueryAdapter { * reference). All optional so a presentation-only (L0) host can omit the data ones entirely. */ export interface RendererDataBindings { - /** Current dataset handle. Preferred over the legacy `adamStore.currentPerspective`. */ + /** The dataset queries run against unless a `dataset:` path overrides it. Opaque to the renderer. */ $currentDataset?: () => DatasetHandle | null; /** Resolve a model name to its queryable handle. */ $getModel?: (name: string) => ModelClass; - /** Dataset-scoped model resolution (for backends with per-dataset dynamic model classes). */ - $getModelForPerspective?: (name: string, datasetId?: string) => ModelClass | undefined; + /** + * Dataset-scoped model resolution, for backends whose model classes are per-dataset (AD4M + * synthesises them from a perspective's SHACL). Receives the dataset **handle**, not an id + * extracted from it — deriving a key is the host's job, since only the host knows the concrete + * type. This is what lets the renderer treat a handle as fully opaque. + */ + $getModelForPerspective?: (name: string, dataset?: DatasetHandle) => ModelClass | undefined; /** Surface a data-layer error to the host UI. */ $onError?: (message: string) => void; /** Mutation surface for `model.create` / `update` / `delete` actions. */ model?: MutationApi; /** Query-execution adapter — routes a neutral `QueryIR` to this backend (plan + lower). */ $queryAdapter?: QueryAdapter; + /** + * Route queries through the neutral `QueryIR` rather than handing the host's own dialect straight + * to its backend. A boolean, or an accessor so a host can toggle it reactively. + */ + $useQueryIR?: boolean | (() => boolean); + /** + * Identity directory backing the `$agent` block: look up a profile by id, and ask the host to + * fetch one it hasn't cached. Every backend has some version of this (AD4M agents/DIDs, another + * host's users), so the renderer names the capability and the host binds whatever it has. + * + * `get` must read reactively — the `$agent` effect re-runs on its dependencies, so a profile that + * arrives after `fetch` shows up without further prompting. + */ + $identities?: { + get: (id: string) => Record | undefined; + fetch: (id: string) => void; + }; +} + +/** + * The `stores` bag as the renderer sees it: the declared data bindings above, plus whatever + * namespaces a host's templates reach by dot-path (`$store: 'someStore.field'`). The index + * signature is what keeps it open — the contract is a floor, not a closed set. + * + * Declared as an extending interface rather than `RendererDataBindings & Record`: + * intersecting with an index signature widens the declared members, losing exactly the typing this + * exists to provide. + */ +export interface RendererStores extends RendererDataBindings { + [key: string]: unknown; } diff --git a/packages/schema-system/shared/src/index.ts b/packages/schema-system/shared/src/index.ts index 9a55f095..9af14527 100644 --- a/packages/schema-system/shared/src/index.ts +++ b/packages/schema-system/shared/src/index.ts @@ -98,6 +98,7 @@ export type { DataSource, QueryAdapter, RendererDataBindings, + RendererStores, } from './dataSource'; export { modelManifestSchema, validateManifest, getEntity, getProperty, getRelation } from './manifest'; export type { diff --git a/packages/schema-system/shared/src/types.ts b/packages/schema-system/shared/src/types.ts index 83caab16..6d73b262 100644 --- a/packages/schema-system/shared/src/types.ts +++ b/packages/schema-system/shared/src/types.ts @@ -1,3 +1,5 @@ +import type { RendererStores } from './dataSource'; + // Pure framework-agnostic schema types export type SchemaProp = string | number | boolean | Record | SchemaProp[] | undefined; export type StoreDeclaration = Record; @@ -123,7 +125,14 @@ export type ComponentRegistry = { export type RenderProps = { node: SchemaNode | null; - stores: Record; + /** + * The injected stores bag. Typed as the declared contract rather than `Record` + * so the bindings the renderer depends on are checked at the boundary: with a bare index + * signature every read came back `unknown`, a truthiness guard narrowed that to `{}`, and each + * call site had to re-assert the shape by hand — which meant the contract was documentation + * nothing enforced, and could drift from what the renderer actually read. + */ + stores: RendererStores; registry: ComponentRegistry; context?: Record; children?: NodeType; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6059d422..e7a76fb5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -822,6 +822,33 @@ 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':