diff --git a/renderers/lit/package-lock.json b/renderers/lit/package-lock.json index ced356e5..d30dd34d 100644 --- a/renderers/lit/package-lock.json +++ b/renderers/lit/package-lock.json @@ -26,9 +26,10 @@ }, "../web_core": { "name": "@a2ui/web_core", - "version": "0.8.0", + "version": "0.8.2", "license": "Apache-2.0", "devDependencies": { + "@types/node": "^24.10.1", "typescript": "^5.8.3", "wireit": "^0.15.0-pre.2" } @@ -1007,8 +1008,7 @@ "version": "0.2.2", "resolved": "https://registry.npmjs.org/signal-polyfill/-/signal-polyfill-0.2.2.tgz", "integrity": "sha512-p63Y4Er5/eMQ9RHg0M0Y64NlsQKpiu6MDdhBXpyywRuWiPywhJTpKJ1iB5K2hJEbFZ0BnDS7ZkJ+0AfTuL37Rg==", - "license": "Apache-2.0", - "peer": true + "license": "Apache-2.0" }, "node_modules/signal-utils": { "version": "0.21.1", diff --git a/renderers/web_core/package-lock.json b/renderers/web_core/package-lock.json index fdcd63be..71d363e6 100644 --- a/renderers/web_core/package-lock.json +++ b/renderers/web_core/package-lock.json @@ -1,14 +1,15 @@ { "name": "@a2ui/web_core", - "version": "0.8.0", + "version": "0.8.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@a2ui/web_core", - "version": "0.8.0", + "version": "0.8.2", "license": "Apache-2.0", "devDependencies": { + "@types/node": "^24.10.1", "typescript": "^5.8.3", "wireit": "^0.15.0-pre.2" } @@ -51,6 +52,16 @@ "node": ">= 8" } }, + "node_modules/@types/node": { + "version": "24.10.12", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.12.tgz", + "integrity": "sha512-68e+T28EbdmLSTkPgs3+UacC6rzmqrcWFPQs1C8mwJhI/r5Uxr0yEuQotczNRROd1gq30NGxee+fo0rSIxpyAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.16.0" + } + }, "node_modules/anymatch": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", @@ -439,6 +450,13 @@ "node": ">=14.17" } }, + "node_modules/undici-types": { + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "dev": true, + "license": "MIT" + }, "node_modules/wireit": { "version": "0.15.0-pre.2", "resolved": "https://registry.npmjs.org/wireit/-/wireit-0.15.0-pre.2.tgz", diff --git a/renderers/web_core/package.json b/renderers/web_core/package.json index c911bf29..1f3ed83d 100644 --- a/renderers/web_core/package.json +++ b/renderers/web_core/package.json @@ -9,6 +9,10 @@ "types": "./dist/src/v0_8/index.d.ts", "default": "./dist/src/v0_8/index.js" }, + "./v0_9": { + "types": "./dist/src/v0_9/index.d.ts", + "default": "./dist/src/v0_9/index.js" + }, "./types/*": { "types": "./dist/src/v0_8/types/*.d.ts", "default": "./dist/src/v0_8/types/*.js" @@ -69,6 +73,7 @@ "author": "Google", "license": "Apache-2.0", "devDependencies": { + "@types/node": "^24.10.1", "typescript": "^5.8.3", "wireit": "^0.15.0-pre.2" } diff --git a/renderers/web_core/src/v0_9/state/data-model.test.ts b/renderers/web_core/src/v0_9/state/data-model.test.ts new file mode 100644 index 00000000..09e01f85 --- /dev/null +++ b/renderers/web_core/src/v0_9/state/data-model.test.ts @@ -0,0 +1,203 @@ + +import assert from 'node:assert'; +import { test, describe, it, beforeEach } from 'node:test'; +import { DataModel } from './data-model.js'; + +describe('DataModel', () => { + let model: DataModel; + + beforeEach(() => { + model = new DataModel({ + user: { + name: 'Alice', + settings: { + theme: 'dark' + } + }, + items: ['a', 'b', 'c'] + }); + }); + + // --- Basic Retrieval --- + + it('retrieves root data', () => { + assert.deepStrictEqual(model.get('/'), { user: { name: 'Alice', settings: { theme: 'dark' } }, items: ['a', 'b', 'c'] }); + }); + + it('retrieves nested path', () => { + assert.strictEqual(model.get('/user/name'), 'Alice'); + assert.strictEqual(model.get('/user/settings/theme'), 'dark'); + }); + + it('retrieves array items', () => { + assert.strictEqual(model.get('/items/0'), 'a'); + assert.strictEqual(model.get('/items/1'), 'b'); + }); + + it('returns undefined for non-existent paths', () => { + assert.strictEqual(model.get('/user/age'), undefined); + assert.strictEqual(model.get('/unknown/path'), undefined); + }); + + // --- Updates --- + + it('sets value at existing path', () => { + model.set('/user/name', 'Bob'); + assert.strictEqual(model.get('/user/name'), 'Bob'); + }); + + it('sets value at new path', () => { + model.set('/user/age', 30); + assert.strictEqual(model.get('/user/age'), 30); + }); + + it('creates intermediate objects', () => { + model.set('/a/b/c', 'foo'); + assert.strictEqual(model.get('/a/b/c'), 'foo'); + assert.notStrictEqual(model.get('/a/b'), undefined); + }); + + it('removes keys when value is undefined', () => { + model.set('/user/name', undefined); + assert.strictEqual(model.get('/user/name'), undefined); + assert.strictEqual(Object.keys(model.get('/user')).includes('name'), false); + }); + + it('replaces root object on root update', () => { + model.set('/', { newRoot: true }); + assert.deepStrictEqual(model.get('/'), { newRoot: true }); + }); + + // --- Array / List Handling (Flutter Parity) --- + + it('List: set and get', () => { + model.set('/list/0', 'hello'); + assert.strictEqual(model.get('/list/0'), 'hello'); + assert.ok(Array.isArray(model.get('/list'))); + }); + + it('List: append and get', () => { + model.set('/list/0', 'hello'); + model.set('/list/1', 'world'); + assert.strictEqual(model.get('/list/0'), 'hello'); + assert.strictEqual(model.get('/list/1'), 'world'); + assert.strictEqual(model.get('/list').length, 2); + }); + + it('List: update existing index', () => { + model.set('/items/1', 'updated'); + assert.strictEqual(model.get('/items/1'), 'updated'); + }); + + it('Nested structures are created automatically', () => { + // Should create nested map and list: { a: { b: [ { c: 123 } ] } } + model.set('/a/b/0/c', 123); + assert.strictEqual(model.get('/a/b/0/c'), 123); + assert.ok(Array.isArray(model.get('/a/b'))); + assert.ok(!Array.isArray(model.get('/a/b/0'))); + + // Should create nested maps + model.set('/x/y/z', 'hello'); + assert.strictEqual(model.get('/x/y/z'), 'hello'); + + // Should create nested lists + model.set('/nestedList/0/0', 'inner'); + assert.strictEqual(model.get('/nestedList/0/0'), 'inner'); + assert.ok(Array.isArray(model.get('/nestedList'))); + assert.ok(Array.isArray(model.get('/nestedList/0'))); + }); + + // --- Subscriptions --- + + it('returns a subscription object', () => { + model.set('/a', 1); + const sub = model.subscribe('/a'); + assert.strictEqual(sub.value, 1); + + let updatedValue: number | undefined; + sub.onChange = (val) => updatedValue = val; + + model.set('/a', 2); + assert.strictEqual(sub.value, 2); + assert.strictEqual(updatedValue, 2); + + sub.unsubscribe(); + // Verify listener removed + model.set('/a', 3); + assert.strictEqual(updatedValue, 2); + }); + + it('notifies subscribers on exact match', (_: any, done: (result?: any) => void) => { + const sub = model.subscribe('/user/name'); + sub.onChange = (val) => { + assert.strictEqual(val, 'Charlie'); + done(); + }; + model.set('/user/name', 'Charlie'); + }); + + it('notifies ancestor subscribers (Container Semantics)', (_: any, done: (result?: any) => void) => { + const sub = model.subscribe('/user'); + sub.onChange = (val: any) => { + assert.strictEqual(val.name, 'Dave'); + done(); + }; + model.set('/user/name', 'Dave'); + }); + + it('notifies descendant subscribers', (_: any, done: (result?: any) => void) => { + const sub = model.subscribe('/user/settings/theme'); + sub.onChange = (val) => { + assert.strictEqual(val, 'light'); + done(); + }; + + // We update the parent object + model.set('/user/settings', { theme: 'light' }); + }); + + it('notifies root subscriber', (_: any, done: (result?: any) => void) => { + const sub = model.subscribe('/'); + sub.onChange = (val: any) => { + assert.strictEqual(val.newProp, 'test'); + done(); + }; + model.set('/newProp', 'test'); + }); + + it('notifies parent when child updates', () => { + model.set('/parent', { child: 'initial' }); + + const sub = model.subscribe('/parent'); + let parentValue: any; + sub.onChange = (val) => parentValue = val; + + model.set('/parent/child', 'updated'); + assert.deepStrictEqual(parentValue, { child: 'updated' }); + }); + + it('stops notifying after dispose', () => { + let count = 0; + const sub = model.subscribe('/'); + sub.onChange = () => count++; + + model.dispose(); + model.set('/foo', 'bar'); + assert.strictEqual(count, 0); + }); + + it('throws when trying to set nested property through a primitive', () => { + model.set('/user/name', 'not an object'); + assert.strictEqual(model.get('/user/name'), 'not an object'); + + assert.throws(() => { + model.set('/user/name/first', 'Alice'); + }, /Cannot set path/); + }); + + it('throws when using non-numeric segment on an array', () => { + assert.throws(() => { + model.set('/items/foo', 'bar'); + }, /Cannot use non-numeric segment/); + }); +}); diff --git a/renderers/web_core/src/v0_9/state/data-model.ts b/renderers/web_core/src/v0_9/state/data-model.ts new file mode 100644 index 00000000..cb4d7073 --- /dev/null +++ b/renderers/web_core/src/v0_9/state/data-model.ts @@ -0,0 +1,207 @@ +/* + Copyright 2025 Google LLC + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +/** + * Represents a reactive connection to a specific path in the data model. + */ +export interface Subscription { + /** + * The current value at the subscribed path. + */ + readonly value: T | undefined; + + /** + * A callback function to be invoked when the value changes. + */ + onChange?: (value: T | undefined) => void; + + /** + * Unsubscribes from the data model. + */ + unsubscribe(): void; +} + +/** + * A standalone, observable data store representing the client-side state. + * It handles JSON Pointer path resolution and subscription management. + */ +export class DataModel { + private data: any = {}; + private readonly subscriptions: Map>> = new Map(); + + constructor(initialData: any = {}) { + this.data = initialData; + } + + /** + * Updates the model at the specific path and notifies all relevant subscribers. + * If path is '/' or empty, replaces the entire root. + * + * Note on `undefined` values: + * - For objects: Setting a property to `undefined` removes the key from the object. + * - For arrays: Setting an index to `undefined` sets that index to `undefined` but preserves the array length (sparse array). + */ + set(path: string, value: any): void { + if (path === '/' || path === '') { + this.data = value; + this.notifyAllSubscribers(); + return; + } + + const segments = this.parsePath(path); + const lastSegment = segments.pop()!; + + let current = this.data; + for (let i = 0; i < segments.length; i++) { + const segment = segments[i]; + + if (Array.isArray(current) && !/^\d+$/.test(segment)) { + throw new Error(`Cannot use non-numeric segment '${segment}' on an array in path '${path}'.`); + } + + // If we encounter a primitive where a container is expected, we cannot proceed. + // We allow undefined/null to be overwritten by a new container. + if (current[segment] !== undefined && current[segment] !== null && typeof current[segment] !== 'object') { + throw new Error(`Cannot set path '${path}': segment '${segment}' is a primitive value.`); + } + + if (current[segment] === undefined || current[segment] === null) { + const nextSegment = (i < segments.length - 1) ? segments[i + 1] : lastSegment; + current[segment] = /^\d+$/.test(nextSegment) ? [] : {}; + } + current = current[segment]; + } + + if (Array.isArray(current) && !/^\d+$/.test(lastSegment)) { + throw new Error(`Cannot use non-numeric segment '${lastSegment}' on an array in path '${path}'.`); + } + + if (value === undefined) { + if (Array.isArray(current)) { + current[parseInt(lastSegment, 10)] = undefined; + } else { + delete current[lastSegment]; + } + } else { + current[lastSegment] = value; + } + + this.notifySubscribers(path); + } + + /** + * Retrieves data at a specific path. + */ + get(path: string): any { + if (path === '/' || path === '') return this.data; + + const segments = this.parsePath(path); + let current = this.data; + for (const segment of segments) { + if (current === undefined || current === null) return undefined; + current = current[segment]; + } + return current; + } + + /** + * Subscribes to changes at a specific path. Returns a Subscription object. + */ + subscribe(path: string): Subscription { + const normalizedPath = this.normalizePath(path); + + const subscription: Subscription = { + value: undefined as any, + onChange: undefined, + unsubscribe: () => { + const set = this.subscriptions.get(normalizedPath); + if (set) { + set.delete(subscription); + if (set.size === 0) { + this.subscriptions.delete(normalizedPath); + } + } + } + }; + + Object.defineProperty(subscription, 'value', { + get: () => this.get(normalizedPath), + enumerable: true + }); + + if (!this.subscriptions.has(normalizedPath)) { + this.subscriptions.set(normalizedPath, new Set()); + } + this.subscriptions.get(normalizedPath)!.add(subscription); + + return subscription; + } + + /** + * Clears all internal subscriptions. + */ + dispose(): void { + this.subscriptions.clear(); + } + + private normalizePath(path: string): string { + if (path.length > 1 && path.endsWith('/')) { + return path.slice(0, -1); + } + return path || '/'; + } + + private parsePath(path: string): string[] { + return path.split('/').filter(p => p.length > 0); + } + + private notifySubscribers(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); + } + + // Notify Descendants + for (const subPath of this.subscriptions.keys()) { + if (this.isDescendant(subPath, normalizedPath)) { + this.notify(subPath); + } + } + } + + private notify(path: string): void { + const set = this.subscriptions.get(path); + if (!set) return; + const value = this.get(path); + set.forEach(sub => sub.onChange?.(value)); + } + + private notifyAllSubscribers(): void { + for (const path of this.subscriptions.keys()) { + this.notify(path); + } + } + + private isDescendant(childPath: string, parentPath: string): boolean { + if (parentPath === '/' || parentPath === '') return childPath !== '/'; + return childPath.startsWith(parentPath + '/'); + } +} diff --git a/specification/v0_9/docs/a2ui_protocol.md b/specification/v0_9/docs/a2ui_protocol.md index 910be0e4..5a7e6fee 100644 --- a/specification/v0_9/docs/a2ui_protocol.md +++ b/specification/v0_9/docs/a2ui_protocol.md @@ -408,6 +408,7 @@ When a container component (such as `Column`, `Row`, or `List`) utilizes the **T - **Scope instantiation:** For every item in the array, the client instantiates the template component. - **Relative resolution:** Inside these instantiated components, any path that **does not** start with a forward slash `/` is treated as a **Relative Path**. - A relative path `firstName` inside a template iterating over `/users` resolves to `/users/0/firstName` for the first item, `/users/1/firstName` for the second, etc. + - It is an error to use a non-numeric index on a path segment that refers to an array. - **Mixing scopes:** Components inside a Child Scope can still access the Root Scope by using an Absolute Path. @@ -519,7 +520,7 @@ The server sends `updateDataModel` messages to modify the client's data model. T - If the path exists, the value is updated. - If the path does not exist, the value is created. -- If the value is `null`, the key at that path is removed. +- If the value is omitted (or set to `undefined`), the key is removed. For arrays, the index is set to `undefined`, preserving length. The `updateDataModel` message replaces the value at the specified `path` with the new content. If `path` is omitted (or is `/`), the entire data model for the surface is replaced.