From 6ab973e8f916f889cafe5b66a06f964363a3837e Mon Sep 17 00:00:00 2001 From: Jacob Simionato Date: Fri, 30 Jan 2026 08:23:06 +1030 Subject: [PATCH 1/8] Add gemignore --- .geminiignore | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 .geminiignore diff --git a/.geminiignore b/.geminiignore new file mode 100644 index 00000000..b757a207 --- /dev/null +++ b/.geminiignore @@ -0,0 +1,4 @@ +# Ignore large lock files that aren't useful for context +**/pnpm-lock.yaml +**/package-lock.json +**/uv.lock From d32c5e30f7a277334829496c71d3a11d7533b24d Mon Sep 17 00:00:00 2001 From: Jacob Simionato Date: Mon, 9 Feb 2026 12:08:07 +1030 Subject: [PATCH 2/8] Add data model --- .../src/v0_9/state/data-model.test.ts | 188 +++++++++++++++++ .../web_core/src/v0_9/state/data-model.ts | 189 ++++++++++++++++++ 2 files changed, 377 insertions(+) create mode 100644 renderers/web_core/src/v0_9/state/data-model.test.ts create mode 100644 renderers/web_core/src/v0_9/state/data-model.ts 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..e07d1488 --- /dev/null +++ b/renderers/web_core/src/v0_9/state/data-model.test.ts @@ -0,0 +1,188 @@ + +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', (_, done) => { + 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)', (_, done) => { + const sub = model.subscribe('/user'); + sub.onChange = (val: any) => { + assert.strictEqual(val.name, 'Dave'); + done(); + }; + model.set('/user/name', 'Dave'); + }); + + it('notifies descendant subscribers', (_, done) => { + 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', (_, done) => { + 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); + }); +}); 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..94b8d134 --- /dev/null +++ b/renderers/web_core/src/v0_9/state/data-model.ts @@ -0,0 +1,189 @@ +/* + 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; + + /** + * A callback function to be invoked when the value changes. + */ + onChange?: (value: T) => 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. + */ + 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 (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 (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); + if (parentPath === '/') break; + } + + // 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 + '/'); + } +} From a4730896cac572976d684ce240c4ebfec4bd6e76 Mon Sep 17 00:00:00 2001 From: Jacob Simionato Date: Mon, 9 Feb 2026 12:28:23 +1030 Subject: [PATCH 3/8] Try to fix CI --- renderers/web_core/package-lock.json | 22 +++++++++++++++++-- renderers/web_core/package.json | 5 +++++ .../src/v0_9/state/data-model.test.ts | 8 +++---- 3 files changed, 29 insertions(+), 6 deletions(-) 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 index e07d1488..0a15ff79 100644 --- a/renderers/web_core/src/v0_9/state/data-model.test.ts +++ b/renderers/web_core/src/v0_9/state/data-model.test.ts @@ -127,7 +127,7 @@ describe('DataModel', () => { assert.strictEqual(updatedValue, 2); }); - it('notifies subscribers on exact match', (_, done) => { + it('notifies subscribers on exact match', (_: any, done: (result?: any) => void) => { const sub = model.subscribe('/user/name'); sub.onChange = (val) => { assert.strictEqual(val, 'Charlie'); @@ -136,7 +136,7 @@ describe('DataModel', () => { model.set('/user/name', 'Charlie'); }); - it('notifies ancestor subscribers (Container Semantics)', (_, done) => { + 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'); @@ -145,7 +145,7 @@ describe('DataModel', () => { model.set('/user/name', 'Dave'); }); - it('notifies descendant subscribers', (_, done) => { + it('notifies descendant subscribers', (_: any, done: (result?: any) => void) => { const sub = model.subscribe('/user/settings/theme'); sub.onChange = (val) => { assert.strictEqual(val, 'light'); @@ -156,7 +156,7 @@ describe('DataModel', () => { model.set('/user/settings', { theme: 'light' }); }); - it('notifies root subscriber', (_, done) => { + it('notifies root subscriber', (_: any, done: (result?: any) => void) => { const sub = model.subscribe('/'); sub.onChange = (val: any) => { assert.strictEqual(val.newProp, 'test'); From 1e2e6783c6ebd7e88f8fa8a996173fd339bbf3ba Mon Sep 17 00:00:00 2001 From: Jacob Simionato Date: Mon, 9 Feb 2026 12:55:54 +1030 Subject: [PATCH 4/8] fix: Address PR feedback --- .../schemas/standard_catalog_definition.json | 134 ++++++++++++++---- .../web_core/src/v0_9/state/data-model.ts | 9 +- specification/v0_9/docs/a2ui_protocol.md | 2 +- 3 files changed, 112 insertions(+), 33 deletions(-) diff --git a/renderers/web_core/src/v0_8/schemas/standard_catalog_definition.json b/renderers/web_core/src/v0_8/schemas/standard_catalog_definition.json index 5a662cf1..fa6fc228 100644 --- a/renderers/web_core/src/v0_8/schemas/standard_catalog_definition.json +++ b/renderers/web_core/src/v0_8/schemas/standard_catalog_definition.json @@ -31,7 +31,9 @@ ] } }, - "required": ["text"] + "required": [ + "text" + ] }, "Image": { "type": "object", @@ -74,7 +76,9 @@ ] } }, - "required": ["url"] + "required": [ + "url" + ] }, "Icon": { "type": "object", @@ -144,7 +148,9 @@ } } }, - "required": ["name"] + "required": [ + "name" + ] }, "Video": { "type": "object", @@ -164,7 +170,9 @@ } } }, - "required": ["url"] + "required": [ + "url" + ] }, "AudioPlayer": { "type": "object", @@ -197,7 +205,9 @@ } } }, - "required": ["url"] + "required": [ + "url" + ] }, "Row": { "type": "object", @@ -226,7 +236,10 @@ "type": "string" } }, - "required": ["componentId", "dataBinding"] + "required": [ + "componentId", + "dataBinding" + ] } } }, @@ -245,10 +258,17 @@ "alignment": { "type": "string", "description": "Defines the alignment of children along the cross axis (vertically). This corresponds to the CSS 'align-items' property.", - "enum": ["start", "center", "end", "stretch"] + "enum": [ + "start", + "center", + "end", + "stretch" + ] } }, - "required": ["children"] + "required": [ + "children" + ] }, "Column": { "type": "object", @@ -277,7 +297,10 @@ "type": "string" } }, - "required": ["componentId", "dataBinding"] + "required": [ + "componentId", + "dataBinding" + ] } } }, @@ -296,10 +319,17 @@ "alignment": { "type": "string", "description": "Defines the alignment of children along the cross axis (horizontally). This corresponds to the CSS 'align-items' property.", - "enum": ["center", "end", "start", "stretch"] + "enum": [ + "center", + "end", + "start", + "stretch" + ] } }, - "required": ["children"] + "required": [ + "children" + ] }, "List": { "type": "object", @@ -328,22 +358,35 @@ "type": "string" } }, - "required": ["componentId", "dataBinding"] + "required": [ + "componentId", + "dataBinding" + ] } } }, "direction": { "type": "string", "description": "The direction in which the list items are laid out.", - "enum": ["vertical", "horizontal"] + "enum": [ + "vertical", + "horizontal" + ] }, "alignment": { "type": "string", "description": "Defines the alignment of children along the cross axis.", - "enum": ["start", "center", "end", "stretch"] + "enum": [ + "start", + "center", + "end", + "stretch" + ] } }, - "required": ["children"] + "required": [ + "children" + ] }, "Card": { "type": "object", @@ -354,7 +397,9 @@ "description": "The ID of the component to be rendered inside the card." } }, - "required": ["child"] + "required": [ + "child" + ] }, "Tabs": { "type": "object", @@ -384,11 +429,16 @@ "type": "string" } }, - "required": ["title", "child"] + "required": [ + "title", + "child" + ] } } }, - "required": ["tabItems"] + "required": [ + "tabItems" + ] }, "Divider": { "type": "object", @@ -397,7 +447,10 @@ "axis": { "type": "string", "description": "The orientation of the divider.", - "enum": ["horizontal", "vertical"] + "enum": [ + "horizontal", + "vertical" + ] } } }, @@ -414,7 +467,10 @@ "description": "The ID of the component to be displayed inside the modal." } }, - "required": ["entryPointChild", "contentChild"] + "required": [ + "entryPointChild", + "contentChild" + ] }, "Button": { "type": "object", @@ -465,14 +521,22 @@ } } }, - "required": ["key", "value"] + "required": [ + "key", + "value" + ] } } }, - "required": ["name"] + "required": [ + "name" + ] } }, - "required": ["child", "action"] + "required": [ + "child", + "action" + ] }, "CheckBox": { "type": "object", @@ -505,7 +569,10 @@ } } }, - "required": ["label", "value"] + "required": [ + "label", + "value" + ] }, "TextField": { "type": "object", @@ -553,7 +620,9 @@ "description": "A regular expression used for client-side validation of the input." } }, - "required": ["label"] + "required": [ + "label" + ] }, "DateTimeInput": { "type": "object", @@ -581,7 +650,9 @@ "description": "If true, allows the user to select a time." } }, - "required": ["value"] + "required": [ + "value" + ] }, "MultipleChoice": { "type": "object", @@ -632,7 +703,10 @@ "description": "The value to be associated with this option when selected." } }, - "required": ["label", "value"] + "required": [ + "label", + "value" + ] } }, "maxAllowedSelections": { @@ -650,7 +724,7 @@ "filterable": { "type": "boolean", "description": "If true, displays a search input to filter the options." - }, + } } }, "Slider": { @@ -679,7 +753,9 @@ "description": "The maximum value of the slider." } }, - "required": ["value"] + "required": [ + "value" + ] } }, "styles": { 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 94b8d134..7c4ee101 100644 --- a/renderers/web_core/src/v0_9/state/data-model.ts +++ b/renderers/web_core/src/v0_9/state/data-model.ts @@ -21,12 +21,12 @@ export interface Subscription { /** * The current value at the subscribed path. */ - readonly value: T; + readonly value: T | undefined; /** * A callback function to be invoked when the value changes. */ - onChange?: (value: T) => void; + onChange?: (value: T | undefined) => void; /** * Unsubscribes from the data model. @@ -49,6 +49,10 @@ export class DataModel { /** * 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 === '') { @@ -158,7 +162,6 @@ export class DataModel { while (parentPath !== '/' && parentPath !== '') { parentPath = parentPath.substring(0, parentPath.lastIndexOf('/')) || '/'; this.notify(parentPath); - if (parentPath === '/') break; } // Notify Descendants diff --git a/specification/v0_9/docs/a2ui_protocol.md b/specification/v0_9/docs/a2ui_protocol.md index 910be0e4..511306f0 100644 --- a/specification/v0_9/docs/a2ui_protocol.md +++ b/specification/v0_9/docs/a2ui_protocol.md @@ -519,7 +519,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. From 2fc9919eb60a5d01eea2f5686dccf3367a6e9d32 Mon Sep 17 00:00:00 2001 From: Jacob Simionato Date: Mon, 9 Feb 2026 13:01:12 +1030 Subject: [PATCH 5/8] Revert standard catalog change --- .../schemas/standard_catalog_definition.json | 134 ++++-------------- 1 file changed, 29 insertions(+), 105 deletions(-) diff --git a/renderers/web_core/src/v0_8/schemas/standard_catalog_definition.json b/renderers/web_core/src/v0_8/schemas/standard_catalog_definition.json index fa6fc228..5a662cf1 100644 --- a/renderers/web_core/src/v0_8/schemas/standard_catalog_definition.json +++ b/renderers/web_core/src/v0_8/schemas/standard_catalog_definition.json @@ -31,9 +31,7 @@ ] } }, - "required": [ - "text" - ] + "required": ["text"] }, "Image": { "type": "object", @@ -76,9 +74,7 @@ ] } }, - "required": [ - "url" - ] + "required": ["url"] }, "Icon": { "type": "object", @@ -148,9 +144,7 @@ } } }, - "required": [ - "name" - ] + "required": ["name"] }, "Video": { "type": "object", @@ -170,9 +164,7 @@ } } }, - "required": [ - "url" - ] + "required": ["url"] }, "AudioPlayer": { "type": "object", @@ -205,9 +197,7 @@ } } }, - "required": [ - "url" - ] + "required": ["url"] }, "Row": { "type": "object", @@ -236,10 +226,7 @@ "type": "string" } }, - "required": [ - "componentId", - "dataBinding" - ] + "required": ["componentId", "dataBinding"] } } }, @@ -258,17 +245,10 @@ "alignment": { "type": "string", "description": "Defines the alignment of children along the cross axis (vertically). This corresponds to the CSS 'align-items' property.", - "enum": [ - "start", - "center", - "end", - "stretch" - ] + "enum": ["start", "center", "end", "stretch"] } }, - "required": [ - "children" - ] + "required": ["children"] }, "Column": { "type": "object", @@ -297,10 +277,7 @@ "type": "string" } }, - "required": [ - "componentId", - "dataBinding" - ] + "required": ["componentId", "dataBinding"] } } }, @@ -319,17 +296,10 @@ "alignment": { "type": "string", "description": "Defines the alignment of children along the cross axis (horizontally). This corresponds to the CSS 'align-items' property.", - "enum": [ - "center", - "end", - "start", - "stretch" - ] + "enum": ["center", "end", "start", "stretch"] } }, - "required": [ - "children" - ] + "required": ["children"] }, "List": { "type": "object", @@ -358,35 +328,22 @@ "type": "string" } }, - "required": [ - "componentId", - "dataBinding" - ] + "required": ["componentId", "dataBinding"] } } }, "direction": { "type": "string", "description": "The direction in which the list items are laid out.", - "enum": [ - "vertical", - "horizontal" - ] + "enum": ["vertical", "horizontal"] }, "alignment": { "type": "string", "description": "Defines the alignment of children along the cross axis.", - "enum": [ - "start", - "center", - "end", - "stretch" - ] + "enum": ["start", "center", "end", "stretch"] } }, - "required": [ - "children" - ] + "required": ["children"] }, "Card": { "type": "object", @@ -397,9 +354,7 @@ "description": "The ID of the component to be rendered inside the card." } }, - "required": [ - "child" - ] + "required": ["child"] }, "Tabs": { "type": "object", @@ -429,16 +384,11 @@ "type": "string" } }, - "required": [ - "title", - "child" - ] + "required": ["title", "child"] } } }, - "required": [ - "tabItems" - ] + "required": ["tabItems"] }, "Divider": { "type": "object", @@ -447,10 +397,7 @@ "axis": { "type": "string", "description": "The orientation of the divider.", - "enum": [ - "horizontal", - "vertical" - ] + "enum": ["horizontal", "vertical"] } } }, @@ -467,10 +414,7 @@ "description": "The ID of the component to be displayed inside the modal." } }, - "required": [ - "entryPointChild", - "contentChild" - ] + "required": ["entryPointChild", "contentChild"] }, "Button": { "type": "object", @@ -521,22 +465,14 @@ } } }, - "required": [ - "key", - "value" - ] + "required": ["key", "value"] } } }, - "required": [ - "name" - ] + "required": ["name"] } }, - "required": [ - "child", - "action" - ] + "required": ["child", "action"] }, "CheckBox": { "type": "object", @@ -569,10 +505,7 @@ } } }, - "required": [ - "label", - "value" - ] + "required": ["label", "value"] }, "TextField": { "type": "object", @@ -620,9 +553,7 @@ "description": "A regular expression used for client-side validation of the input." } }, - "required": [ - "label" - ] + "required": ["label"] }, "DateTimeInput": { "type": "object", @@ -650,9 +581,7 @@ "description": "If true, allows the user to select a time." } }, - "required": [ - "value" - ] + "required": ["value"] }, "MultipleChoice": { "type": "object", @@ -703,10 +632,7 @@ "description": "The value to be associated with this option when selected." } }, - "required": [ - "label", - "value" - ] + "required": ["label", "value"] } }, "maxAllowedSelections": { @@ -724,7 +650,7 @@ "filterable": { "type": "boolean", "description": "If true, displays a search input to filter the options." - } + }, } }, "Slider": { @@ -753,9 +679,7 @@ "description": "The maximum value of the slider." } }, - "required": [ - "value" - ] + "required": ["value"] } }, "styles": { From 758aa1a3e4fa6a31b7a9c68e842f86d42eae329c Mon Sep 17 00:00:00 2001 From: Jacob Simionato Date: Mon, 9 Feb 2026 13:07:59 +1030 Subject: [PATCH 6/8] fix: Address PR feedback --- .../schemas/standard_catalog_definition.json | 134 ++++++++++++++---- .../src/v0_9/state/data-model.test.ts | 9 ++ .../web_core/src/v0_9/state/data-model.ts | 7 + 3 files changed, 121 insertions(+), 29 deletions(-) diff --git a/renderers/web_core/src/v0_8/schemas/standard_catalog_definition.json b/renderers/web_core/src/v0_8/schemas/standard_catalog_definition.json index 5a662cf1..fa6fc228 100644 --- a/renderers/web_core/src/v0_8/schemas/standard_catalog_definition.json +++ b/renderers/web_core/src/v0_8/schemas/standard_catalog_definition.json @@ -31,7 +31,9 @@ ] } }, - "required": ["text"] + "required": [ + "text" + ] }, "Image": { "type": "object", @@ -74,7 +76,9 @@ ] } }, - "required": ["url"] + "required": [ + "url" + ] }, "Icon": { "type": "object", @@ -144,7 +148,9 @@ } } }, - "required": ["name"] + "required": [ + "name" + ] }, "Video": { "type": "object", @@ -164,7 +170,9 @@ } } }, - "required": ["url"] + "required": [ + "url" + ] }, "AudioPlayer": { "type": "object", @@ -197,7 +205,9 @@ } } }, - "required": ["url"] + "required": [ + "url" + ] }, "Row": { "type": "object", @@ -226,7 +236,10 @@ "type": "string" } }, - "required": ["componentId", "dataBinding"] + "required": [ + "componentId", + "dataBinding" + ] } } }, @@ -245,10 +258,17 @@ "alignment": { "type": "string", "description": "Defines the alignment of children along the cross axis (vertically). This corresponds to the CSS 'align-items' property.", - "enum": ["start", "center", "end", "stretch"] + "enum": [ + "start", + "center", + "end", + "stretch" + ] } }, - "required": ["children"] + "required": [ + "children" + ] }, "Column": { "type": "object", @@ -277,7 +297,10 @@ "type": "string" } }, - "required": ["componentId", "dataBinding"] + "required": [ + "componentId", + "dataBinding" + ] } } }, @@ -296,10 +319,17 @@ "alignment": { "type": "string", "description": "Defines the alignment of children along the cross axis (horizontally). This corresponds to the CSS 'align-items' property.", - "enum": ["center", "end", "start", "stretch"] + "enum": [ + "center", + "end", + "start", + "stretch" + ] } }, - "required": ["children"] + "required": [ + "children" + ] }, "List": { "type": "object", @@ -328,22 +358,35 @@ "type": "string" } }, - "required": ["componentId", "dataBinding"] + "required": [ + "componentId", + "dataBinding" + ] } } }, "direction": { "type": "string", "description": "The direction in which the list items are laid out.", - "enum": ["vertical", "horizontal"] + "enum": [ + "vertical", + "horizontal" + ] }, "alignment": { "type": "string", "description": "Defines the alignment of children along the cross axis.", - "enum": ["start", "center", "end", "stretch"] + "enum": [ + "start", + "center", + "end", + "stretch" + ] } }, - "required": ["children"] + "required": [ + "children" + ] }, "Card": { "type": "object", @@ -354,7 +397,9 @@ "description": "The ID of the component to be rendered inside the card." } }, - "required": ["child"] + "required": [ + "child" + ] }, "Tabs": { "type": "object", @@ -384,11 +429,16 @@ "type": "string" } }, - "required": ["title", "child"] + "required": [ + "title", + "child" + ] } } }, - "required": ["tabItems"] + "required": [ + "tabItems" + ] }, "Divider": { "type": "object", @@ -397,7 +447,10 @@ "axis": { "type": "string", "description": "The orientation of the divider.", - "enum": ["horizontal", "vertical"] + "enum": [ + "horizontal", + "vertical" + ] } } }, @@ -414,7 +467,10 @@ "description": "The ID of the component to be displayed inside the modal." } }, - "required": ["entryPointChild", "contentChild"] + "required": [ + "entryPointChild", + "contentChild" + ] }, "Button": { "type": "object", @@ -465,14 +521,22 @@ } } }, - "required": ["key", "value"] + "required": [ + "key", + "value" + ] } } }, - "required": ["name"] + "required": [ + "name" + ] } }, - "required": ["child", "action"] + "required": [ + "child", + "action" + ] }, "CheckBox": { "type": "object", @@ -505,7 +569,10 @@ } } }, - "required": ["label", "value"] + "required": [ + "label", + "value" + ] }, "TextField": { "type": "object", @@ -553,7 +620,9 @@ "description": "A regular expression used for client-side validation of the input." } }, - "required": ["label"] + "required": [ + "label" + ] }, "DateTimeInput": { "type": "object", @@ -581,7 +650,9 @@ "description": "If true, allows the user to select a time." } }, - "required": ["value"] + "required": [ + "value" + ] }, "MultipleChoice": { "type": "object", @@ -632,7 +703,10 @@ "description": "The value to be associated with this option when selected." } }, - "required": ["label", "value"] + "required": [ + "label", + "value" + ] } }, "maxAllowedSelections": { @@ -650,7 +724,7 @@ "filterable": { "type": "boolean", "description": "If true, displays a search input to filter the options." - }, + } } }, "Slider": { @@ -679,7 +753,9 @@ "description": "The maximum value of the slider." } }, - "required": ["value"] + "required": [ + "value" + ] } }, "styles": { 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 index 0a15ff79..0239c198 100644 --- a/renderers/web_core/src/v0_9/state/data-model.test.ts +++ b/renderers/web_core/src/v0_9/state/data-model.test.ts @@ -185,4 +185,13 @@ describe('DataModel', () => { 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/); + }); }); 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 7c4ee101..34d5aeec 100644 --- a/renderers/web_core/src/v0_9/state/data-model.ts +++ b/renderers/web_core/src/v0_9/state/data-model.ts @@ -67,6 +67,13 @@ export class DataModel { let current = this.data; for (let i = 0; i < segments.length; i++) { const segment = segments[i]; + + // 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) ? [] : {}; From 4d6c1df38917e9d1eb9ab059a9b184d3418937d0 Mon Sep 17 00:00:00 2001 From: Jacob Simionato Date: Mon, 9 Feb 2026 13:23:10 +1030 Subject: [PATCH 7/8] fix: Address PR feedback --- renderers/lit/package-lock.json | 6 +++--- renderers/web_core/src/v0_9/state/data-model.test.ts | 6 ++++++ renderers/web_core/src/v0_9/state/data-model.ts | 8 ++++++++ specification/v0_9/docs/a2ui_protocol.md | 1 + 4 files changed, 18 insertions(+), 3 deletions(-) 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/src/v0_9/state/data-model.test.ts b/renderers/web_core/src/v0_9/state/data-model.test.ts index 0239c198..09e01f85 100644 --- a/renderers/web_core/src/v0_9/state/data-model.test.ts +++ b/renderers/web_core/src/v0_9/state/data-model.test.ts @@ -194,4 +194,10 @@ describe('DataModel', () => { 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 index 34d5aeec..cb4d7073 100644 --- a/renderers/web_core/src/v0_9/state/data-model.ts +++ b/renderers/web_core/src/v0_9/state/data-model.ts @@ -68,6 +68,10 @@ export class DataModel { 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') { @@ -81,6 +85,10 @@ export class DataModel { 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; diff --git a/specification/v0_9/docs/a2ui_protocol.md b/specification/v0_9/docs/a2ui_protocol.md index 511306f0..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. From 89ebdc69f21174478d3d20e5a2bd13d0563e380f Mon Sep 17 00:00:00 2001 From: Jacob Simionato Date: Mon, 9 Feb 2026 13:26:40 +1030 Subject: [PATCH 8/8] Fix standard catalog def --- .../schemas/standard_catalog_definition.json | 134 ++++-------------- 1 file changed, 29 insertions(+), 105 deletions(-) diff --git a/renderers/web_core/src/v0_8/schemas/standard_catalog_definition.json b/renderers/web_core/src/v0_8/schemas/standard_catalog_definition.json index fa6fc228..5a662cf1 100644 --- a/renderers/web_core/src/v0_8/schemas/standard_catalog_definition.json +++ b/renderers/web_core/src/v0_8/schemas/standard_catalog_definition.json @@ -31,9 +31,7 @@ ] } }, - "required": [ - "text" - ] + "required": ["text"] }, "Image": { "type": "object", @@ -76,9 +74,7 @@ ] } }, - "required": [ - "url" - ] + "required": ["url"] }, "Icon": { "type": "object", @@ -148,9 +144,7 @@ } } }, - "required": [ - "name" - ] + "required": ["name"] }, "Video": { "type": "object", @@ -170,9 +164,7 @@ } } }, - "required": [ - "url" - ] + "required": ["url"] }, "AudioPlayer": { "type": "object", @@ -205,9 +197,7 @@ } } }, - "required": [ - "url" - ] + "required": ["url"] }, "Row": { "type": "object", @@ -236,10 +226,7 @@ "type": "string" } }, - "required": [ - "componentId", - "dataBinding" - ] + "required": ["componentId", "dataBinding"] } } }, @@ -258,17 +245,10 @@ "alignment": { "type": "string", "description": "Defines the alignment of children along the cross axis (vertically). This corresponds to the CSS 'align-items' property.", - "enum": [ - "start", - "center", - "end", - "stretch" - ] + "enum": ["start", "center", "end", "stretch"] } }, - "required": [ - "children" - ] + "required": ["children"] }, "Column": { "type": "object", @@ -297,10 +277,7 @@ "type": "string" } }, - "required": [ - "componentId", - "dataBinding" - ] + "required": ["componentId", "dataBinding"] } } }, @@ -319,17 +296,10 @@ "alignment": { "type": "string", "description": "Defines the alignment of children along the cross axis (horizontally). This corresponds to the CSS 'align-items' property.", - "enum": [ - "center", - "end", - "start", - "stretch" - ] + "enum": ["center", "end", "start", "stretch"] } }, - "required": [ - "children" - ] + "required": ["children"] }, "List": { "type": "object", @@ -358,35 +328,22 @@ "type": "string" } }, - "required": [ - "componentId", - "dataBinding" - ] + "required": ["componentId", "dataBinding"] } } }, "direction": { "type": "string", "description": "The direction in which the list items are laid out.", - "enum": [ - "vertical", - "horizontal" - ] + "enum": ["vertical", "horizontal"] }, "alignment": { "type": "string", "description": "Defines the alignment of children along the cross axis.", - "enum": [ - "start", - "center", - "end", - "stretch" - ] + "enum": ["start", "center", "end", "stretch"] } }, - "required": [ - "children" - ] + "required": ["children"] }, "Card": { "type": "object", @@ -397,9 +354,7 @@ "description": "The ID of the component to be rendered inside the card." } }, - "required": [ - "child" - ] + "required": ["child"] }, "Tabs": { "type": "object", @@ -429,16 +384,11 @@ "type": "string" } }, - "required": [ - "title", - "child" - ] + "required": ["title", "child"] } } }, - "required": [ - "tabItems" - ] + "required": ["tabItems"] }, "Divider": { "type": "object", @@ -447,10 +397,7 @@ "axis": { "type": "string", "description": "The orientation of the divider.", - "enum": [ - "horizontal", - "vertical" - ] + "enum": ["horizontal", "vertical"] } } }, @@ -467,10 +414,7 @@ "description": "The ID of the component to be displayed inside the modal." } }, - "required": [ - "entryPointChild", - "contentChild" - ] + "required": ["entryPointChild", "contentChild"] }, "Button": { "type": "object", @@ -521,22 +465,14 @@ } } }, - "required": [ - "key", - "value" - ] + "required": ["key", "value"] } } }, - "required": [ - "name" - ] + "required": ["name"] } }, - "required": [ - "child", - "action" - ] + "required": ["child", "action"] }, "CheckBox": { "type": "object", @@ -569,10 +505,7 @@ } } }, - "required": [ - "label", - "value" - ] + "required": ["label", "value"] }, "TextField": { "type": "object", @@ -620,9 +553,7 @@ "description": "A regular expression used for client-side validation of the input." } }, - "required": [ - "label" - ] + "required": ["label"] }, "DateTimeInput": { "type": "object", @@ -650,9 +581,7 @@ "description": "If true, allows the user to select a time." } }, - "required": [ - "value" - ] + "required": ["value"] }, "MultipleChoice": { "type": "object", @@ -703,10 +632,7 @@ "description": "The value to be associated with this option when selected." } }, - "required": [ - "label", - "value" - ] + "required": ["label", "value"] } }, "maxAllowedSelections": { @@ -724,7 +650,7 @@ "filterable": { "type": "boolean", "description": "If true, displays a search input to filter the options." - } + }, } }, "Slider": { @@ -753,9 +679,7 @@ "description": "The maximum value of the slider." } }, - "required": [ - "value" - ] + "required": ["value"] } }, "styles": {