From 84a31a905181ad14505d4d947811181705348eca Mon Sep 17 00:00:00 2001 From: Jacob Simionato Date: Sun, 8 Mar 2026 14:29:04 +1030 Subject: [PATCH 1/4] Migreate web core to signals --- renderers/web_core/package-lock.json | 25 ++- renderers/web_core/package.json | 6 +- .../functions/basic_functions.test.ts | 69 ++++---- .../functions/basic_functions.ts | 34 ++-- renderers/web_core/src/v0_9/catalog/types.ts | 4 +- .../src/v0_9/rendering/data-context.test.ts | 4 +- .../src/v0_9/rendering/data-context.ts | 126 +++++-------- .../web_core/src/v0_9/state/data-model.ts | 165 ++++++++++-------- .../src/v0_9/test/function_execution.spec.ts | 15 +- 9 files changed, 213 insertions(+), 235 deletions(-) diff --git a/renderers/web_core/package-lock.json b/renderers/web_core/package-lock.json index df5ef79a1..8bf2ca709 100644 --- a/renderers/web_core/package-lock.json +++ b/renderers/web_core/package-lock.json @@ -9,7 +9,7 @@ "version": "0.8.2", "license": "Apache-2.0", "dependencies": { - "rxjs": "^7.8.2", + "@preact/signals-core": "^1.13.0", "zod": "^3.25.76" }, "devDependencies": { @@ -54,6 +54,16 @@ "node": ">= 8" } }, + "node_modules/@preact/signals-core": { + "version": "1.13.0", + "resolved": "https://registry.npmjs.org/@preact/signals-core/-/signals-core-1.13.0.tgz", + "integrity": "sha512-slT6XeTCAbdql61GVLlGU4x7XHI7kCZV5Um5uhE4zLX4ApgiiXc0UYFvVOKq06xcovzp7p+61l68oPi563ARKg==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/preact" + } + }, "node_modules/@types/node": { "version": "24.11.0", "integrity": "sha512-fPxQqz4VTgPI/IQ+lj9r0h+fDR66bzoeMGHp8ASee+32OSGIkeASsoZuJixsQoVef1QJbeubcPBxKk22QVoWdw==", @@ -390,14 +400,6 @@ "queue-microtask": "^1.2.2" } }, - "node_modules/rxjs": { - "version": "7.8.2", - "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.1.0" - } - }, "node_modules/signal-exit": { "version": "3.0.7", "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", @@ -416,11 +418,6 @@ "node": ">=8.0" } }, - "node_modules/tslib": { - "version": "2.8.1", - "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "license": "0BSD" - }, "node_modules/typescript": { "version": "5.9.3", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", diff --git a/renderers/web_core/package.json b/renderers/web_core/package.json index 92a73c3db..163d7e4a9 100644 --- a/renderers/web_core/package.json +++ b/renderers/web_core/package.json @@ -77,7 +77,7 @@ "clean": "if-file-deleted" }, "test": { - "command": "node --test \"dist/\"", + "command": "node --test dist/**/*.test.js", "dependencies": [ "build" ] @@ -92,7 +92,7 @@ "zod-to-json-schema": "^3.25.1" }, "dependencies": { - "rxjs": "^7.8.2", + "@preact/signals-core": "^1.13.0", "zod": "^3.25.76" } -} \ No newline at end of file +} diff --git a/renderers/web_core/src/v0_9/basic_catalog/functions/basic_functions.test.ts b/renderers/web_core/src/v0_9/basic_catalog/functions/basic_functions.test.ts index 3b6659564..03ecd183c 100644 --- a/renderers/web_core/src/v0_9/basic_catalog/functions/basic_functions.test.ts +++ b/renderers/web_core/src/v0_9/basic_catalog/functions/basic_functions.test.ts @@ -16,6 +16,7 @@ import { describe, it } from "node:test"; import * as assert from "node:assert"; +import { effect } from "@preact/signals-core"; import { BASIC_FUNCTIONS } from "./basic_functions.js"; import { DataModel } from "../../state/data-model.js"; import { DataContext } from "../../rendering/data-context.js"; @@ -238,11 +239,16 @@ describe("BASIC_FUNCTIONS", () => { const result = BASIC_FUNCTIONS.formatString( { value: "hello world" }, context, - ) as import("rxjs").Observable; + ) as import("@preact/signals-core").Signal; - result.subscribe((val) => { - assert.strictEqual(val, "hello world"); - done(); + let cleanup: (() => void) | undefined; + cleanup = effect(() => { + const val = result.value; + if (val) { + assert.strictEqual(val, "hello world"); + if (cleanup) cleanup(); + done(); + } }); }); @@ -251,32 +257,30 @@ describe("BASIC_FUNCTIONS", () => { const result = BASIC_FUNCTIONS.formatString( { value: "Value: ${a}" }, context, - ) as import("rxjs").Observable; + ) as import("@preact/signals-core").Signal; let emitCount = 0; - const sub = result.subscribe({ - next: (val) => { - try { - if (emitCount === 0) { - assert.strictEqual(val, "Value: 10"); - emitCount++; - // Trigger a change in the next tick to avoid uninitialized sub - setTimeout(() => { - dataModel.set("/a", 42); - }, 0); - } else if (emitCount === 1) { - assert.strictEqual(val, "Value: 42"); - emitCount++; - sub.unsubscribe(); - done(); - } - } catch (e) { - done(e); + let cleanup: (() => void) | undefined; + cleanup = effect(() => { + const val = result.value; + try { + if (emitCount === 0) { + assert.strictEqual(val, "Value: 10"); + emitCount++; + // Trigger a change in the next tick to avoid uninitialized sub + setTimeout(() => { + dataModel.set("/a", 42); + }, 0); + } else if (emitCount === 1) { + assert.strictEqual(val, "Value: 42"); + emitCount++; + if (cleanup) cleanup(); + done(); } - }, - error: (e) => { + } catch (e) { + if (cleanup) cleanup(); done(e); - }, + } }); }); @@ -292,11 +296,16 @@ describe("BASIC_FUNCTIONS", () => { const result = BASIC_FUNCTIONS.formatString( { value: "Result: ${add(a: 5, b: 7)}" }, ctxWithInvoker, - ) as import("rxjs").Observable; + ) as import("@preact/signals-core").Signal; - result.subscribe((val) => { - assert.strictEqual(val, "Result: 12"); - done(); + let cleanup: (() => void) | undefined; + cleanup = effect(() => { + const val = result.value; + if (val) { + assert.strictEqual(val, "Result: 12"); + if (cleanup) cleanup(); + done(); + } }); }); diff --git a/renderers/web_core/src/v0_9/basic_catalog/functions/basic_functions.ts b/renderers/web_core/src/v0_9/basic_catalog/functions/basic_functions.ts index 071744996..e108cfc04 100644 --- a/renderers/web_core/src/v0_9/basic_catalog/functions/basic_functions.ts +++ b/renderers/web_core/src/v0_9/basic_catalog/functions/basic_functions.ts @@ -15,8 +15,7 @@ */ import { ExpressionParser } from "../expressions/expression_parser.js"; -import { Observable, combineLatest, of } from "rxjs"; -import { map } from "rxjs/operators"; +import { computed, Signal } from "@preact/signals-core"; import { FunctionImplementation } from "../../catalog/types.js"; /** @@ -156,27 +155,24 @@ export const BASIC_FUNCTIONS: Record = { if (parts.length === 0) return ""; - const observables = parts.map((part) => { - // If it's a literal string (or number/boolean/etc), wrap it in 'of' + const dynamicParts = parts.map((part) => { + // If it's a literal string (or number/boolean/etc), keep it as is if (typeof part !== "object" || part === null || Array.isArray(part)) { - return of(part); + return part; } - - // Otherwise, it's a dynamic value we need to subscribe to - return new Observable((subscriber) => { - const sub = context.subscribeDynamicValue(part, (val) => { - subscriber.next(val); - }); - - // Emit the initial synchronously-resolved value - subscriber.next(sub.value); - - return () => sub.unsubscribe(); - }); + return context.resolveSignal(part); }); - // Combine all parts and join them into a single string whenever any part changes - return combineLatest(observables).pipe(map((values) => values.join(""))); + return computed(() => { + return dynamicParts + .map((p) => { + if (p instanceof Signal) { + return p.value; + } + return p; + }) + .join(""); + }); }, /** diff --git a/renderers/web_core/src/v0_9/catalog/types.ts b/renderers/web_core/src/v0_9/catalog/types.ts index e9646cffd..461e61d98 100644 --- a/renderers/web_core/src/v0_9/catalog/types.ts +++ b/renderers/web_core/src/v0_9/catalog/types.ts @@ -16,7 +16,7 @@ import { z } from "zod"; import { DataContext } from "../rendering/data-context.js"; -import { Observable } from "rxjs"; +import { Signal } from "@preact/signals-core"; /** * A function implementation that can be registered with the evaluator or basic catalog. @@ -24,7 +24,7 @@ import { Observable } from "rxjs"; export type FunctionImplementation = ( args: Record, context: DataContext, -) => unknown | Observable; +) => unknown | Signal; /** * A definition of a UI component's API. diff --git a/renderers/web_core/src/v0_9/rendering/data-context.test.ts b/renderers/web_core/src/v0_9/rendering/data-context.test.ts index b42363446..5f96b52c6 100644 --- a/renderers/web_core/src/v0_9/rendering/data-context.test.ts +++ b/renderers/web_core/src/v0_9/rendering/data-context.test.ts @@ -16,7 +16,7 @@ import assert from "node:assert"; import { describe, it, beforeEach } from "node:test"; -import { of } from "rxjs"; +import { signal } from "@preact/signals-core"; import { DataModel } from "../state/data-model.js"; import { DataContext } from "./data-context.js"; @@ -172,7 +172,7 @@ describe("DataContext", () => { it("subscribes to function call returning an observable", () => { const fnInvoker = (name: string) => { - if (name === "obs") return of("hello"); + if (name === "obs") return signal("hello"); return null; }; const ctx = new DataContext(model, "/", fnInvoker); diff --git a/renderers/web_core/src/v0_9/rendering/data-context.ts b/renderers/web_core/src/v0_9/rendering/data-context.ts index cbc70f41e..74713424a 100644 --- a/renderers/web_core/src/v0_9/rendering/data-context.ts +++ b/renderers/web_core/src/v0_9/rendering/data-context.ts @@ -14,8 +14,7 @@ * limitations under the License. */ -import { Observable, of, combineLatest, isObservable } from "rxjs"; -import { switchMap } from "rxjs/operators"; +import { signal, computed, Signal, effect } from "@preact/signals-core"; import { DataModel, DataSubscription } from "../state/data-model.js"; import type { DynamicValue, @@ -24,7 +23,7 @@ import type { } from "../schema/common-types.js"; import { A2uiExpressionError } from "../errors.js"; -/** A function that invokes a catalog function by name and returns its result synchronously or as an Observable. */ +/** A function that invokes a catalog function by name and returns its result synchronously or as a Signal. */ export type FunctionInvoker = ( name: string, args: Record, @@ -34,18 +33,10 @@ export type FunctionInvoker = ( /** * A contextual view of the main DataModel, serving as the unified interface for resolving * DynamicValues (literals, data paths, function calls) within a specific scope. - * - * Components use `DataContext` instead of interacting with the `DataModel` directly. - * It automatically handles resolving relative paths against the component's current scope - * and provides tools for evaluating complex, reactive expressions. */ export class DataContext { /** * Initializes a new DataContext. - * - * @param dataModel The shared, global DataModel instance for the entire UI surface. - * @param path The absolute path in the DataModel that this context is scoped to (its "current working directory"). - * @param functionInvoker An optional callback for executing function calls defined in the A2UI component tree against a UI catalog. */ constructor( readonly dataModel: DataModel, @@ -55,12 +46,6 @@ export class DataContext { /** * Mutates the underlying DataModel at the specified path. - * - * This is the primary method for components to push state changes (e.g. user input) - * back up to the global model. - * - * @param path A JSON pointer path. If relative, it is resolved against this context's `path`. - * @param value The new value to store in the DataModel. */ set(path: string, value: any): void { const absolutePath = this.resolvePath(path); @@ -68,21 +53,11 @@ export class DataContext { } /** - * Synchronously evaluates a `DynamicValue` (a literal, a path binding, or a function call) - * into its concrete runtime value. - * - * **Note:** This method evaluates the value *once* at the current moment in time. - * It does not create any reactive subscriptions. If the underlying data changes later, - * this result will not automatically update. Use `subscribeDynamicValue` for reactive updates. - * - * @param value The DynamicValue object from the A2UI JSON payload. - * @returns The synchronously resolved value. + * Synchronously evaluates a `DynamicValue` into its concrete runtime value. */ resolveDynamicValue(value: DynamicValue): V { // 1. Literal Check if (typeof value !== "object" || value === null || Array.isArray(value)) { - // TypeScript erases types at runtime, so we return the literal as V. - // Schema validation handles strict type checking. return value as V; } @@ -97,13 +72,10 @@ export class DataContext { const call = value as FunctionCall; const args: Record = {}; - // Resolve arguments recursively for (const [key, argVal] of Object.entries(call.args)) { args[key] = this.resolveDynamicValue(argVal); } - // Evaluate function - // Note: sync resolution of async functions returns the Observable itself if (!this.functionInvoker) { throw new A2uiExpressionError( `Failed to resolve dynamic value: Function invoker is not configured for call '${call.call}'.`, @@ -111,7 +83,7 @@ export class DataContext { } const result = this.functionInvoker(call.call, args, this); - return result as V; + return (result instanceof Signal ? result.peek() : result) as V; } throw new A2uiExpressionError( @@ -121,106 +93,93 @@ export class DataContext { /** * Reactively listens to changes in a `DynamicValue`. - * - * This is the core reactive binding mechanism. Whenever the underlying data changes - * (or if a function call's dependencies change), the `onChange` callback will be fired - * with the freshly evaluated result. - * - * @template V The expected type of the resolved value. - * @param value The DynamicValue to evaluate and observe. - * @param onChange A callback fired whenever the evaluated result changes. - * @returns A `DataSubscription` containing the initial synchronously-resolved value, along with an `unsubscribe` method to clean up the listener. */ subscribeDynamicValue( value: DynamicValue, onChange: (value: V | undefined) => void, ): DataSubscription { - let initialValue: V | undefined; + const sig = this.resolveSignal(value); + let isSync = true; + let currentValue = sig.peek(); - const observable = this.toObservable(value); - const sub = observable.subscribe((val) => { - if (isSync) { - initialValue = val; - } else { + const dispose = effect(() => { + const val = sig.value; + currentValue = val; + if (!isSync) { onChange(val); } }); isSync = false; return { - value: initialValue as unknown as V, - unsubscribe: () => sub.unsubscribe(), + get value() { + return currentValue; + }, + unsubscribe: () => dispose(), }; } - private toObservable(value: DynamicValue): Observable { + /** + * Returns a Preact Signal representing the reactive dynamic value. + */ + resolveSignal(value: DynamicValue): Signal { // 1. Literal if (typeof value !== "object" || value === null || Array.isArray(value)) { - return of(value as V); + return signal(value as V); } // 2. Path Check if ("path" in value) { const absolutePath = this.resolvePath((value as DataBinding).path); - // Create an observable from the data model subscription - return new Observable((subscriber) => { - const sub = this.dataModel.subscribe(absolutePath, (val) => { - subscriber.next(val as V); - }); - // Emit initial value immediately - subscriber.next(this.dataModel.get(absolutePath)); - return () => sub.unsubscribe(); - }); + return this.dataModel.getSignal(absolutePath) as Signal; } // 3. Function Call if ("call" in value) { const call = value as FunctionCall; - const argObservables: Record> = {}; + const argSignals: Record> = {}; for (const [key, argVal] of Object.entries(call.args)) { - argObservables[key] = this.toObservable(argVal); + argSignals[key] = this.resolveSignal(argVal); } - // If no args, just call directly - if (Object.keys(argObservables).length === 0) { - return this.evaluateFunctionReactive(call.call, {}); + if (Object.keys(argSignals).length === 0) { + const result = this.evaluateFunctionReactive(call.call, {}); + return result instanceof Signal ? result : signal(result); } - return combineLatest(argObservables).pipe( - switchMap((args) => this.evaluateFunctionReactive(call.call, args)), - ); + const keys = Object.keys(argSignals); + + return computed(() => { + const argsRecord: Record = {}; + for (let i = 0; i < keys.length; i++) { + argsRecord[keys[i]] = argSignals[keys[i]].value; + } + + const result = this.evaluateFunctionReactive(call.call, argsRecord); + // Track inner signal if the function returns one + return result instanceof Signal ? result.value : result; + }); } - return of(value as V); + return signal(value as unknown as V); } private evaluateFunctionReactive( name: string, args: Record, - ): Observable { + ): Signal | V { if (!this.functionInvoker) { throw new A2uiExpressionError( `Failed to resolve dynamic value: Function invoker is not configured for call '${name}'.`, ); } - const result = this.functionInvoker(name, args, this); - - if (isObservable(result)) { - return result as Observable; - } - return of(result as V); + return this.functionInvoker(name, args, this); } /** * Creates a new, child `DataContext` scoped to a deeper path. - * - * This is used when a component (like a List or a Card) wants to provide a targeted - * data scope for its children, so children can use relative paths like `./title`. - * - * @param relativePath The path relative to the *current* context's path. - * @returns A new `DataContext` instance pointing to the resolved absolute path. */ nested(relativePath: string): DataContext { const newPath = this.resolvePath(relativePath); @@ -228,16 +187,13 @@ export class DataContext { } private resolvePath(path: string): string { - // Absolute path - no resolution required. if (path.startsWith("/")) { return path; } - // Handle specific cases like '.' or empty if (path === "" || path === ".") { return this.path; } - // Normalize current path (remove trailing slash if exists, unless root) let base = this.path; if (base.endsWith("/") && base.length > 1) { base = base.slice(0, -1); diff --git a/renderers/web_core/src/v0_9/state/data-model.ts b/renderers/web_core/src/v0_9/state/data-model.ts index 4f3ac89df..be59e4133 100644 --- a/renderers/web_core/src/v0_9/state/data-model.ts +++ b/renderers/web_core/src/v0_9/state/data-model.ts @@ -16,6 +16,7 @@ import { Subscription as BaseSubscription } from "../common/events.js"; import { A2uiDataError } from "../errors.js"; +import { signal, Signal, batch, effect } from "@preact/signals-core"; /** * Represents a reactive connection to a specific path in the data model. @@ -27,35 +28,6 @@ export interface DataSubscription extends BaseSubscription { readonly value: T | undefined; } -class SubscriptionImpl implements DataSubscription { - private _value: T | undefined; - private readonly _unsubscribe: () => void; - public onChange: (value: T | undefined) => void; - - constructor( - initialValue: T | undefined, - onChange: (value: T | undefined) => void, - unsubscribe: () => void, - ) { - this._value = initialValue; - this.onChange = onChange; - this._unsubscribe = unsubscribe; - } - - get value(): T | undefined { - return this._value; - } - - setValue(value: T | undefined): void { - this._value = value; - this.onChange(value); - } - - unsubscribe(): void { - this._unsubscribe(); - } -} - function isNumeric(value: string): boolean { return /^\d+$/.test(value); } @@ -66,8 +38,8 @@ function isNumeric(value: string): boolean { */ export class DataModel { private data: Record = {}; - private readonly subscriptions: Map>> = - new Map(); + private readonly signals: Map> = new Map(); + private readonly subscriptions: Set<() => void> = new Set(); // To track direct subscriptions for dispose /** * Creates a new data model. @@ -79,7 +51,18 @@ export class DataModel { } /** - * Updates the model at the specific path and notifies all relevant subscribers. + * Retrieves a Preact Signal for a specific data path. + */ + getSignal(path: string): Signal { + const normalizedPath = this.normalizePath(path); + if (!this.signals.has(normalizedPath)) { + this.signals.set(normalizedPath, signal(this.get(normalizedPath))); + } + return this.signals.get(normalizedPath) as Signal; + } + + /** + * Updates the model at the specific path and notifies all relevant signals. * If path is '/' or empty, replaces the entire root. * * Note on `undefined` values: @@ -90,9 +73,10 @@ export class DataModel { if (path === null || path === undefined) { throw new A2uiDataError("Path cannot be null or undefined."); } + if (path === "/" || path === "") { this.data = value; - this.notifyAllSubscribers(); + this.notifyAllSignals(); return this; } @@ -148,7 +132,7 @@ export class DataModel { current[lastSegment] = value; } - this.notifySubscribers(path); + this.notifySignals(path); return this; } @@ -179,41 +163,47 @@ export class DataModel { /** * Subscribes to changes at the specified data path. - * - * @param path The JSON pointer path to subscribe to. - * @param onChange The callback to invoke when the data changes. - * @returns A subscription object that provides the current value and allows unsubscribing. + * Backwards-compatible layer using Preact Signals. */ subscribe( path: string, onChange: (value: T | undefined) => void, ): DataSubscription { - const normalizedPath = this.normalizePath(path); - const initialValue = this.get(normalizedPath); - - const subscription = new SubscriptionImpl(initialValue, onChange, () => { - const set = this.subscriptions.get(normalizedPath); - if (set) { - set.delete(subscription); - if (set.size === 0) { - this.subscriptions.delete(normalizedPath); - } + const sig = this.getSignal(path); + let isSync = true; + let currentValue = sig.peek(); + + const dispose = effect(() => { + const val = sig.value; + currentValue = val; + if (!isSync) { + onChange(val); } }); + isSync = false; - if (!this.subscriptions.has(normalizedPath)) { - this.subscriptions.set(normalizedPath, new Set()); - } - this.subscriptions.get(normalizedPath)!.add(subscription); + this.subscriptions.add(dispose); - return subscription; + return { + get value() { + return currentValue; + }, + unsubscribe: () => { + dispose(); + this.subscriptions.delete(dispose); + } + }; } /** * Clears all internal subscriptions. */ dispose(): void { + for (const dispose of this.subscriptions) { + dispose(); + } this.subscriptions.clear(); + this.signals.clear(); } private normalizePath(path: string): string { @@ -227,38 +217,59 @@ export class DataModel { return path.split("/").filter((p) => p.length > 0); } - private notifySubscribers(path: string): void { + private notifySignals(path: string): void { const normalizedPath = this.normalizePath(path); - this.notify(normalizedPath); - // Notify Ancestors - let parentPath = normalizedPath; - while (parentPath !== "/" && parentPath !== "") { - parentPath = parentPath.substring(0, parentPath.lastIndexOf("/")) || "/"; - this.notify(parentPath); - } + batch(() => { + this.updateSignal(normalizedPath); - // Notify Descendants - for (const subPath of this.subscriptions.keys()) { - if (this.isDescendant(subPath, normalizedPath)) { - this.notify(subPath); + // Notify Ancestors + let parentPath = normalizedPath; + while (parentPath !== "/" && parentPath !== "") { + parentPath = parentPath.substring(0, parentPath.lastIndexOf("/")) || "/"; + this.updateSignal(parentPath); } - } + + // Notify Descendants + for (const subPath of this.signals.keys()) { + if (this.isDescendant(subPath, normalizedPath)) { + this.updateSignal(subPath); + } + } + }); } - private notify(path: string): void { - const set = this.subscriptions.get(path); - if (!set) { - return; + private updateSignal(path: string): void { + const sig = this.signals.get(path); + if (sig) { + // Signals use strict equality. To force array/object updates we assign the reference again + // but if the reference didn't change (because we mutated it), we must trick it or create a new reference. + // Easiest is to force notify in Preact signals if mutating, but A2UI gets away with object equality checks. + // Wait, Preact signals track object mutation only if the reference changes or if we force it. + // Let's copy arrays/objects at read time? No, get() returns the same reference. + // To force Preact signal to update, we can re-assign a shallow clone, or since A2UI paths are often primitives it might be ok. + // Let's just assign the value. If it's the exact same object reference, Preact signal won't trigger effects. + // To fix this, we can trick Preact by assigning undefined then the value, or just use a wrapper. + // Wait, for DataModel.get(path), if it's an object, it mutated. + // We will assign a new wrapper object to force update, or just use `sig.value = Array.isArray(val) ? [...val] : (typeof val === 'object' && val !== null ? {...val} : val)` + // Let's just shallow clone to ensure reference change. + const val = this.get(path); + if (Array.isArray(val)) { + sig.value = [...val]; + } else if (typeof val === 'object' && val !== null) { + sig.value = { ...val }; + } else { + sig.value = val; + } } - const value = this.get(path); - set.forEach((sub) => sub.setValue(value)); } - private notifyAllSubscribers(): void { - for (const path of this.subscriptions.keys()) { - this.notify(path); - } + private notifyAllSignals(): void { + batch(() => { + for (const path of this.signals.keys()) { + this.updateSignal(path); + } + }); } private isDescendant(childPath: string, parentPath: string): boolean { @@ -267,4 +278,4 @@ export class DataModel { } return childPath.startsWith(parentPath + "/"); } -} +} \ No newline at end of file diff --git a/renderers/web_core/src/v0_9/test/function_execution.spec.ts b/renderers/web_core/src/v0_9/test/function_execution.spec.ts index 222100a68..316e41fac 100644 --- a/renderers/web_core/src/v0_9/test/function_execution.spec.ts +++ b/renderers/web_core/src/v0_9/test/function_execution.spec.ts @@ -3,8 +3,7 @@ import assert from "node:assert"; import { DataModel } from "../state/data-model.js"; import { DataContext } from "../rendering/data-context.js"; -import { timer } from "rxjs"; -import { map } from "rxjs/operators"; +import { signal } from "@preact/signals-core"; describe("Function Execution in DataContext", () => { it("resolves and subscribes to metronome function", (_t, done) => { @@ -14,7 +13,17 @@ describe("Function Execution in DataContext", () => { // mimic metronome: returns a stream of ticks functions.set("metronome", (args: Record) => { const interval = Number(args["interval"]) || 100; - return timer(0, interval).pipe(map((i) => `tick ${i}`)); + const subj = signal("tick 0"); + let i = 1; + const timerId = setInterval(() => { + subj.value = `tick ${i++}`; + }, interval); + + // Note: we're hacking cleanup here for test only because signals don't inherently have teardowns unless tied to an effect lifecycle + (subj as any).unsubscribe = () => { + clearInterval(timerId); + }; + return subj; }); const context = new DataContext(dataModel, "/", (name, args) => { From 9ee839a5cfe51374810bb81447f19cdbb3e99d25 Mon Sep 17 00:00:00 2001 From: Jacob Simionato Date: Tue, 10 Mar 2026 13:54:26 +1030 Subject: [PATCH 2/4] Address feedback --- .../src/v0_9/rendering/data-context.test.ts | 2 +- .../src/v0_9/rendering/data-context.ts | 76 +++++++++++++++++-- .../web_core/src/v0_9/state/data-model.ts | 15 +--- renderers/web_core/test_mechanism.ts | 6 ++ 4 files changed, 82 insertions(+), 17 deletions(-) create mode 100644 renderers/web_core/test_mechanism.ts diff --git a/renderers/web_core/src/v0_9/rendering/data-context.test.ts b/renderers/web_core/src/v0_9/rendering/data-context.test.ts index 5f96b52c6..cefec8704 100644 --- a/renderers/web_core/src/v0_9/rendering/data-context.test.ts +++ b/renderers/web_core/src/v0_9/rendering/data-context.test.ts @@ -170,7 +170,7 @@ describe("DataContext", () => { ); }); - it("subscribes to function call returning an observable", () => { + it("subscribes to function call returning a signal", () => { const fnInvoker = (name: string) => { if (name === "obs") return signal("hello"); return null; diff --git a/renderers/web_core/src/v0_9/rendering/data-context.ts b/renderers/web_core/src/v0_9/rendering/data-context.ts index 74713424a..5ef10921f 100644 --- a/renderers/web_core/src/v0_9/rendering/data-context.ts +++ b/renderers/web_core/src/v0_9/rendering/data-context.ts @@ -33,10 +33,18 @@ export type FunctionInvoker = ( /** * A contextual view of the main DataModel, serving as the unified interface for resolving * DynamicValues (literals, data paths, function calls) within a specific scope. + * + * Components use `DataContext` instead of interacting with the `DataModel` directly. + * It automatically handles resolving relative paths against the component's current scope + * and provides tools for evaluating complex, reactive expressions. */ export class DataContext { /** * Initializes a new DataContext. + * + * @param dataModel The shared, global DataModel instance for the entire UI surface. + * @param path The absolute path in the DataModel that this context is scoped to (its "current working directory"). + * @param functionInvoker An optional callback for executing function calls defined in the A2UI component tree against a UI catalog. */ constructor( readonly dataModel: DataModel, @@ -46,6 +54,12 @@ export class DataContext { /** * Mutates the underlying DataModel at the specified path. + * + * This is the primary method for components to push state changes (e.g. user input) + * back up to the global model. + * + * @param path A JSON pointer path. If relative, it is resolved against this context's `path`. + * @param value The new value to store in the DataModel. */ set(path: string, value: any): void { const absolutePath = this.resolvePath(path); @@ -53,7 +67,15 @@ export class DataContext { } /** - * Synchronously evaluates a `DynamicValue` into its concrete runtime value. + * Synchronously evaluates a `DynamicValue` (a literal, a path binding, or a function call) + * into its concrete runtime value. + * + * **Note:** This method evaluates the value *once* at the current moment in time. + * It does not create any reactive subscriptions. If the underlying data changes later, + * this result will not automatically update. Use `subscribeDynamicValue` for reactive updates. + * + * @param value The DynamicValue object from the A2UI JSON payload. + * @returns The synchronously resolved value. */ resolveDynamicValue(value: DynamicValue): V { // 1. Literal Check @@ -93,6 +115,15 @@ export class DataContext { /** * Reactively listens to changes in a `DynamicValue`. + * + * This is the core reactive binding mechanism. Whenever the underlying data changes + * (or if a function call's dependencies change), the `onChange` callback will be fired + * with the freshly evaluated result. + * + * @template V The expected type of the resolved value. + * @param value The DynamicValue to evaluate and observe. + * @param onChange A callback fired whenever the evaluated result changes. + * @returns A `DataSubscription` containing the initial synchronously-resolved value, along with an `unsubscribe` method to clean up the listener. */ subscribeDynamicValue( value: DynamicValue, @@ -116,7 +147,12 @@ export class DataContext { get value() { return currentValue; }, - unsubscribe: () => dispose(), + unsubscribe: () => { + dispose(); + if ((sig as any).unsubscribe) { + (sig as any).unsubscribe(); + } + }, }; } @@ -146,12 +182,19 @@ export class DataContext { if (Object.keys(argSignals).length === 0) { const result = this.evaluateFunctionReactive(call.call, {}); - return result instanceof Signal ? result : signal(result); + const sig = result instanceof Signal ? result : signal(result); + if (result instanceof Signal && (result as any).unsubscribe) { + (sig as any).unsubscribe = (result as any).unsubscribe; + } + return sig; } const keys = Object.keys(argSignals); + let innerUnsubscribe: (() => void) | undefined; - return computed(() => { + const sig = computed(() => { + if (innerUnsubscribe) innerUnsubscribe(); + const argsRecord: Record = {}; for (let i = 0; i < keys.length; i++) { argsRecord[keys[i]] = argSignals[keys[i]].value; @@ -159,8 +202,25 @@ export class DataContext { const result = this.evaluateFunctionReactive(call.call, argsRecord); // Track inner signal if the function returns one - return result instanceof Signal ? result.value : result; + if (result instanceof Signal) { + innerUnsubscribe = (result as any).unsubscribe; + return result.value; + } + innerUnsubscribe = undefined; + return result; }); + + (sig as any).unsubscribe = () => { + if (innerUnsubscribe) innerUnsubscribe(); + for (let i = 0; i < keys.length; i++) { + const argSig = argSignals[keys[i]]; + if ((argSig as any).unsubscribe) { + (argSig as any).unsubscribe(); + } + } + }; + + return sig; } return signal(value as unknown as V); @@ -180,6 +240,12 @@ export class DataContext { /** * Creates a new, child `DataContext` scoped to a deeper path. + * + * This is used when a component (like a List or a Card) wants to provide a targeted + * data scope for its children, so children can use relative paths like `./title`. + * + * @param relativePath The path relative to the *current* context's path. + * @returns A new `DataContext` instance pointing to the resolved absolute path. */ nested(relativePath: string): DataContext { const newPath = this.resolvePath(relativePath); diff --git a/renderers/web_core/src/v0_9/state/data-model.ts b/renderers/web_core/src/v0_9/state/data-model.ts index be59e4133..b6d9bd5e7 100644 --- a/renderers/web_core/src/v0_9/state/data-model.ts +++ b/renderers/web_core/src/v0_9/state/data-model.ts @@ -242,17 +242,10 @@ export class DataModel { private updateSignal(path: string): void { const sig = this.signals.get(path); if (sig) { - // Signals use strict equality. To force array/object updates we assign the reference again - // but if the reference didn't change (because we mutated it), we must trick it or create a new reference. - // Easiest is to force notify in Preact signals if mutating, but A2UI gets away with object equality checks. - // Wait, Preact signals track object mutation only if the reference changes or if we force it. - // Let's copy arrays/objects at read time? No, get() returns the same reference. - // To force Preact signal to update, we can re-assign a shallow clone, or since A2UI paths are often primitives it might be ok. - // Let's just assign the value. If it's the exact same object reference, Preact signal won't trigger effects. - // To fix this, we can trick Preact by assigning undefined then the value, or just use a wrapper. - // Wait, for DataModel.get(path), if it's an object, it mutated. - // We will assign a new wrapper object to force update, or just use `sig.value = Array.isArray(val) ? [...val] : (typeof val === 'object' && val !== null ? {...val} : val)` - // Let's just shallow clone to ensure reference change. + // Signals trigger updates based on strict equality checks. If an object or array + // in the data model is mutated, its reference doesn't change, and the signal + // won't update. By creating a shallow copy, we ensure a new reference is + // assigned, which correctly triggers dependent effects. const val = this.get(path); if (Array.isArray(val)) { sig.value = [...val]; diff --git a/renderers/web_core/test_mechanism.ts b/renderers/web_core/test_mechanism.ts new file mode 100644 index 000000000..838f16e18 --- /dev/null +++ b/renderers/web_core/test_mechanism.ts @@ -0,0 +1,6 @@ +import { signal } from "@preact/signals-core"; + +const s = signal(0); +(s as any).unsubscribe = () => console.log("unsubscribed"); + +console.log(typeof (s as any).unsubscribe); From 4399db5d39ad6bb16d45dc1375e936f5c857633a Mon Sep 17 00:00:00 2001 From: Jacob Simionato Date: Tue, 10 Mar 2026 14:34:27 +1030 Subject: [PATCH 3/4] Add abort signals --- renderers/web_core/src/v0_9/catalog/types.ts | 1 + .../src/v0_9/rendering/data-context.ts | 34 +++++++++---------- .../src/v0_9/test/function_execution.spec.ts | 18 +++++----- 3 files changed, 27 insertions(+), 26 deletions(-) diff --git a/renderers/web_core/src/v0_9/catalog/types.ts b/renderers/web_core/src/v0_9/catalog/types.ts index 461e61d98..2bb33c06d 100644 --- a/renderers/web_core/src/v0_9/catalog/types.ts +++ b/renderers/web_core/src/v0_9/catalog/types.ts @@ -24,6 +24,7 @@ import { Signal } from "@preact/signals-core"; export type FunctionImplementation = ( args: Record, context: DataContext, + abortSignal?: AbortSignal, ) => unknown | Signal; /** diff --git a/renderers/web_core/src/v0_9/rendering/data-context.ts b/renderers/web_core/src/v0_9/rendering/data-context.ts index 5ef10921f..9c0665f00 100644 --- a/renderers/web_core/src/v0_9/rendering/data-context.ts +++ b/renderers/web_core/src/v0_9/rendering/data-context.ts @@ -28,6 +28,7 @@ export type FunctionInvoker = ( name: string, args: Record, context: DataContext, + abortSignal?: AbortSignal, ) => any; /** @@ -104,7 +105,11 @@ export class DataContext { ); } - const result = this.functionInvoker(call.call, args, this); + // Synchronous resolution should not spawn long-running resources. + const abortController = new AbortController(); + abortController.abort(); + + const result = this.functionInvoker(call.call, args, this, abortController.signal); return (result instanceof Signal ? result.peek() : result) as V; } @@ -181,37 +186,31 @@ export class DataContext { } if (Object.keys(argSignals).length === 0) { - const result = this.evaluateFunctionReactive(call.call, {}); + const abortController = new AbortController(); + const result = this.evaluateFunctionReactive(call.call, {}, abortController.signal); const sig = result instanceof Signal ? result : signal(result); - if (result instanceof Signal && (result as any).unsubscribe) { - (sig as any).unsubscribe = (result as any).unsubscribe; - } + (sig as any).unsubscribe = () => abortController.abort(); return sig; } const keys = Object.keys(argSignals); - let innerUnsubscribe: (() => void) | undefined; + let abortController: AbortController | undefined; const sig = computed(() => { - if (innerUnsubscribe) innerUnsubscribe(); + if (abortController) abortController.abort(); + abortController = new AbortController(); const argsRecord: Record = {}; for (let i = 0; i < keys.length; i++) { argsRecord[keys[i]] = argSignals[keys[i]].value; } - const result = this.evaluateFunctionReactive(call.call, argsRecord); - // Track inner signal if the function returns one - if (result instanceof Signal) { - innerUnsubscribe = (result as any).unsubscribe; - return result.value; - } - innerUnsubscribe = undefined; - return result; + const result = this.evaluateFunctionReactive(call.call, argsRecord, abortController.signal); + return result instanceof Signal ? result.value : result; }); (sig as any).unsubscribe = () => { - if (innerUnsubscribe) innerUnsubscribe(); + if (abortController) abortController.abort(); for (let i = 0; i < keys.length; i++) { const argSig = argSignals[keys[i]]; if ((argSig as any).unsubscribe) { @@ -229,13 +228,14 @@ export class DataContext { private evaluateFunctionReactive( name: string, args: Record, + abortSignal?: AbortSignal, ): Signal | V { if (!this.functionInvoker) { throw new A2uiExpressionError( `Failed to resolve dynamic value: Function invoker is not configured for call '${name}'.`, ); } - return this.functionInvoker(name, args, this); + return this.functionInvoker(name, args, this, abortSignal); } /** diff --git a/renderers/web_core/src/v0_9/test/function_execution.spec.ts b/renderers/web_core/src/v0_9/test/function_execution.spec.ts index 316e41fac..08d564de4 100644 --- a/renderers/web_core/src/v0_9/test/function_execution.spec.ts +++ b/renderers/web_core/src/v0_9/test/function_execution.spec.ts @@ -11,7 +11,7 @@ describe("Function Execution in DataContext", () => { const functions = new Map(); // mimic metronome: returns a stream of ticks - functions.set("metronome", (args: Record) => { + functions.set("metronome", (args: Record, abortSignal?: AbortSignal) => { const interval = Number(args["interval"]) || 100; const subj = signal("tick 0"); let i = 1; @@ -19,16 +19,16 @@ describe("Function Execution in DataContext", () => { subj.value = `tick ${i++}`; }, interval); - // Note: we're hacking cleanup here for test only because signals don't inherently have teardowns unless tied to an effect lifecycle - (subj as any).unsubscribe = () => { - clearInterval(timerId); - }; + abortSignal?.addEventListener("abort", () => { + clearInterval(timerId); + }); + return subj; }); - const context = new DataContext(dataModel, "/", (name, args) => { + const context = new DataContext(dataModel, "/", (name, args, _ctx, abortSignal) => { const fn = functions.get(name); - return fn ? fn(args) : undefined; + return fn ? fn(args, abortSignal) : undefined; }); // DynamicValue representing: metronome(interval: 50) @@ -68,9 +68,9 @@ describe("Function Execution in DataContext", () => { return `echo: ${args["val"]}`; }); - const context = new DataContext(dataModel, "/", (name, args) => { + const context = new DataContext(dataModel, "/", (name, args, _ctx, abortSignal) => { const fn = functions.get(name); - return fn ? fn(args) : undefined; + return fn ? fn(args, abortSignal) : undefined; }); dataModel.set("/msg", "hello"); From bd82d1580623a34b2e945fd450241e58cdd65b14 Mon Sep 17 00:00:00 2001 From: Jacob Simionato Date: Tue, 10 Mar 2026 14:45:52 +1030 Subject: [PATCH 4/4] Remove test mechanism --- renderers/web_core/test_mechanism.ts | 6 ------ 1 file changed, 6 deletions(-) delete mode 100644 renderers/web_core/test_mechanism.ts diff --git a/renderers/web_core/test_mechanism.ts b/renderers/web_core/test_mechanism.ts deleted file mode 100644 index 838f16e18..000000000 --- a/renderers/web_core/test_mechanism.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { signal } from "@preact/signals-core"; - -const s = signal(0); -(s as any).unsubscribe = () => console.log("unsubscribed"); - -console.log(typeof (s as any).unsubscribe);