From 4dcc5725398c74f0d7adfff82f20d9cc4039b077 Mon Sep 17 00:00:00 2001 From: jhweir Date: Mon, 20 Jul 2026 10:46:50 +0100 Subject: [PATCH 1/8] =?UTF-8?q?refactor(schema):=20enforce=20the=20rendere?= =?UTF-8?q?r=E2=86=94backend=20contract,=20and=20implement=20it=20in=20the?= =?UTF-8?q?=20AD4M=20host?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `DataSource` / `ModelClass` / `DatasetHandle` / `RendererDataBindings` were declared but nothing implemented or checked them. `stores` entered the renderer as `unknown`, so every binding was read through a hand-written cast (26 of them), and the AD4M host pushed its native types straight through the hole. Being unenforced, the contract had already drifted: `$useQueryIR` was read by the renderer but never declared, and `Stores` described `$getModel` as returning AD4M's `ModelClass` — whose `query` takes a `PerspectiveProxy`, not the neutral shape. **The dataset handle is now genuinely opaque.** `DatasetHandle` was `{ id, uri }`, which sounds principled but nothing reads those fields: the renderer obtains a handle, checks it is present, and hands it back. Its only inspection was `uuid ?? id`, purely to key `getModelForPerspective`. A structural handle would therefore force every backend to destroy its native handle and reconstruct it — AD4M flattening a `PerspectiveProxy` to an id and re-resolving it by lookup on every query — to satisfy fields nobody uses. `$getModelForPerspective` now takes the handle itself and the host derives its own key, so both peeks are gone along with the `uuid ?? id` TODO. **The model shape is adapted, because that the renderer does depend on.** `toRendererModel` maps AD4M's statics onto neutral `query`/`findAll`. It is a pure signature map with no data conversion — precisely because the handle round-trips untouched. The two decisions pay for each other: adapt what the renderer must understand, keep opaque what it doesn't. Also: - `stores` is typed `RendererStores` throughout; all 26 casts deleted. `Stores` now *extends* it, so drift surfaces at the host's own declaration rather than at runtime. - `$agent` resolves through a neutral `$identities` binding instead of reaching for `adamStore`, removing the last backend reference from the agnostic renderer. - The AD4M adapter is one artifact: `ad4mCapabilities`, `createAd4mQueryAdapter`, `toRendererModel` and `createAd4mDataBindings` together, with `TemplateProvider` composing rather than implementing. Its deps are declared structurally (four accessors), so `shared/` stays framework-agnostic and the adapter is stubbable — `adamStore` satisfies them and can still be passed whole. - `$onError` / `$useQueryIR` deliberately stay with the app: any backend wires those the same way. Landed as one commit because the pieces are atomically coupled — changing the contract without updating the host does not build, and git cannot split a single file's changes non-interactively. Known follow-up surfaced by the typing: `@coasys/ad4m` types `findAll` as `(perspective, query?)` with no third argument, yet the renderer passes an abort-signal options object that both its own comment and CLAUDE.md describe as forwarded to the executor's cancel machinery. The blanket cast hid the disagreement. Kept the argument behind a documented widened call — dropping it would silently disable cancellation — but if the runtime never supported it, the AbortController buys nothing. Co-Authored-By: Claude Opus 4.8 --- .../solid/providers/TemplateProvider.tsx | 24 ++-- .../src/frameworks/solid/types.ts | 37 +++--- .../app-framework/src/shared/ad4mAdapter.ts | 97 ++++++++++++++ .../src/shared/registries/modelRegistry.ts | 9 +- .../frameworks/solid/src/SchemaRenderer.tsx | 124 ++++++++---------- .../solid/tests/queryToken.test.tsx | 33 +++-- .../schema-system/shared/src/dataSource.ts | 69 +++++++--- packages/schema-system/shared/src/index.ts | 1 + packages/schema-system/shared/src/types.ts | 11 +- 9 files changed, 279 insertions(+), 126 deletions(-) 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 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/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; From 0750f56163cb935504da0631fc3e436666cf1e39 Mon Sep 17 00:00:00 2001 From: jhweir Date: Mon, 20 Jul 2026 11:38:52 +0100 Subject: [PATCH 2/8] test(schema-solid): cover the published dist, and retire a disproven TODO MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The playground's vite.config carried a TODO, since the very first harness commit, claiming esbuild-plugin-solid's dist "breaks scheduled effects downstream" — leaving WE's published artifact documented as suspect for reactivity. That matters for adoption: it is what a consumer without a Solid toolchain gets. It does not reproduce. Checked and ruled out a second Solid runtime in the bundle (all three solid entry points are externalized), malformed JSX output (the dist emits the normal template/insert/effect calls), and a dual-package hazard on REACTIVE_ACCESSOR — a module-local Symbol(), so two copies of schema-shared would silently make every reactive prop read as static, except schema-shared exposes no source condition, so only one instance exists. Confirmed in the browser by aliasing the playground onto dist/index.js; the dist-mode and source-mode bundles hash differently, so the alias was verifiably in effect. Initial paint and live reactivity both worked. The likeliest history is that the real cause was a duplicate solid-js instance, fixed by the dedupe line in that same config, and that the comment blaming the compiler was a misdiagnosis nobody revisited. distReactivity.test.tsx runs the portable-slice feed cases against both the src and dist entry points, so the published artifact can't silently regress. The live-mutation case is the load-bearing one — it exercises the scheduled $query effect, which is what breaks first when two compilers or two runtimes meet. It skips itself when dist/ is absent (gitignored), so a fresh clone isn't failed for the wrong reason. The vite.config comment now records the durable why: dedupe is load-bearing, because two owner graphs stop scheduled effects while the initial paint still looks correct. Co-Authored-By: Claude Opus 4.8 --- .../solid/portable-ui-slice/vite.config.ts | 19 ++- .../solid/tests/distReactivity.test.tsx | 125 ++++++++++++++++++ 2 files changed, 137 insertions(+), 7 deletions(-) create mode 100644 packages/schema-system/frameworks/solid/tests/distReactivity.test.tsx 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/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)); From 2b2e164718132486e5edde160bd59d9680a67e38 Mon Sep 17 00:00:00 2001 From: jhweir Date: Mon, 20 Jul 2026 13:13:27 +0100 Subject: [PATCH 3/8] fix(toast): don't restart a live toast's countdown on repeat MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The dedupe added in a1051643 called scheduleDismiss on every repeat of a still-visible toast, which cleared and restarted its 4s timer. A condition that reports faster than the duration — a reactive query effect re-running per node on every dataset change — therefore reset the countdown indefinitely and pinned the toast on screen until whatever was reporting it unmounted. A repeat still collapses into the live toast, but keeps its original deadline, so one transient failure reads as transient however many times it is reported. scheduleDismiss is now called once per toast; the timers map remains so a manual dismiss can cancel the pending auto-dismiss. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../components/feedback/Toast/toast.service.ts | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) 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 }; From b26f7891c767ca3c54ab9322a052695ca981d6c1 Mon Sep 17 00:00:00 2001 From: jhweir Date: Mon, 20 Jul 2026 14:22:57 +0100 Subject: [PATCH 4/8] perf(primitives): skip removeProperty for custom properties never written updateCustomVars walks a fixed list of ~59 custom properties on every update and calls setProperty for each, whether or not the corresponding design-system prop is set. A bare we-text with no DS props was therefore issuing ~59 removeProperty calls per update, almost all of them clearing properties that had never been written. setProperty now tracks which properties an element has actually written and skips the clear path for the rest. Removing a property that was never set is a no-op by definition, so rendered output cannot change. Measured by ablation on a 3006-element tree: updateAllCustomVars accounted for 83% of the flush phase (~564ms of ~681ms). This recovers 87% of that. headless (4000 nodes) flush 681ms -> 192ms (-72%) browser, Static Extreme flush 128ms -> 93ms (-27%), total -7% browser, Web Components flush 23ms -> 14ms (-42%), total -22% Build is unchanged in both, confirming the change is isolated to the DS prop pipeline. The browser gains less than headless because happy-dom's CSSOM is a JS implementation where a no-op removeProperty is real work; the in-app suite is the figure that counts. Two primitives write a custom property directly that helpers.ts also generates (--we-spinner-color, --we-markdown-gap). Both are covered by tests; this likely fixes a latent ordering bug where helpers.ts could clear a var the component had just set. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../3-primitives/src/shared/helpers.ts | 41 ++++++++++++++++++- 1 file changed, 39 insertions(+), 2 deletions(-) diff --git a/packages/design-system/3-primitives/src/shared/helpers.ts b/packages/design-system/3-primitives/src/shared/helpers.ts index e2fb1ec7..beabe983 100644 --- a/packages/design-system/3-primitives/src/shared/helpers.ts +++ b/packages/design-system/3-primitives/src/shared/helpers.ts @@ -165,9 +165,46 @@ const DEFAULT_TRANSITION = 'all var(--we-transition-200, 150ms) ease'; // Runtime: CSS custom property updates // ──────────────────────────────────────────── +/** + * Custom properties this element has actually written, so clearing one that was never set can skip + * the CSSOM call entirely. + * + * WeakMap-keyed, so entries are collected with the element and nothing leaks. + */ +const writtenVars = new WeakMap>(); + +/** + * 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); } /** From e06a29e7317a95382a11006dd6171a29f1a51280 Mon Sep 17 00:00:00 2001 From: jhweir Date: Mon, 20 Jul 2026 14:23:16 +0100 Subject: [PATCH 5/8] refactor(design-utils): memoise getKeysForLayers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DesignSystemMixin calls this once per class, but ~20 primitives (button, text, input, badge, checkbox, ...) override getInstanceProps() and call it again on every invocation — once per instance per update. Each uncached call allocated a Set and spread it into a fresh array of up to 82 keys. layerKeyMap is a module constant, so the result is a pure function of the layer set and there are only a handful of distinct combinations across the whole design system. The returned array is shared rather than copied; callers treat it as read-only (filterProps takes readonly string[] and only filters/maps). Kept for correctness rather than speed: measured against a 3006-element tree it made no detectable difference, so this removes redundant work rather than delivering a win. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/design-system/utils/src/index.ts | 28 +++++++++++++++++++++-- 1 file changed, 26 insertions(+), 2 deletions(-) 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 --- From f4ebb8b76cb3c8c51a20b730fd99ee5ab65264b0 Mon Sep 17 00:00:00 2001 From: jhweir Date: Mon, 20 Jul 2026 14:23:39 +0100 Subject: [PATCH 6/8] fix(shell): reserve the scrollbar gutter on shell overlays The shell overlay container is the scroll container for every shell view. Any view whose content crosses the viewport height gains and loses its scrollbar as content changes, and each transition reflows the whole page horizontally. Most visible in the benchmark runner, which navigates between routes of wildly different heights and so jitters continuously, but it affects any shell view with variable-length content. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/frameworks/solid/layouts/TemplateLayout.tsx | 6 ++++++ 1 file changed, 6 insertions(+) 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" > From abaae80e772c41843559f1fd5287c99274a01d11 Mon Sep 17 00:00:00 2001 From: jhweir Date: Mon, 20 Jul 2026 14:23:39 +0100 Subject: [PATCH 7/8] test(bench): make the in-app benchmark suite measure what it claimed to MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The suite reported a single number that excluded most of the render. The timer sits last in each route, so its own body cannot run until every preceding sibling has been built — but it started the clock there, which put the entire schema walk outside the measured window. It also stopped at a single rAF, which fires before paint. The visible symptom was Tokens Heavy scoring faster than Tokens Light despite doing strictly more token work. Timing is now four phases derived from raw marks the timer hands back: Build navigation -> timer constructed schema walk, tokens, DOM creation Mount timer constructed -> onMount insertion + custom-element upgrade Flush onMount -> microtask Lit first render + DS prop pipeline Paint microtask -> 2nd rAF style, layout, paint Also: - One queue-driven runner. A single route's Run is a queue of one and Run All a queue of twelve, so both obey the same sampling rules; previously they were separate paths that would have drifted. - Median of 5 warm samples, first discarded. Samples bounce through a new /idle route because navigating to the current path is a no-op, so repeats would otherwise never remount. - Element and custom-element counts, so results normalise to us/element. Total ms alone is not comparable between a 137-element route and an 8015-element one — the previous absolute thresholds left the largest route permanently red while it was in fact the best performer per unit of work. - Per-route baselines for us/element (green +20%, amber +50%), and absolute bands for spread (green <10%, amber <20%). Both calibrated from measured run-to-run variation rather than guessed; earlier guessed thresholds coloured everything amber, which carries no information. - Spread reported as a trimmed range, dropping the slowest sample, so one GC pause doesn't dominate the error bar. - A reactive-update route, since update cost is paid during interaction and is a different question from mount cost. - Heap sampled on /idle before each route, to expose accumulation across a run. - A static progress overlay replaces the per-route status block, which used an animating we-spinner. That spinner was inflating every measurement in proportion to DOM size — removing it cut Static Extreme roughly in half. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../solid/components/BenchmarkTimer.tsx | 57 +- .../shell/tests/SchemaBenchmark.schema.ts | 694 ++++++++++++++++-- .../shared/schemas/shell/tests/testStore.ts | 411 +++++++++-- 3 files changed, 1030 insertions(+), 132 deletions(-) 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/shared/schemas/shell/tests/SchemaBenchmark.schema.ts b/packages/app-framework/src/shared/schemas/shell/tests/SchemaBenchmark.schema.ts index e6f27a22..07da50a8 100644 --- a/packages/app-framework/src/shared/schemas/shell/tests/SchemaBenchmark.schema.ts +++ b/packages/app-framework/src/shared/schemas/shell/tests/SchemaBenchmark.schema.ts @@ -9,6 +9,7 @@ * / — dashboard with results summary * /static-small — 50 static nodes (baseline) * /static-large — 200+ static nodes (scaling test) + * /static-extreme — 1000 static nodes (scaling test) * /tokens-light — 50 nodes with 1-2 $store reads each * /tokens-heavy — 50 nodes with deeply composed tokens * /each-flat — $each with 100 items @@ -17,9 +18,48 @@ * /solid-components — 100 Solid component nodes (Column, Row) * /deep-nesting — 30-level deep nesting * /mixed-realistic — ~70-node dashboard with representative token mix + * /update-perf — 100 nodes bound to one $store value; measures the update path + * /idle — near-empty bounce target the runner parks on between samples * - * Timing: BenchmarkTimer component at end of each route captures - * render time via onMount+rAF, passing it back via onComplete. + * Timing: BenchmarkTimer sits at the end of each route and stamps four checkpoints, which + * testStore turns into phase durations: + * + * Build navigation → timer constructed — schema walk, token resolution, DOM construction + * Mount timer constructed → onMount — DOM insertion + custom-element upgrade + * Flush onMount → microtask — Lit's async first render and updated() hooks + * Paint microtask → 2nd rAF — style, layout, paint + * + * The Build boundary works precisely *because* the timer is the last child: its component body + * cannot run until every preceding sibling has been built. An earlier version measured only from + * that point onward, which excluded the entire schema walk from the result — the reason Tokens + * Heavy used to score faster than Tokens Light despite doing strictly more token work. + * + * Sampling: every run takes 6 samples and reports the median of the last 5. The first is discarded + * as warm-up — it is genuinely different for the first route of a session (Lit template + * compilation, the class-level CSSStyleSheet), and discarding it is what makes a single route's + * run comparable to a Run All run. It is not displayed: measured across five sessions its own + * spread reached 32% on one route, so as a reported figure it was noise wearing the label of a + * finding. + * + * Each result instead carries a `spread` — the trimmed range of the warm samples, dropping the + * single slowest so one GC pause doesn't dominate — so every median comes with its own error bar. + * A delta smaller than a route's spread means nothing. Measured spread runs from ~1% on the small + * paint-bound routes to ~25% on Static Large, and varies that much run to run on the same route, + * which is why it is judged against absolute bands rather than a per-route baseline (see + * spreadColor). `heap` is the JS heap while parked on /idle just before the route rendered; a + * figure that climbs down the results list indicates accumulation across routes rather than any + * property of an individual route. + * + * Baselines: BASELINE_US_PER_ELEMENT below records each route's measured µs/element, and the colour + * coding compares against it — green to +20%, amber to +50%, red beyond. It exists so the page + * flags a *regression* rather than being permanently red, which is what both earlier threshold + * schemes were. + * + * That is a source-recorded reference for colouring only, not a persisted runtime baseline. An + * earlier version pinned results to localStorage and showed a percentage delta; it was removed + * because verifying a change forces a reload, and cross-session drift (~10%, and up to ~14% after + * a reboot) is far larger than within-session spread — so it reported drift as signal. Verifying a + * change still means comparing two sets of results directly, from settled run 3s. */ import type { SchemaNode, TemplateSchema } from '@we/schema-shared'; @@ -31,7 +71,7 @@ export const benchmarkBasePath = '/benchmarks'; // --------------------------------------------------------------------------- /** Timer placed at the end of each benchmark route. - * Records performance.now() at creation, measures to paint-complete via onMount+rAF. */ + * Stamps four checkpoints and hands them to testStore, which derives the phase durations. */ function timer(label: string): SchemaNode { return { type: 'BenchmarkTimer', @@ -42,15 +82,54 @@ function timer(label: string): SchemaNode { }; } -/** Threshold-based color token: green < 100ms, warning 100-300ms, danger > 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 Date: Mon, 20 Jul 2026 14:23:56 +0100 Subject: [PATCH 8/8] test(schema-bench): add a headless render benchmark package MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Iterating against the in-app suite means edit, rebuild, reload, run twelve routes three times, read results. That loop is slow enough to encourage guessing, and guessing already put a 2.5x Build regression into the app before it was caught. Renders the same fixtures through two registries and reports both: stub the schema walk in isolation real the walk plus what it causes downstream (buildLayoutStyles, Lit reactive-property setters, the CSSOM writes) The gap between them is the point, and is why this is a package rather than a file in @we/schema-solid. Measuring real cost needs the real design system, and the renderer must not depend on it — it 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. A stub-only harness was tried first and proved structurally blind to the class of change most renderer optimisations fall into: the regression above measured +6% against stubs and +160% in the app. Scope is Build and Flush. happy-dom has no layout engine, so Paint (~30% of browser total) is unreachable, and its JS CSSOM overstates flush by roughly 2.7x. Treat a result as a filter — a regression means stop, a win is a hypothesis to confirm in the app on a settled run 3. Includes an environment guard asserting Lit actually upgrades and the DS prop pipeline actually writes styles here. Without it, that pipeline silently failing under happy-dom would collapse flush and read as a spectacular optimisation. setProperty.probe.ts covers the write-tracking change in @we/primitives, including the two names a primitive writes directly that helpers.ts also generates. It belongs in @we/primitives; that package has no test infrastructure at all, and this is the only package already wired for happy-dom plus primitives. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../benchmarks/bench/environment.probe.ts | 56 ++++++ .../benchmarks/bench/renderTree.bench.tsx | 184 ++++++++++++++++++ .../benchmarks/bench/setProperty.probe.ts | 93 +++++++++ .../schema-system/benchmarks/package.json | 21 ++ .../schema-system/benchmarks/tsconfig.json | 19 ++ .../schema-system/benchmarks/vitest.config.ts | 19 ++ pnpm-lock.yaml | 27 +++ 7 files changed, 419 insertions(+) create mode 100644 packages/schema-system/benchmarks/bench/environment.probe.ts create mode 100644 packages/schema-system/benchmarks/bench/renderTree.bench.tsx create mode 100644 packages/schema-system/benchmarks/bench/setProperty.probe.ts create mode 100644 packages/schema-system/benchmarks/package.json create mode 100644 packages/schema-system/benchmarks/tsconfig.json create mode 100644 packages/schema-system/benchmarks/vitest.config.ts 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/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':