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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 12 additions & 7 deletions apps/playgrounds/solid/portable-ui-slice/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: ['../../../..'] },
Expand Down
Original file line number Diff line number Diff line change
@@ -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 });
});
});
});
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"
>
<ShellRouteStoreProvider>
<ShellOverlayInner stores={stores} view={view} />
Expand Down
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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.
Expand Down
37 changes: 22 additions & 15 deletions packages/app-framework/src/frameworks/solid/types.ts
Original file line number Diff line number Diff line change
@@ -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);
Expand All @@ -22,7 +22,24 @@ export type ModelStore = {
delete: (modelName: string, id: string, options?: { perspective?: string }) => Promise<void>;
};

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;
Expand All @@ -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<string, unknown>;
}
97 changes: 97 additions & 0 deletions packages/app-framework/src/shared/ad4mAdapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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<T>)` — 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<typeof Model.query>[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> | 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<string, unknown> | 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'],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,12 +39,19 @@ export function registerDynamicModels(perspectiveUuid: string, models: Record<st
* (e.g. Flux models not known to WE at compile time).
* Returns `undefined` (rather than throwing) so callers can fall back gracefully.
*/
export function getModelForPerspective(name: string, perspectiveUuid?: string): ModelClass | undefined {
/**
* `dataset` is the renderer's opaque dataset handle — for this backend, a `PerspectiveProxy`.
* Deriving the registry key from it is deliberately the host's job: the renderer never inspects a
* handle, so only here is the concrete type known. Note `uuid` must be read rather than `id`, since
* a `PerspectiveProxy` also carries an unrelated `id` (a subscription id) that must not win.
*/
export function getModelForPerspective(name: string, dataset?: unknown): ModelClass | undefined {
// Prefer globally registered native class first
const global = modelRegistry[name];
if (global) return global;

// Fall back to per-perspective synthesised class (external models)
const perspectiveUuid = (dataset as { uuid?: string } | undefined)?.uuid;
if (perspectiveUuid) {
return perspectiveModelRegistry.get(perspectiveUuid)?.[name];
}
Expand Down
Loading
Loading