diff --git a/renderers/react/src/v0_9/A2uiNodeSurface.tsx b/renderers/react/src/v0_9/A2uiNodeSurface.tsx new file mode 100644 index 0000000000..be5bde3107 --- /dev/null +++ b/renderers/react/src/v0_9/A2uiNodeSurface.tsx @@ -0,0 +1,210 @@ +/** + * Copyright 2026 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 + * + * http://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. + */ + +/** + * Experimental surface renderer driven by the node layer. + * + * Where `A2uiSurface` walks component ids and lets each component's wrapper + * run its own `GenericBinder`, this surface constructs one `NodeResolver` and + * renders the resolved `ComponentNode` tree it maintains. Each node's view + * subscribes to that node's props signal only, so a data change re-renders + * exactly the affected component. + * + * Views are reused unchanged: node props are converted back to the shapes the + * existing view contract expects (child ids as strings, template children as + * `{id, basePath}` pairs) and `buildChild` maps those refs back to their live + * nodes. A ref the node layer did not resolve (a single-child id whose schema + * is not declared with `componentReference()`) falls back to `DeferredChild` + * recursion over the raw definitions, so catalogs can migrate incrementally. + */ + +import React, {memo, useCallback, useMemo, useSyncExternalStore} from 'react'; +import { + ComponentContext, + ComponentNode, + NodeResolver, + effect, + getValue, + peekValue, + type NodeProps, + type Signal, + type SurfaceModel, +} from '@a2ui/web_core/v0_9'; +import type {ReactComponentImplementation} from './adapter'; +import {DeferredChild} from './A2uiSurface'; + +function useSignalValue(signal: Signal): T { + const subscribe = useCallback( + (onChange: () => void) => + effect(() => { + getValue(signal); + onChange(); + }), + [signal], + ); + const getSnapshot = useCallback(() => peekValue(signal), [signal]); + return useSyncExternalStore(subscribe, getSnapshot); +} + +/** Child nodes of one view, keyed by componentId and by componentId@dataPath. */ +type ChildIndex = Map; + +function registerChild(index: ChildIndex, child: ComponentNode): void { + index.set(`${child.componentId}@${child.dataPath}`, child); + if (!index.has(child.componentId)) { + index.set(child.componentId, child); + } +} + +/** + * Converts node-resolved props to the shapes existing views were written + * against: a child node becomes its componentId string when it shares the + * parent's data scope, and an `{id, basePath}` pair when it was spawned at a + * scoped path (a template item). The nodes themselves are collected into + * `index` for `buildChild` to find again. + */ +function toViewValue(parent: ComponentNode, value: unknown, index: ChildIndex): unknown { + if (value instanceof ComponentNode) { + registerChild(index, value); + if (value.dataPath !== parent.dataPath) { + return {id: value.componentId, basePath: value.dataPath}; + } + return value.componentId; + } + if (Array.isArray(value)) { + return value.map(item => toViewValue(parent, item, index)); + } + if (value && typeof value === 'object') { + const result: Record = {}; + for (const [key, inner] of Object.entries(value)) { + result[key] = toViewValue(parent, inner, index); + } + return result; + } + return value; +} + +const NodeView = memo( + ({surface, node}: {surface: SurfaceModel; node: ComponentNode}) => { + const resolved = useSignalValue(node.props); + + const {viewProps, childIndex} = useMemo(() => { + const index: ChildIndex = new Map(); + const converted: NodeProps = {}; + for (const [key, value] of Object.entries(resolved)) { + converted[key] = toViewValue(node, value, index); + } + return {viewProps: converted, childIndex: index}; + }, [node, resolved]); + + const context = useMemo( + () => + node.isPlaceholder + ? undefined + : new ComponentContext(surface, node.componentId, node.dataPath), + [surface, node], + ); + + const buildChild = useCallback( + (child: string | ComponentNode, basePath?: string): React.ReactNode => { + const childNode = + child instanceof ComponentNode + ? child + : (childIndex.get(basePath ? `${child}@${basePath}` : child) ?? + childIndex.get(`${child}@${node.dataPath}`)); + if (childNode) { + return ; + } + if (typeof child === 'string') { + // Not resolved by the node layer; recurse over the raw definitions. + return ( + + ); + } + return null; + }, + [surface, childIndex, node], + ); + + if (node.isPlaceholder) { + return
[Loading {node.componentId}...]
; + } + const impl = surface.catalog.components.get(node.type); + if (!impl) { + return
Unknown component: {node.type}
; + } + const View = impl.view; + if (!View) { + // Binderless implementation: no unwrapped view exists, so render the + // wrapper, which binds from the context itself. + const Render = impl.render; + return ; + } + return ; + }, +); +NodeView.displayName = 'NodeView'; + +/** + * Drop-in alternative to `A2uiSurface` rendering from a `NodeResolver`. + */ +export const A2uiNodeSurface: React.FC<{ + surface: SurfaceModel; +}> = ({surface}) => { + // The resolver is created inside subscribe, which React calls only for + // committed renders: a render that is discarded (concurrent mode, + // Suspense) never constructs one, and every constructed resolver is + // disposed by its own unsubscribe. StrictMode's double mount simply + // creates and disposes two in turn. + const box = useMemo( + () => ({resolver: undefined as NodeResolver | undefined}), + [surface], + ); + const subscribe = useCallback( + (onChange: () => void) => { + const resolver = new NodeResolver(surface, surface.catalog); + box.resolver = resolver; + onChange(); + const stopEffect = effect(() => { + getValue(resolver.rootNode); + onChange(); + }); + return () => { + stopEffect(); + resolver.dispose(); + if (box.resolver === resolver) { + box.resolver = undefined; + } + }; + }, + [surface, box], + ); + const getSnapshot = useCallback( + () => (box.resolver ? peekValue(box.resolver.rootNode) : undefined), + [box], + ); + const root = useSyncExternalStore(subscribe, getSnapshot); + + if (!root) { + return
[Loading root...]
; + } + return ; +}; diff --git a/renderers/react/src/v0_9/adapter.tsx b/renderers/react/src/v0_9/adapter.tsx index bd38a081f3..7427c15c59 100644 --- a/renderers/react/src/v0_9/adapter.tsx +++ b/renderers/react/src/v0_9/adapter.tsx @@ -28,6 +28,13 @@ export interface ReactComponentImplementation extends ComponentApi { context: ComponentContext; buildChild: (id: string, basePath?: string) => React.ReactNode; }>; + /** + * The unwrapped view function `render` wraps. `A2uiNodeSurface` renders it + * directly with props resolved by the node layer, so the component works + * without `render`'s own binder. Absent on binderless implementations, + * which the node surface falls back to rendering through `render`. + */ + view?: React.FC>; } export type ReactA2uiComponentProps = { @@ -98,6 +105,7 @@ export function createComponentImplementation( name: api.name, schema: api.schema, render: ReactWrapper, + view: RenderComponent as React.FC>, }; } diff --git a/renderers/react/src/v0_9/index.ts b/renderers/react/src/v0_9/index.ts index 31c37ce9fa..8ab7abc6b5 100644 --- a/renderers/react/src/v0_9/index.ts +++ b/renderers/react/src/v0_9/index.ts @@ -15,6 +15,7 @@ */ export * from './A2uiSurface'; +export * from './A2uiNodeSurface'; export * from './adapter'; // Export basic catalog components directly for 3P developers diff --git a/renderers/react/tests/v0_9/node-surface.test.tsx b/renderers/react/tests/v0_9/node-surface.test.tsx new file mode 100644 index 0000000000..fadc038e71 --- /dev/null +++ b/renderers/react/tests/v0_9/node-surface.test.tsx @@ -0,0 +1,300 @@ +/** + * Copyright 2026 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 + * + * http://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. + */ + +import {describe, it, expect, beforeEach} from 'vitest'; +import React from 'react'; +import {render, screen, act, fireEvent} from '@testing-library/react'; +import {z} from 'zod'; +import { + Catalog, + CommonSchemas, + ComponentModel, + SurfaceModel, + componentReference, + componentReferenceList, + type A2uiClientAction, +} from '@a2ui/web_core/v0_9'; +import { + createComponentImplementation, + type ReactComponentImplementation, +} from '../../src/v0_9/adapter'; +import {A2uiNodeSurface} from '../../src/v0_9/A2uiNodeSurface'; +import {basicCatalog} from '../../src/v0_9/catalog/basic'; + +/** View render counts, keyed per component instance. */ +const renders = new Map(); +function bump(key: string): void { + renders.set(key, (renders.get(key) ?? 0) + 1); +} +function rendersOf(key: string): number { + return renders.get(key) ?? 0; +} + +const TextImpl = createComponentImplementation( + {name: 'Text', schema: z.object({text: CommonSchemas.DynamicString.optional()})}, + ({props, context}) => { + bump(`Text:${context.componentModel.id}@${context.dataContext.path}`); + return {String(props.text ?? '')}; + }, +); + +const ColumnImpl = createComponentImplementation( + {name: 'Column', schema: z.object({children: componentReferenceList().optional()})}, + ({props, buildChild, context}) => { + bump(`Column:${context.componentModel.id}`); + const children = props.children as Array | undefined; + return ( +
+ {Array.isArray(children) + ? children.map((ref, index) => + typeof ref === 'string' ? ( + {buildChild(ref)} + ) : ( + + {buildChild(ref.id, ref.basePath)} + + ), + ) + : null} +
+ ); + }, +); + +const CardImpl = createComponentImplementation( + {name: 'Card', schema: z.object({child: componentReference().optional()})}, + ({props, buildChild, context}) => { + bump(`Card:${context.componentModel.id}`); + return
{props.child ? buildChild(props.child as string) : null}
; + }, +); + +const TextFieldImpl = createComponentImplementation( + {name: 'TextField', schema: z.object({value: CommonSchemas.DynamicString.optional()})}, + ({props, context}) => { + bump(`TextField:${context.componentModel.id}@${context.dataContext.path}`); + const setValue = (props as {setValue?: (value: string) => void}).setValue; + return ( + setValue?.(event.target.value)} + /> + ); + }, +); + +const ButtonImpl = createComponentImplementation( + {name: 'Button', schema: z.object({action: CommonSchemas.Action.optional()})}, + ({props, context}) => { + bump(`Button:${context.componentModel.id}`); + return ( + + ); + }, +); + +function setup() { + const catalog = new Catalog('node-react-test', [ + TextImpl, + ColumnImpl, + CardImpl, + ButtonImpl, + TextFieldImpl, + ]); + const surface = new SurfaceModel('surf-1', catalog); + return surface; +} + +function add(surface: SurfaceModel, id: string, type: string, props: Record) { + surface.componentsModel.addComponent(new ComponentModel(id, type, props)); +} + +beforeEach(() => { + renders.clear(); +}); + +describe('A2uiNodeSurface', () => { + it('renders a resolved tree end to end', () => { + const surface = setup(); + add(surface, 'root', 'Column', {children: ['greeting', 'card1']}); + add(surface, 'greeting', 'Text', {text: 'Hello'}); + add(surface, 'card1', 'Card', {child: 'inner'}); + add(surface, 'inner', 'Text', {text: 'Inner'}); + + render(); + + expect(screen.getByText('Hello')).toBeDefined(); + expect(screen.getByText('Inner')).toBeDefined(); + }); + + it('re-renders only the component whose data changed', () => { + const surface = setup(); + surface.dataModel.set('/username', 'Alice'); + add(surface, 'root', 'Column', {children: ['static', 'bound']}); + add(surface, 'static', 'Text', {text: 'Static'}); + add(surface, 'bound', 'Text', {text: {path: '/username'}}); + + render(); + expect(screen.getByText('Alice')).toBeDefined(); + + const columnBefore = rendersOf('Column:root'); + const staticBefore = rendersOf('Text:static@/'); + const boundBefore = rendersOf('Text:bound@/'); + + act(() => { + surface.dataModel.set('/username', 'Bob'); + }); + + expect(screen.getByText('Bob')).toBeDefined(); + expect(rendersOf('Text:bound@/')).toBe(boundBefore + 1); + expect(rendersOf('Text:static@/')).toBe(staticBefore); + expect(rendersOf('Column:root')).toBe(columnBefore); + }); + + it('shows a placeholder for a missing component and upgrades it in place', () => { + const surface = setup(); + add(surface, 'root', 'Column', {children: ['late']}); + + render(); + expect(screen.getByText(/Loading late/)).toBeDefined(); + + act(() => { + add(surface, 'late', 'Text', {text: 'Arrived'}); + }); + + expect(screen.queryByText(/Loading late/)).toBeNull(); + expect(screen.getByText('Arrived')).toBeDefined(); + }); + + it('renders template children and keeps existing items mounted on append', () => { + const surface = setup(); + surface.dataModel.set('/items', [{name: 'A'}, {name: 'B'}]); + add(surface, 'root', 'Column', {children: {componentId: 'item', path: '/items'}}); + add(surface, 'item', 'Text', {text: {path: 'name'}}); + + render(); + expect(screen.getByText('A')).toBeDefined(); + expect(screen.getByText('B')).toBeDefined(); + + const item0Before = rendersOf('Text:item@/items/0'); + const item1Before = rendersOf('Text:item@/items/1'); + + act(() => { + surface.dataModel.set('/items', [{name: 'A'}, {name: 'B'}, {name: 'C'}]); + }); + + expect(screen.getByText('C')).toBeDefined(); + expect(rendersOf('Text:item@/items/0')).toBe(item0Before); + expect(rendersOf('Text:item@/items/1')).toBe(item1Before); + }); + + it('dispatches actions with context resolved at click time', async () => { + const surface = setup(); + const actions: A2uiClientAction[] = []; + surface.onAction.subscribe(action => { + actions.push(action); + }); + surface.dataModel.set('/current_id', 'stale'); + add(surface, 'root', 'Button', { + action: {event: {name: 'submit', context: {itemId: {path: '/current_id'}}}}, + }); + + render(); + + act(() => { + surface.dataModel.set('/current_id', 'fresh'); + }); + await act(async () => { + fireEvent.click(screen.getByTestId('btn-root')); + }); + + expect(actions).toHaveLength(1); + expect(actions[0]?.name).toBe('submit'); + expect(actions[0]?.context).toEqual({itemId: 'fresh'}); + }); + + it('renders the shipped basic catalog unchanged, via DeferredChild for unmarked refs', () => { + const surface = new SurfaceModel('surf-basic', basicCatalog); + add(surface, 'root', 'Column', {children: ['t1', 'card1']}); + add(surface, 't1', 'Text', {text: 'hello from nodes'}); + add(surface, 'card1', 'Card', {child: 't2'}); + add(surface, 't2', 'Text', {text: 'inside the card'}); + + const {container} = render(); + + expect(container.textContent).toContain('hello from nodes'); + expect(container.textContent).toContain('inside the card'); + }); + + it('input components write scoped values using only synthesized setters', () => { + const surface = setup(); + surface.dataModel.set('/items', [{value: 'a'}, {value: 'b'}]); + add(surface, 'root', 'Column', {children: {componentId: 'field', path: '/items'}}); + add(surface, 'field', 'TextField', {value: {path: 'value'}}); + + render(); + const second = screen.getByTestId('input-/items/1') as HTMLInputElement; + expect(second.value).toBe('b'); + + fireEvent.change(second, {target: {value: 'edited'}}); + + expect(surface.dataModel.get('/items/1/value')).toBe('edited'); + expect((screen.getByTestId('input-/items/1') as HTMLInputElement).value).toBe('edited'); + expect(surface.dataModel.get('/items/0/value')).toBe('a'); + }); + + it('works under StrictMode: renders, updates, and unmounts cleanly', () => { + const surface = setup(); + add(surface, 'root', 'Text', {text: 'strict'}); + + const {unmount} = render( + + + , + ); + expect(screen.getByText('strict')).toBeDefined(); + + const rootModel = surface.componentsModel.get('root'); + expect(rootModel).toBeDefined(); + act(() => { + if (rootModel) { + rootModel.properties = {text: 'updated'}; + } + }); + expect(screen.getByText('updated')).toBeDefined(); + + unmount(); + expect(() => surface.dataModel.set('/x', 1)).not.toThrow(); + }); + + it('unmounts cleanly and later data changes are inert', () => { + const surface = setup(); + surface.dataModel.set('/username', 'Alice'); + add(surface, 'root', 'Text', {text: {path: '/username'}}); + + const {unmount} = render(); + expect(screen.getByText('Alice')).toBeDefined(); + + unmount(); + expect(() => surface.dataModel.set('/username', 'Bob')).not.toThrow(); + }); +}); diff --git a/renderers/web_core/src/v0_9/catalog/types.ts b/renderers/web_core/src/v0_9/catalog/types.ts index d786f715af..7f289b32e2 100644 --- a/renderers/web_core/src/v0_9/catalog/types.ts +++ b/renderers/web_core/src/v0_9/catalog/types.ts @@ -109,18 +109,32 @@ export type InferredComponentApiSchemaType = z.infer { +export declare interface CatalogInterface< + T extends ComponentApi, + F extends FunctionApi = FunctionImplementation, +> { readonly id: string; readonly components: ReadonlyMap; - readonly functions: ReadonlyMap; + readonly functions: ReadonlyMap; readonly themeSchema?: z.ZodObject; readonly invoker: FunctionInvoker; } /** * A collection of available components and functions. + * + * The `F` parameter distinguishes catalogs that carry executable function + * implementations (`FunctionImplementation`, the default and the only kind a + * renderer should hold) from schema-only catalogs (`FunctionApi`), whose + * functions are signatures loaded from catalog JSON with no code attached. + * Consumers that need to execute functions, such as `NodeResolver`, constrain + * `F` to `FunctionImplementation` so a schema-only catalog is rejected at + * compile time rather than resolving values to `undefined` at runtime. */ -export class Catalog implements CatalogInterface { +export class Catalog< + T extends ComponentApi, + F extends FunctionApi = FunctionImplementation, +> implements CatalogInterface { readonly id: string; /** @@ -132,7 +146,7 @@ export class Catalog implements CatalogInterface { /** * Map of functions provided by this catalog. */ - readonly functions: ReadonlyMap; + readonly functions: ReadonlyMap; /** * The schema for theme parameters used by this catalog. @@ -145,12 +159,7 @@ export class Catalog implements CatalogInterface { */ readonly invoker: FunctionInvoker; - constructor( - id: string, - components: T[], - functions: FunctionImplementation[] = [], - themeSchema?: z.ZodObject, - ) { + constructor(id: string, components: T[], functions: F[] = [], themeSchema?: z.ZodObject) { this.id = id; const compMap = new Map(); @@ -159,7 +168,7 @@ export class Catalog implements CatalogInterface { } this.components = compMap; - const funcMap = new Map(); + const funcMap = new Map(); for (const fn of functions) { funcMap.set(fn.name, fn); } @@ -172,11 +181,18 @@ export class Catalog implements CatalogInterface { if (!fn) { throw new A2uiExpressionError(`Function not found in catalog '${this.id}': ${name}`, name); } + const execute = (fn as Partial).execute; + if (typeof execute !== 'function') { + throw new A2uiExpressionError( + `Function '${name}' in catalog '${this.id}' is schema-only and has no implementation.`, + name, + ); + } // Provides runtime safety: Coerces and strips invalid arguments before execute() try { const safeArgs = fn.schema.parse(rawArgs); - return fn.execute(safeArgs, ctx, abortSignal); + return execute.call(fn, safeArgs, ctx, abortSignal); } catch (e: any) { if (e?.name === 'ZodError' || e instanceof z.ZodError) { throw new A2uiExpressionError( diff --git a/renderers/web_core/src/v0_9/index.ts b/renderers/web_core/src/v0_9/index.ts index db2b165fce..f43c9eb2ec 100644 --- a/renderers/web_core/src/v0_9/index.ts +++ b/renderers/web_core/src/v0_9/index.ts @@ -28,6 +28,9 @@ export * from './processing/message-processor.js'; export * from './rendering/component-context.js'; export * from './rendering/data-context.js'; export * from './rendering/generic-binder.js'; +export * from './nodes/component-node.js'; +export * from './nodes/node-resolver.js'; +export * from './nodes/ref-fields.js'; export * from './schema/index.js'; export * from './state/component-model.js'; export * from './state/data-model.js'; diff --git a/renderers/web_core/src/v0_9/nodes/component-node.ts b/renderers/web_core/src/v0_9/nodes/component-node.ts new file mode 100644 index 0000000000..efa324b6d5 --- /dev/null +++ b/renderers/web_core/src/v0_9/nodes/component-node.ts @@ -0,0 +1,185 @@ +/** + * Copyright 2026 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 + * + * http://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. + */ + +import {EventEmitter, EventSource} from '../common/events.js'; +import {Signal, signal, peekValue, setValue} from '../reactivity/signals.js'; + +/** The `type` of a node whose component definition has not arrived yet. */ +export const PLACEHOLDER_TYPE = 'Placeholder'; + +/** Resolved node properties, keyed by the component's schema property names. */ +export type NodeProps = Record; + +/** + * One resolved component instance in the rendered tree. + * + * A node's `props` hold fully resolved values: primitives for dynamic values, + * ready-to-call `() => void` closures for actions, and live `ComponentNode` + * references (or arrays of them) for child properties. + * + * Emission contract: `props` emits when this node's own resolved properties + * change, including when a child *reference* is replaced (a placeholder + * upgrade, a deletion, a list change). It does not emit when a child's + * internal properties change; subscribe to the child's `props` for that. + */ +export class ComponentNode { + /** + * Identifier for this node in the rendered tree. The bare component id at + * the root data scope; for template-spawned items the scoped data path is + * appended (e.g. `item-card-[/items/0]`) so sibling keys are distinct. + * + * Until the spec provides data-derived child keys (a2ui#1745), this id + * names a list position, not a data item: it is not stable across array + * insertions or reorders. + */ + readonly instanceId: string; + /** The component id from the payload. */ + readonly componentId: string; + /** The catalog component type, or `'Placeholder'`. */ + readonly type: string; + /** The data model scope this node resolves against, e.g. `/items/0`. */ + readonly dataPath: string; + /** Resolved, reactive properties. Read with `getValue`/`peekValue`. */ + readonly props: Signal; + + private readonly _onDestroyed = new EventEmitter(); + /** Fires exactly once, when this node is disposed. */ + readonly onDestroyed: EventSource = this._onDestroyed; + + private cleanups: Array<() => void> = []; + private _disposed = false; + + constructor( + instanceId: string, + componentId: string, + type: string, + dataPath: string, + initialProps: TProps, + ) { + this.instanceId = instanceId; + this.componentId = componentId; + this.type = type; + this.dataPath = dataPath; + this.props = signal(initialProps); + } + + get disposed(): boolean { + return this._disposed; + } + + get isPlaceholder(): boolean { + return this.type === PLACEHOLDER_TYPE; + } + + /** Registers teardown work to run when this node is disposed. */ + addCleanup(cleanup: () => void): void { + this.cleanups.push(cleanup); + } + + /** + * Replaces the resolved props, emitting only if a shallow comparison shows + * a change. Callers keep unchanged values reference-identical (child nodes + * come from the resolver's cache; untouched arrays keep their identity), so + * shallow comparison is exact rather than heuristic. + */ + setProps(next: TProps): void { + if (this._disposed) { + return; + } + const previous = peekValue(this.props); + if (!shallowEqual(previous, next)) { + setValue(this.props, next); + } + } + + /** + * Tears down this node: runs registered cleanups, then fires `onDestroyed`. + * Idempotent. + */ + dispose(): void { + if (this._disposed) { + return; + } + this._disposed = true; + for (const cleanup of this.cleanups) { + try { + cleanup(); + } catch (e) { + console.error(`ComponentNode cleanup error (${this.instanceId}):`, e); + } + } + this.cleanups = []; + this._onDestroyed.emit(); + this._onDestroyed.dispose(); + } + + /** + * Serializes the resolved tree for debugging and headless assertions. + * Child nodes serialize recursively; action closures serialize as the + * string `''`. + */ + toJSON(): Record { + if (this.isPlaceholder) { + return {id: this.componentId, type: PLACEHOLDER_TYPE}; + } + const serialized: Record = { + id: this.componentId, + type: this.type, + }; + const props = peekValue(this.props); + for (const [key, value] of Object.entries(props)) { + serialized[key] = serializeValue(value); + } + return serialized; + } +} + +function serializeValue(value: unknown): unknown { + if (value instanceof ComponentNode) { + return value.toJSON(); + } + if (typeof value === 'function') { + return ''; + } + if (Array.isArray(value)) { + return value.map(serializeValue); + } + if (value && typeof value === 'object') { + const result: Record = {}; + for (const [key, inner] of Object.entries(value)) { + result[key] = serializeValue(inner); + } + return result; + } + return value; +} + +function shallowEqual(a: NodeProps, b: NodeProps): boolean { + if (Object.is(a, b)) { + return true; + } + const aKeys = Object.keys(a); + const bKeys = Object.keys(b); + if (aKeys.length !== bKeys.length) { + return false; + } + for (const key of aKeys) { + if (!Object.is(a[key], b[key])) { + return false; + } + } + return true; +} diff --git a/renderers/web_core/src/v0_9/nodes/node-resolver.test.ts b/renderers/web_core/src/v0_9/nodes/node-resolver.test.ts new file mode 100644 index 0000000000..05b6186612 --- /dev/null +++ b/renderers/web_core/src/v0_9/nodes/node-resolver.test.ts @@ -0,0 +1,672 @@ +/** + * Copyright 2026 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 + * + * http://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. + */ + +/** + * Conformance suite for the node layer, ported from the Python reference + * (`agent_sdks/python/a2ui_core/tests/test_node_graph.py`) plus tests for the + * defects the reference is known to have: eager action resolution, shared-node + * use-after-dispose, and whole-list template respawn. + */ + +import assert from 'node:assert'; +import {describe, it} from 'node:test'; +import {z} from 'zod'; +import { + Catalog, + ComponentApi, + FunctionApi, + createFunctionImplementation, +} from '../catalog/types.js'; +import {ComponentModel} from '../state/component-model.js'; +import {SurfaceModel} from '../state/surface-model.js'; +import {A2uiClientAction} from '../schema/client-to-server.js'; +import {ActionSchema, DynamicStringSchema} from '../schema/common-types.js'; +import {effect, getValue, peekValue, Signal} from '../reactivity/signals.js'; +import {ComponentNode, NodeProps, PLACEHOLDER_TYPE} from './component-node.js'; +import {NodeResolver} from './node-resolver.js'; +import {componentReference, componentReferenceList} from './ref-fields.js'; + +const TextApi = { + name: 'Text', + schema: z.object({text: DynamicStringSchema.optional()}), +}; +const ButtonApi = { + name: 'Button', + schema: z.object({label: DynamicStringSchema.optional(), action: ActionSchema.optional()}), +}; +const CardApi = { + name: 'Card', + schema: z.object({child: componentReference().optional()}), +}; +const ColumnApi = { + name: 'Column', + schema: z.object({children: componentReferenceList().optional()}), +}; +const TabsApi = { + name: 'Tabs', + schema: z.object({ + items: z.array(z.object({title: z.string(), child: componentReference()})).optional(), + }), +}; + +const ShoutApi = { + name: 'shout', + returnType: 'string', + schema: z.object({value: z.coerce.string()}), +} as const; + +function makeCatalog() { + return new Catalog( + 'node-test-catalog', + [TextApi, ButtonApi, CardApi, ColumnApi, TabsApi], + [createFunctionImplementation(ShoutApi, args => String(args.value).toUpperCase())], + ); +} + +function setup() { + const catalog = makeCatalog(); + const surface = new SurfaceModel('surf-1', catalog); + const resolver = new NodeResolver(surface, catalog); + return {catalog, surface, resolver}; +} + +function add(surface: SurfaceModel, id: string, type: string, props: Record) { + surface.componentsModel.addComponent(new ComponentModel(id, type, props)); +} + +function props(node: ComponentNode): NodeProps { + return peekValue(node.props); +} + +function child(node: ComponentNode, key: string, index?: number): ComponentNode { + const value = index === undefined ? props(node)[key] : (props(node)[key] as unknown[])[index]; + assert.ok( + value instanceof ComponentNode, + `expected ${key}[${index ?? ''}] to be a ComponentNode`, + ); + return value; +} + +/** Counts emissions of a signal, excluding the subscription-time run. */ +function countEmissions(sig: Signal): {readonly count: number; dispose(): void} { + let n = -1; + const dispose = effect(() => { + getValue(sig); + n++; + }); + return { + get count() { + return n; + }, + dispose, + }; +} + +const flush = () => new Promise(resolve => setTimeout(resolve, 0)); + +describe('NodeResolver conformance (port of test_node_graph.py)', () => { + it('resolves the root and upgrades and downgrades referenced children (lifecycle)', () => { + const {surface, resolver} = setup(); + add(surface, 'root', 'Column', {children: ['child_1']}); + const root = getValue(resolver.rootNode); + assert.ok(root); + assert.strictEqual(root.type, 'Column'); + assert.strictEqual(child(root, 'children', 0).type, PLACEHOLDER_TYPE); + + add(surface, 'child_1', 'Text', {text: 'Hello Node'}); + const upgraded = child(root, 'children', 0); + assert.strictEqual(upgraded.type, 'Text'); + assert.strictEqual(props(upgraded).text, 'Hello Node'); + + surface.componentsModel.removeComponent('child_1'); + assert.strictEqual(child(root, 'children', 0).type, PLACEHOLDER_TYPE); + assert.strictEqual(upgraded.disposed, true); + resolver.dispose(); + surface.dispose(); + }); + + it('tracks root creation and removal on rootNode', () => { + const {surface, resolver} = setup(); + assert.strictEqual(getValue(resolver.rootNode), undefined); + + add(surface, 'root', 'Column', {children: []}); + const root = getValue(resolver.rootNode); + assert.ok(root instanceof ComponentNode); + assert.strictEqual(root.componentId, 'root'); + assert.strictEqual(root.type, 'Column'); + + surface.componentsModel.removeComponent('root'); + assert.strictEqual(getValue(resolver.rootNode), undefined); + assert.strictEqual(root.disposed, true); + resolver.dispose(); + }); + + it('exposes core node properties', () => { + const {surface, resolver} = setup(); + add(surface, 'root', 'Card', {child: 'text-1'}); + add(surface, 'text-1', 'Text', {text: 'Hi'}); + const root = getValue(resolver.rootNode); + assert.ok(root); + assert.strictEqual(root.instanceId, 'root'); + assert.strictEqual(root.dataPath, '/'); + const textNode = child(root, 'child'); + assert.strictEqual(textNode.instanceId, 'text-1'); + assert.strictEqual(textNode.componentId, 'text-1'); + assert.strictEqual(textNode.type, 'Text'); + assert.strictEqual(textNode.dataPath, '/'); + resolver.dispose(); + }); + + it('resolves data-bound properties reactively', () => { + const {surface, resolver} = setup(); + surface.dataModel.set('/username', 'Alice'); + add(surface, 'root', 'Text', {text: {path: '/username'}}); + const root = getValue(resolver.rootNode); + assert.ok(root); + assert.strictEqual(props(root).text, 'Alice'); + + surface.dataModel.set('/username', 'Bob'); + assert.strictEqual(props(root).text, 'Bob'); + resolver.dispose(); + }); + + it('resolves a single child reference to a live node', () => { + const {surface, resolver} = setup(); + add(surface, 'root', 'Card', {child: 'text-1'}); + add(surface, 'text-1', 'Text', {text: 'Hello'}); + const root = getValue(resolver.rootNode); + assert.ok(root); + const textNode = child(root, 'child'); + assert.strictEqual(textNode.type, 'Text'); + assert.strictEqual(props(textNode).text, 'Hello'); + resolver.dispose(); + }); + + it('resolves an explicit children list in order', () => { + const {surface, resolver} = setup(); + add(surface, 'root', 'Column', {children: ['c1', 'c2']}); + add(surface, 'c1', 'Text', {text: 'C1'}); + add(surface, 'c2', 'Text', {text: 'C2'}); + const root = getValue(resolver.rootNode); + assert.ok(root); + const children = props(root).children as ComponentNode[]; + assert.strictEqual(children.length, 2); + assert.strictEqual(props(children[0]).text, 'C1'); + assert.strictEqual(props(children[1]).text, 'C2'); + resolver.dispose(); + }); + + it('spawns one node per array item for a template child list', () => { + const {surface, resolver} = setup(); + surface.dataModel.set('/items', [{name: 'A'}, {name: 'B'}]); + add(surface, 'root', 'Column', {children: {componentId: 'item_tpl', path: '/items'}}); + add(surface, 'item_tpl', 'Text', {text: {path: 'name'}}); + const root = getValue(resolver.rootNode); + assert.ok(root); + const children = props(root).children as ComponentNode[]; + assert.strictEqual(children.length, 2); + assert.strictEqual(children[0].instanceId, 'item_tpl-[/items/0]'); + assert.strictEqual(children[0].dataPath, '/items/0'); + assert.strictEqual(props(children[0]).text, 'A'); + assert.strictEqual(props(children[1]).text, 'B'); + resolver.dispose(); + }); + + it('renders placeholders progressively and emits the parent exactly once on upgrade', () => { + const {surface, resolver} = setup(); + add(surface, 'root', 'Column', {children: ['late']}); + const root = getValue(resolver.rootNode); + assert.ok(root); + const placeholder = child(root, 'children', 0); + assert.strictEqual(placeholder.type, PLACEHOLDER_TYPE); + assert.strictEqual(placeholder.componentId, 'late'); + + let destroyed = 0; + placeholder.onDestroyed.subscribe(() => { + destroyed++; + }); + const emissions = countEmissions(root.props); + + add(surface, 'late', 'Text', {text: 'Arrived'}); + assert.strictEqual(emissions.count, 1); + const upgraded = child(root, 'children', 0); + assert.notStrictEqual(upgraded, placeholder); + assert.strictEqual(upgraded.type, 'Text'); + assert.strictEqual(props(upgraded).text, 'Arrived'); + assert.strictEqual(placeholder.disposed, true); + assert.strictEqual(destroyed, 1); + emissions.dispose(); + resolver.dispose(); + }); + + it('binds actions as closures that dispatch through the surface', async () => { + const {surface, resolver} = setup(); + const actions: A2uiClientAction[] = []; + surface.onAction.subscribe(action => { + actions.push(action); + }); + surface.dataModel.set('/current_id', 42); + add(surface, 'root', 'Button', { + label: 'Go', + action: {event: {name: 'submit', context: {itemId: {path: '/current_id'}}}}, + }); + const root = getValue(resolver.rootNode); + assert.ok(root); + const fire = props(root).action; + assert.strictEqual(typeof fire, 'function'); + (fire as () => void)(); + await flush(); + + assert.strictEqual(actions.length, 1); + assert.strictEqual(actions[0].name, 'submit'); + assert.strictEqual(actions[0].surfaceId, 'surf-1'); + assert.strictEqual(actions[0].sourceComponentId, 'root'); + assert.deepStrictEqual(actions[0].context, {itemId: 42}); + resolver.dispose(); + }); + + it('resolves an unresolved binding to undefined without failing', () => { + const {surface, resolver} = setup(); + add(surface, 'root', 'Text', {text: {path: '/missing'}}); + const root = getValue(resolver.rootNode); + assert.ok(root); + assert.strictEqual(props(root).text, undefined); + resolver.dispose(); + }); + + it('reconciles explicit children list changes, reusing surviving nodes', () => { + const {surface, resolver} = setup(); + add(surface, 'root', 'Column', {children: ['c1', 'c2']}); + add(surface, 'c1', 'Text', {text: 'C1'}); + add(surface, 'c2', 'Text', {text: 'C2'}); + add(surface, 'c3', 'Text', {text: 'C3'}); + const root = getValue(resolver.rootNode); + assert.ok(root); + const before = props(root).children as ComponentNode[]; + + const rootModel = surface.componentsModel.get('root'); + assert.ok(rootModel); + rootModel.properties = {children: ['c1', 'c3']}; + + const after = props(root).children as ComponentNode[]; + assert.strictEqual(after.length, 2); + assert.strictEqual(after[0], before[0]); + assert.strictEqual(props(after[1]).text, 'C3'); + assert.strictEqual(before[1].disposed, true); + resolver.dispose(); + }); + + it('reconciles a swap from explicit children to a template', () => { + const {surface, resolver} = setup(); + surface.dataModel.set('/items', [{name: 'T0'}]); + add(surface, 'root', 'Column', {children: ['c1']}); + add(surface, 'c1', 'Text', {text: 'C1'}); + add(surface, 'item_tpl', 'Text', {text: {path: 'name'}}); + const root = getValue(resolver.rootNode); + assert.ok(root); + const explicitChild = child(root, 'children', 0); + + const rootModel = surface.componentsModel.get('root'); + assert.ok(rootModel); + rootModel.properties = {children: {componentId: 'item_tpl', path: '/items'}}; + + const children = props(root).children as ComponentNode[]; + assert.strictEqual(children.length, 1); + assert.strictEqual(props(children[0]).text, 'T0'); + assert.strictEqual(explicitChild.disposed, true); + resolver.dispose(); + }); + + it('resolves function-call bindings reactively', () => { + const {surface, resolver} = setup(); + surface.dataModel.set('/username', 'alice'); + add(surface, 'root', 'Text', {text: {call: 'shout', args: {value: {path: '/username'}}}}); + const root = getValue(resolver.rootNode); + assert.ok(root); + assert.strictEqual(props(root).text, 'ALICE'); + + surface.dataModel.set('/username', 'bob'); + assert.strictEqual(props(root).text, 'BOB'); + resolver.dispose(); + }); + + it('resolves nested child references inside item arrays', () => { + const {surface, resolver} = setup(); + add(surface, 'root', 'Tabs', { + items: [ + {title: 'One', child: 't1'}, + {title: 'Two', child: 't2'}, + ], + }); + add(surface, 't1', 'Text', {text: 'First'}); + add(surface, 't2', 'Text', {text: 'Second'}); + const root = getValue(resolver.rootNode); + assert.ok(root); + const items = props(root).items as Array>; + assert.strictEqual(items.length, 2); + assert.strictEqual(items[0].title, 'One'); + const first = items[0].child; + assert.ok(first instanceof ComponentNode); + assert.strictEqual(props(first).text, 'First'); + const second = items[1].child; + assert.ok(second instanceof ComponentNode); + assert.strictEqual(props(second).text, 'Second'); + resolver.dispose(); + }); + + it('reconciles a deleted component back to a placeholder, leaving siblings alone', () => { + const {surface, resolver} = setup(); + add(surface, 'root', 'Column', {children: ['c1', 'c2']}); + add(surface, 'c1', 'Text', {text: 'C1'}); + add(surface, 'c2', 'Text', {text: 'C2'}); + const root = getValue(resolver.rootNode); + assert.ok(root); + const before = props(root).children as ComponentNode[]; + + surface.componentsModel.removeComponent('c2'); + + const after = props(root).children as ComponentNode[]; + assert.strictEqual(after[0], before[0]); + assert.strictEqual(after[1].type, PLACEHOLDER_TYPE); + assert.strictEqual(before[1].disposed, true); + resolver.dispose(); + }); + + it('re-spawns template children as the bound array grows and shrinks', () => { + const {surface, resolver} = setup(); + surface.dataModel.set('/items', [{name: 'A'}]); + add(surface, 'root', 'Column', {children: {componentId: 'item_tpl', path: '/items'}}); + add(surface, 'item_tpl', 'Text', {text: {path: 'name'}}); + const root = getValue(resolver.rootNode); + assert.ok(root); + assert.strictEqual((props(root).children as ComponentNode[]).length, 1); + + surface.dataModel.set('/items', [{name: 'A'}, {name: 'B'}, {name: 'C'}]); + const grown = props(root).children as ComponentNode[]; + assert.strictEqual(grown.length, 3); + assert.strictEqual(props(grown[2]).text, 'C'); + + surface.dataModel.set('/items', [{name: 'A'}]); + const shrunk = props(root).children as ComponentNode[]; + assert.strictEqual(shrunk.length, 1); + assert.strictEqual(grown[1].disposed, true); + assert.strictEqual(grown[2].disposed, true); + resolver.dispose(); + }); + + it('serializes the resolved tree, rendering actions and placeholders specially', () => { + const {surface, resolver} = setup(); + add(surface, 'root', 'Column', {children: ['card', 'btn', 'late']}); + add(surface, 'card', 'Card', {child: 'txt'}); + add(surface, 'txt', 'Text', {text: 'Hello'}); + add(surface, 'btn', 'Button', {label: 'Go', action: {event: {name: 'go'}}}); + const root = getValue(resolver.rootNode); + assert.ok(root); + const json = root.toJSON(); + // setText/setLabel are the binder's synthesized two-way setters; like + // action closures they serialize as ''. + assert.deepStrictEqual(json, { + id: 'root', + type: 'Column', + children: [ + { + id: 'card', + type: 'Card', + child: {id: 'txt', type: 'Text', text: 'Hello', setText: ''}, + }, + {id: 'btn', type: 'Button', label: 'Go', setLabel: '', action: ''}, + {id: 'late', type: PLACEHOLDER_TYPE}, + ], + }); + resolver.dispose(); + }); +}); + +describe('NodeResolver defect coverage (fixes over the Python reference)', () => { + it('resolves action context at dispatch time, not bind time (late resolution)', async () => { + const {surface, resolver} = setup(); + const actions: A2uiClientAction[] = []; + surface.onAction.subscribe(action => { + actions.push(action); + }); + surface.dataModel.set('/current_id', 'stale'); + add(surface, 'root', 'Button', { + action: {event: {name: 'submit', context: {itemId: {path: '/current_id'}}}}, + }); + const root = getValue(resolver.rootNode); + assert.ok(root); + + surface.dataModel.set('/current_id', 'fresh'); + (props(root).action as () => void)(); + await flush(); + + assert.strictEqual(actions.length, 1); + assert.deepStrictEqual(actions[0].context, {itemId: 'fresh'}); + resolver.dispose(); + }); + + it('keeps a shared child alive for one parent when the other stops referencing it', () => { + const {surface, resolver} = setup(); + surface.dataModel.set('/label', 'shared text'); + add(surface, 'root', 'Column', {children: ['card_a', 'card_b']}); + add(surface, 'card_a', 'Card', {child: 'shared'}); + add(surface, 'card_b', 'Card', {child: 'shared'}); + add(surface, 'shared', 'Text', {text: {path: '/label'}}); + const root = getValue(resolver.rootNode); + assert.ok(root); + const cardA = child(root, 'children', 0); + const cardB = child(root, 'children', 1); + const sharedViaA = child(cardA, 'child'); + const sharedViaB = child(cardB, 'child'); + assert.notStrictEqual(sharedViaA, sharedViaB); + + const cardAModel = surface.componentsModel.get('card_a'); + assert.ok(cardAModel); + cardAModel.properties = {}; + + assert.strictEqual(sharedViaA.disposed, true); + assert.strictEqual(sharedViaB.disposed, false); + surface.dataModel.set('/label', 'still updating'); + assert.strictEqual(props(sharedViaB).text, 'still updating'); + resolver.dispose(); + }); + + it('keeps surviving template nodes across array growth and shrink (key stability)', () => { + const {surface, resolver} = setup(); + surface.dataModel.set('/items', [{name: 'A'}, {name: 'B'}]); + add(surface, 'root', 'Column', {children: {componentId: 'item_tpl', path: '/items'}}); + add(surface, 'item_tpl', 'Text', {text: {path: 'name'}}); + const root = getValue(resolver.rootNode); + assert.ok(root); + const before = [...(props(root).children as ComponentNode[])]; + + surface.dataModel.set('/items', [{name: 'A'}, {name: 'B'}, {name: 'C'}]); + const grown = props(root).children as ComponentNode[]; + assert.strictEqual(grown[0], before[0]); + assert.strictEqual(grown[1], before[1]); + assert.strictEqual(before[0].disposed, false); + assert.strictEqual(before[1].disposed, false); + + surface.dataModel.set('/items', [{name: 'A'}]); + const shrunk = props(root).children as ComponentNode[]; + assert.strictEqual(shrunk[0], before[0]); + assert.strictEqual(before[1].disposed, true); + resolver.dispose(); + }); + + it('does not emit a parent props signal when only a child property changes (no bubbling)', () => { + const {surface, resolver} = setup(); + surface.dataModel.set('/username', 'Alice'); + surface.dataModel.set('/items', [{name: 'A'}, {name: 'B'}]); + add(surface, 'root', 'Column', {children: ['bound', 'tpl_col']}); + add(surface, 'bound', 'Text', {text: {path: '/username'}}); + add(surface, 'tpl_col', 'Column', {children: {componentId: 'item_tpl', path: '/items'}}); + add(surface, 'item_tpl', 'Text', {text: {path: 'name'}}); + const root = getValue(resolver.rootNode); + assert.ok(root); + const boundText = child(root, 'children', 0); + const templateColumn = child(root, 'children', 1); + const item0 = child(templateColumn, 'children', 0); + + const rootEmissions = countEmissions(root.props); + const templateColumnEmissions = countEmissions(templateColumn.props); + const boundEmissions = countEmissions(boundText.props); + const item0Emissions = countEmissions(item0.props); + + surface.dataModel.set('/username', 'Bob'); + assert.strictEqual(boundEmissions.count, 1); + assert.strictEqual(props(boundText).text, 'Bob'); + assert.strictEqual(rootEmissions.count, 0); + + // Editing one item's field re-fires the template's array subscription + // (ancestor-path propagation); the item node must update while the + // template parent's props stay identity-stable and silent. + surface.dataModel.set('/items/0/name', 'A2'); + assert.strictEqual(props(item0).text, 'A2'); + assert.ok(item0Emissions.count >= 1); + assert.strictEqual(templateColumnEmissions.count, 0); + assert.strictEqual(rootEmissions.count, 0); + + rootEmissions.dispose(); + templateColumnEmissions.dispose(); + boundEmissions.dispose(); + item0Emissions.dispose(); + resolver.dispose(); + }); +}); + +describe('NodeResolver malformed and unusual payloads', () => { + it('renders cyclic references as placeholders instead of recursing', () => { + const {surface, resolver} = setup(); + const errors: Array> = []; + surface.onError.subscribe(e => { + errors.push(e as Record); + }); + add(surface, 'root', 'Card', {child: 'a'}); + add(surface, 'a', 'Card', {child: 'b'}); + add(surface, 'b', 'Card', {child: 'a'}); + + const root = getValue(resolver.rootNode); + assert.ok(root); + const a = child(root, 'child'); + const b = child(a, 'child'); + const backReference = child(b, 'child'); + assert.strictEqual(backReference.type, PLACEHOLDER_TYPE); + assert.strictEqual(backReference.componentId, 'a'); + assert.ok(errors.some(e => e.code === 'CYCLIC_REFERENCE')); + assert.ok(resolver.activeNodeCount <= 5); + resolver.dispose(); + }); + + it('renders a self-referencing component as a placeholder child', () => { + const {surface, resolver} = setup(); + add(surface, 'root', 'Card', {child: 'root'}); + const root = getValue(resolver.rootNode); + assert.ok(root); + assert.strictEqual(child(root, 'child').type, PLACEHOLDER_TYPE); + resolver.dispose(); + }); + + it('propagates changes to non-plain object values', () => { + // The data model shallow-clones values when notifying, so the non-plain + // object sits one level down, where the clone preserves its reference. + const {surface, resolver} = setup(); + const first = new Map([['k', 1]]); + const second = new Map([['k', 2]]); + surface.dataModel.set('/blob', {wrapper: first}); + add(surface, 'root', 'Text', {text: {path: '/blob'}}); + const root = getValue(resolver.rootNode); + assert.ok(root); + assert.strictEqual((props(root).text as {wrapper: unknown}).wrapper, first); + + surface.dataModel.set('/blob', {wrapper: second}); + assert.strictEqual((props(root).text as {wrapper: unknown}).wrapper, second); + resolver.dispose(); + }); + + it('keeps a stable placeholder for a component whose type is not in the catalog', () => { + const {surface, resolver} = setup(); + const errors: Array> = []; + surface.onError.subscribe(e => { + errors.push(e as Record); + }); + add(surface, 'root', 'Card', {child: 'weird'}); + add(surface, 'weird', 'Bogus', {}); + + const root = getValue(resolver.rootNode); + assert.ok(root); + const placeholder = child(root, 'child'); + assert.strictEqual(placeholder.type, PLACEHOLDER_TYPE); + const reportsBefore = errors.filter(e => e.code === 'UNKNOWN_COMPONENT_TYPE').length; + + const rootModel = surface.componentsModel.get('root'); + assert.ok(rootModel); + rootModel.properties = {child: 'weird'}; + rootModel.properties = {child: 'weird'}; + + assert.strictEqual(child(root, 'child'), placeholder); + assert.strictEqual( + errors.filter(e => e.code === 'UNKNOWN_COMPONENT_TYPE').length, + reportsBefore, + ); + resolver.dispose(); + }); +}); + +describe('NodeResolver construction gate', () => { + it('rejects a catalog instance other than the surface catalog', () => { + const catalogA = makeCatalog(); + const catalogB = makeCatalog(); + const surface = new SurfaceModel('surf-1', catalogA); + assert.throws(() => new NodeResolver(surface, catalogB), /same catalog instance/); + }); + + it('rejects a schema-only catalog at compile time', () => { + // The assertion is the @ts-expect-error below: the build fails if a + // schema-only catalog ever satisfies NodeResolver's constructor bound. + // eslint-disable-next-line @typescript-eslint/no-unused-vars + function schemaOnlyCatalogIsRejected( + surface: SurfaceModel, + schemaOnly: Catalog, + ) { + // @ts-expect-error a schema-only catalog has no function implementations + return new NodeResolver(surface, schemaOnly); + } + assert.strictEqual(typeof schemaOnlyCatalogIsRejected, 'function'); + }); + + it('disposes the whole tree with the resolver, leaving no live nodes', () => { + const {surface, resolver} = setup(); + surface.dataModel.set('/items', [{name: 'A'}, {name: 'B'}]); + add(surface, 'root', 'Column', {children: ['card', 'tpl_col']}); + add(surface, 'card', 'Card', {child: 'txt'}); + add(surface, 'txt', 'Text', {text: 'Hello'}); + add(surface, 'tpl_col', 'Column', {children: {componentId: 'item_tpl', path: '/items'}}); + add(surface, 'item_tpl', 'Text', {text: {path: 'name'}}); + const root = getValue(resolver.rootNode); + assert.ok(root); + assert.ok(resolver.activeNodeCount >= 6); + + resolver.dispose(); + assert.strictEqual(resolver.activeNodeCount, 0); + assert.strictEqual(getValue(resolver.rootNode), undefined); + assert.strictEqual(root.disposed, true); + + // A data change after disposal must not resurrect any binding. + surface.dataModel.set('/items', [{name: 'X'}]); + assert.strictEqual(resolver.activeNodeCount, 0); + }); +}); diff --git a/renderers/web_core/src/v0_9/nodes/node-resolver.ts b/renderers/web_core/src/v0_9/nodes/node-resolver.ts new file mode 100644 index 0000000000..6a3377a5d9 --- /dev/null +++ b/renderers/web_core/src/v0_9/nodes/node-resolver.ts @@ -0,0 +1,585 @@ +/** + * Copyright 2026 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 + * + * http://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. + */ + +import {SurfaceModel} from '../state/surface-model.js'; +import {ComponentModel} from '../state/component-model.js'; +import {Catalog, ComponentApi, FunctionImplementation} from '../catalog/types.js'; +import {ComponentContext} from '../rendering/component-context.js'; +import {GenericBinder} from '../rendering/generic-binder.js'; +import {ComponentNode, NodeProps, PLACEHOLDER_TYPE} from './component-node.js'; +import {extractRefFields, RefFields} from './ref-fields.js'; +import {Signal, signal, setValue, peekValue} from '../reactivity/signals.js'; +import {Subscription} from '../common/events.js'; +import {A2uiStateError} from '../errors.js'; + +const ROOT_COMPONENT_ID = 'root'; +const ROOT_DATA_PATH = '/'; +const ROOT_EDGE_KEY = '>root>root@/'; + +const EMPTY_REF_FIELDS: RefFields = {single: new Set(), list: new Set(), nested: new Map()}; + +interface NodeRecord { + readonly node: ComponentNode; + readonly edgeKey: string; + /** The node whose props reference this one; undefined for the root. */ + readonly parent?: ComponentNode; + readonly refFields: RefFields; + readonly componentModel?: ComponentModel; + readonly binder?: GenericBinder; + binderSub?: {unsubscribe(): void}; + /** The most recent per-component resolution from the binder. */ + lastBinderProps?: NodeProps; + /** Children this node currently references, keyed by edge. This parent owns their disposal. */ + childEdges: Map; +} + +/** + * The tree engine of the node layer: turns a surface's flat component map + * into a live tree of resolved {@link ComponentNode}s rooted at + * {@link rootNode}. Child references become `ComponentNode` objects, template + * `ChildList`s spawn one node per array item, not-yet-arrived components + * appear as placeholder nodes and are upgraded in place, and every node's + * binder and data subscriptions are torn down when its parent stops + * referencing it or the resolver is disposed. + * + * Construction requires a catalog whose functions are executable + * (`F extends FunctionImplementation`). A schema-only catalog + * (`Catalog`) fails this bound at compile time: without + * implementations, function-derived values cannot resolve and the tree this + * class produces would be wrong. Hosts without implementations (agent-side + * code) operate on `SurfaceModel` directly and never construct a resolver. + * + * Node identity is parent-scoped: each referencing position gets its own + * node, so one component id mounted at two positions yields two nodes and + * dropping one position never tears down the other. + */ +export class NodeResolver< + C extends ComponentApi = ComponentApi, + F extends FunctionImplementation = FunctionImplementation, +> { + /** The resolved root of the tree; undefined until the root component arrives. */ + readonly rootNode: Signal; + + private readonly surface: SurfaceModel; + private readonly catalog: Catalog; + private readonly records = new Map(); + private readonly nodesByEdge = new Map(); + private readonly nodesByComponentId = new Map>(); + /** Parents holding a placeholder for a component id, awaiting its arrival. */ + private readonly pendingParents = new Map>(); + private readonly modelSubs: Subscription[] = []; + private rootRecord?: NodeRecord; + private _disposed = false; + + constructor(surface: SurfaceModel, catalog: Catalog) { + if ((catalog as unknown) !== (surface.catalog as unknown)) { + throw new A2uiStateError( + 'NodeResolver requires the same catalog instance its surface was constructed with.', + ); + } + this.surface = surface; + this.catalog = catalog; + this.rootNode = signal(undefined); + + this.modelSubs.push( + surface.componentsModel.onCreated.subscribe(component => this.onComponentCreated(component)), + ); + this.modelSubs.push( + surface.componentsModel.onDeleted.subscribe(id => this.onComponentDeleted(id)), + ); + + if (surface.componentsModel.get(ROOT_COMPONENT_ID)) { + this.buildRoot(); + } + } + + /** Number of live nodes (including placeholders). Exposed for tests and devtools. */ + get activeNodeCount(): number { + return this.records.size; + } + + get disposed(): boolean { + return this._disposed; + } + + /** Tears down the whole tree and stops tracking the surface. Idempotent. */ + dispose(): void { + if (this._disposed) { + return; + } + this._disposed = true; + for (const sub of this.modelSubs) { + sub.unsubscribe(); + } + this.modelSubs.length = 0; + for (const node of [...this.records.keys()]) { + this.disposeNode(node); + } + this.pendingParents.clear(); + this.rootRecord = undefined; + setValue(this.rootNode, undefined); + } + + private buildRoot(): void { + const node = this.createNode(ROOT_COMPONENT_ID, ROOT_DATA_PATH, ROOT_EDGE_KEY, undefined); + this.rootRecord = this.records.get(node); + setValue(this.rootNode, node); + } + + private onComponentCreated(component: ComponentModel): void { + if (this._disposed) { + return; + } + if (component.id === ROOT_COMPONENT_ID && !this.rootRecord) { + this.buildRoot(); + } + const waiting = this.pendingParents.get(component.id); + if (waiting) { + this.pendingParents.delete(component.id); + for (const parent of waiting) { + const record = this.records.get(parent); + if (record && !parent.disposed) { + this.materialize(record); + } + } + } + } + + private onComponentDeleted(id: string): void { + if (this._disposed) { + return; + } + const affected = this.nodesByComponentId.get(id); + if (!affected) { + return; + } + const parentsToRefresh = new Set(); + let rootDeleted = false; + for (const node of [...affected]) { + const record = this.records.get(node); + if (!record) { + continue; + } + if (record.parent) { + parentsToRefresh.add(record.parent); + } else { + rootDeleted = true; + } + } + if (rootDeleted && this.rootRecord) { + this.disposeNode(this.rootRecord.node); + this.rootRecord = undefined; + setValue(this.rootNode, undefined); + } + for (const parent of parentsToRefresh) { + const record = this.records.get(parent); + if (record && !parent.disposed) { + this.materialize(record); + } + } + } + + /** + * Creates a node for one (componentId, dataPath) edge. A missing component + * definition yields a placeholder node and registers the parent for a + * refresh when the definition arrives. + */ + private createNode( + componentId: string, + dataPath: string, + edgeKey: string, + parent: ComponentNode | undefined, + ): ComponentNode { + const model = this.surface.componentsModel.get(componentId); + if (!model) { + const record = this.registerNode( + new ComponentNode( + instanceIdFor(componentId, dataPath), + componentId, + PLACEHOLDER_TYPE, + dataPath, + {}, + ), + {edgeKey, parent, refFields: EMPTY_REF_FIELDS}, + ); + if (parent) { + let waiting = this.pendingParents.get(componentId); + if (!waiting) { + waiting = new Set(); + this.pendingParents.set(componentId, waiting); + } + waiting.add(parent); + } + return record.node; + } + + const api = this.catalog.components.get(model.type); + if (!api) { + this.surface.dispatchError({ + code: 'UNKNOWN_COMPONENT_TYPE', + message: `Component '${componentId}' has type '${model.type}', which is not in catalog '${this.catalog.id}'.`, + }); + return this.registerNode( + new ComponentNode( + instanceIdFor(componentId, dataPath), + componentId, + PLACEHOLDER_TYPE, + dataPath, + {}, + ), + {edgeKey, parent, refFields: EMPTY_REF_FIELDS}, + ).node; + } + + const context = new ComponentContext(this.surface, componentId, dataPath); + const binder = new GenericBinder(context, api.schema); + const record = this.registerNode( + new ComponentNode( + instanceIdFor(componentId, dataPath), + componentId, + model.type, + dataPath, + {}, + ), + {edgeKey, parent, refFields: extractRefFields(api.schema), componentModel: model, binder}, + ); + record.binderSub = binder.subscribe(raw => { + record.lastBinderProps = raw; + this.materialize(record); + }); + // The binder resolves synchronously while connecting, but notifies only + // listeners registered before that resolution, so the first + // materialization must be seeded from its snapshot. + record.lastBinderProps = binder.snapshot; + this.materialize(record); + return record.node; + } + + private registerNode( + node: ComponentNode, + partial: { + edgeKey: string; + parent?: ComponentNode; + refFields: RefFields; + componentModel?: ComponentModel; + binder?: GenericBinder; + }, + ): NodeRecord { + const record: NodeRecord = { + node, + edgeKey: partial.edgeKey, + parent: partial.parent, + refFields: partial.refFields, + componentModel: partial.componentModel, + binder: partial.binder, + childEdges: new Map(), + }; + this.records.set(node, record); + this.nodesByEdge.set(partial.edgeKey, node); + let byId = this.nodesByComponentId.get(node.componentId); + if (!byId) { + byId = new Set(); + this.nodesByComponentId.set(node.componentId, byId); + } + byId.add(node); + return record; + } + + /** + * Returns the node for a child edge, reusing the cached node when the edge + * is unchanged and replacing it (placeholder upgrade or downgrade, id + * change, type change) when it is not. + */ + private childNode( + componentId: string, + dataPath: string, + edgeKey: string, + parent: ComponentNode, + ): ComponentNode { + const existing = this.nodesByEdge.get(edgeKey); + if (this.isCyclic(componentId, dataPath, parent)) { + // Node identity is parent-scoped, so a cyclic payload would otherwise + // recurse forever; render the repeated reference as a placeholder. + if (existing && !existing.disposed && existing.isPlaceholder) { + return existing; + } + if (existing && !existing.disposed) { + this.disposeNode(existing); + } + this.surface.dispatchError({ + code: 'CYCLIC_REFERENCE', + message: `Component '${componentId}' at '${dataPath}' is referenced by one of its own descendants; rendering a placeholder instead.`, + }); + return this.registerNode( + new ComponentNode( + instanceIdFor(componentId, dataPath), + componentId, + PLACEHOLDER_TYPE, + dataPath, + {}, + ), + {edgeKey, parent, refFields: EMPTY_REF_FIELDS}, + ).node; + } + if (existing && !existing.disposed) { + const model = this.surface.componentsModel.get(componentId); + const api = model ? this.catalog.components.get(model.type) : undefined; + // A placeholder stays up to date while its component is missing, and + // also while the component exists but has no catalog entry; recreating + // it cannot improve either situation. + const upToDate = + existing.componentId === componentId && + existing.dataPath === dataPath && + (existing.isPlaceholder ? !model || !api : !!model && existing.type === model.type); + if (upToDate) { + return existing; + } + this.disposeNode(existing); + } + return this.createNode(componentId, dataPath, edgeKey, parent); + } + + /** True when (componentId, dataPath) already appears in the parent chain. */ + private isCyclic(componentId: string, dataPath: string, parent: ComponentNode): boolean { + for ( + let node: ComponentNode | undefined = parent; + node; + node = this.records.get(node)?.parent + ) { + if (node.componentId === componentId && node.dataPath === dataPath) { + return true; + } + } + return false; + } + + /** + * Rebuilds a node's resolved props from its binder output: child reference + * properties become live `ComponentNode`s, children this parent no longer + * references are disposed, and unchanged values keep reference identity so + * the node's shallow emission gate stays exact. + */ + private materialize(record: NodeRecord): void { + if (record.node.disposed) { + return; + } + const raw = record.lastBinderProps ?? record.binder?.snapshot ?? {}; + const next: NodeProps = {...raw}; + const newEdges = new Map(); + + // The binder merges rebuilt props over previous ones and never drops a + // key the component's properties no longer contain. Ref props drive child + // lifecycles, so their presence must follow the component model exactly. + const modelProps = record.componentModel?.properties; + if (modelProps) { + for (const refKeys of [record.refFields.single, record.refFields.list]) { + for (const key of refKeys) { + if (key in next && !(key in modelProps)) { + delete next[key]; + } + } + } + for (const key of record.refFields.nested.keys()) { + if (key in next && !(key in modelProps)) { + delete next[key]; + } + } + } + + const resolveChild = (slot: string, componentId: string, dataPath: string): ComponentNode => { + const edgeKey = `${record.edgeKey}>${slot}>${componentId}@${dataPath}`; + const child = this.childNode(componentId, dataPath, edgeKey, record.node); + newEdges.set(edgeKey, child); + return child; + }; + + for (const key of record.refFields.single) { + const value = next[key]; + if (typeof value === 'string' && value) { + next[key] = resolveChild(key, value, record.node.dataPath); + } + } + + for (const key of record.refFields.list) { + const value = next[key]; + if (!Array.isArray(value)) { + continue; + } + next[key] = value.map((item, index) => { + if (typeof item === 'string' && item) { + return resolveChild(`${key}[${index}]`, item, record.node.dataPath); + } + if (item && typeof item === 'object' && !Array.isArray(item)) { + const entry = item as Record; + // The binder resolves a {componentId, path} template into + // {id, basePath} pairs, one per array element. + if (typeof entry.id === 'string' && typeof entry.basePath === 'string') { + return resolveChild(`${key}[${index}]`, entry.id, entry.basePath); + } + if (typeof entry.componentId === 'string' && entry.componentId) { + return resolveChild(`${key}[${index}]`, entry.componentId, record.node.dataPath); + } + } + return item; + }); + } + + for (const [key, subKeys] of record.refFields.nested) { + const value = next[key]; + if (!Array.isArray(value)) { + continue; + } + next[key] = value.map((item, index) => { + if (!item || typeof item !== 'object' || Array.isArray(item)) { + return item; + } + const entry = {...(item as Record)}; + let resolvedAny = false; + for (const subKey of subKeys) { + const childId = entry[subKey]; + if (typeof childId === 'string' && childId) { + entry[subKey] = resolveChild( + `${key}[${index}].${subKey}`, + childId, + record.node.dataPath, + ); + resolvedAny = true; + } + } + return resolvedAny ? entry : item; + }); + } + + for (const [edgeKey, child] of record.childEdges) { + if (!newEdges.has(edgeKey)) { + this.disposeNode(child); + if (child.isPlaceholder) { + const stillWaiting = [...newEdges.values()].some( + other => other.isPlaceholder && other.componentId === child.componentId, + ); + if (!stillWaiting) { + this.pendingParents.get(child.componentId)?.delete(record.node); + } + } + } + } + record.childEdges = newEdges; + + // peekValue avoids creating a reactive dependency inside materialize. + const previous = peekValue(record.node.props); + for (const key of Object.keys(next)) { + next[key] = stabilize(previous[key], next[key]); + } + record.node.setProps(next); + } + + /** Disposes a node and, through parent-scoped ownership, its subtree. */ + private disposeNode(node: ComponentNode): void { + if (node.disposed) { + return; + } + const record = this.records.get(node); + if (record) { + for (const child of record.childEdges.values()) { + this.disposeNode(child); + } + record.childEdges.clear(); + record.binderSub?.unsubscribe(); + record.binderSub = undefined; + if (this.nodesByEdge.get(record.edgeKey) === node) { + this.nodesByEdge.delete(record.edgeKey); + } + this.records.delete(node); + } + const byId = this.nodesByComponentId.get(node.componentId); + if (byId) { + byId.delete(node); + if (byId.size === 0) { + this.nodesByComponentId.delete(node.componentId); + } + } + for (const waiting of this.pendingParents.values()) { + waiting.delete(node); + } + node.dispose(); + } +} + +function instanceIdFor(componentId: string, dataPath: string): string { + if (dataPath === ROOT_DATA_PATH) { + return componentId; + } + const trimmed = dataPath.replace(/\/+$/, '') || ROOT_DATA_PATH; + return `${componentId}-[${trimmed}]`; +} + +/** + * Returns `prev` whenever `next` is structurally identical to it, so + * unchanged props keep reference identity across rebuilds. Child + * `ComponentNode`s and action closures compare by identity. + */ +function stabilize(prev: unknown, next: unknown): unknown { + if (Object.is(prev, next)) { + return next; + } + if (prev instanceof ComponentNode || next instanceof ComponentNode) { + return next; + } + if (Array.isArray(prev) && Array.isArray(next) && prev.length === next.length) { + let allSame = true; + const out = next.map((item, i) => { + const stabilized = stabilize(prev[i], item); + if (!Object.is(stabilized, prev[i])) { + allSame = false; + } + return stabilized; + }); + return allSame ? prev : out; + } + if (isPlainObject(prev) && isPlainObject(next)) { + const prevKeys = Object.keys(prev); + const nextKeys = Object.keys(next); + if (prevKeys.length === nextKeys.length) { + let allSame = true; + const out: Record = {}; + for (const key of nextKeys) { + const stabilized = stabilize(prev[key], next[key]); + out[key] = stabilized; + if (!(key in prev) || !Object.is(stabilized, prev[key])) { + allSame = false; + } + } + return allSame ? prev : out; + } + } + return next; +} + +function isPlainObject(value: unknown): value is Record { + if ( + !value || + typeof value !== 'object' || + Array.isArray(value) || + value instanceof ComponentNode + ) { + return false; + } + // Maps, Dates, and class instances have no own enumerable keys to compare, + // so key-wise stabilization would wrongly report them unchanged; treat any + // non-literal object as always-changed instead. + const proto = Object.getPrototypeOf(value); + return proto === Object.prototype || proto === null; +} diff --git a/renderers/web_core/src/v0_9/nodes/ref-fields.ts b/renderers/web_core/src/v0_9/nodes/ref-fields.ts new file mode 100644 index 0000000000..07cb5d03d8 --- /dev/null +++ b/renderers/web_core/src/v0_9/nodes/ref-fields.ts @@ -0,0 +1,160 @@ +/** + * Copyright 2026 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 + * + * http://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. + */ + +import {z} from 'zod'; +import {ChildListSchema, ComponentIdSchema} from '../schema/common-types.js'; + +/** + * Marker strings embedded in a schema's description so the node layer can + * identify component-reference properties at runtime. + * + * A bare `z.string()` component id is indistinguishable from any other string + * (`scrapeSchemaBehavior` deliberately lets it fall back to STATIC), so single + * child references are invisible to schema inspection unless the catalog + * declares them with a distinguishable type. These helpers are that type. + */ +const SINGLE_REF_MARKER = 'A2UI_COMPONENT_REF:single'; +const LIST_REF_MARKER = 'A2UI_COMPONENT_REF:list'; + +/** + * Declares a property holding the id of a single child component. + * Use in place of a bare string id so the node layer can resolve the child. + */ +export function componentReference(): z.ZodTypeAny { + return ComponentIdSchema.describe(`${SINGLE_REF_MARKER}|${ComponentIdSchema.description ?? ''}`); +} + +/** + * Declares a property holding a list of children: either a static array of + * component ids or a `{componentId, path}` template. + */ +export function componentReferenceList(): z.ZodTypeAny { + return ChildListSchema.describe(`${LIST_REF_MARKER}|${ChildListSchema.description ?? ''}`); +} + +/** Which properties of a component's schema reference child components. */ +export interface RefFields { + /** Properties holding a single child component id. */ + readonly single: ReadonlySet; + /** Properties holding a `ChildList` (static id array or template). */ + readonly list: ReadonlySet; + /** + * Properties holding an array of plain objects in which some keys are + * single child references (e.g. a tab strip's `items[].child`), mapped to + * those keys. + */ + readonly nested: ReadonlyMap>; +} + +const EMPTY_REF_FIELDS: RefFields = { + single: new Set(), + list: new Set(), + nested: new Map(), +}; + +const refFieldsCache = new WeakMap(); + +/** + * Derives the {@link RefFields} of a component schema. + * + * Detection is by the marker types above, plus the same structural test the + * binder uses for `ChildList` unions (an option object with both `componentId` + * and `path`), so catalogs that already use `ChildListSchema` need no marker + * for list properties. Results are memoized per schema object. + */ +export function extractRefFields(schema: z.ZodTypeAny): RefFields { + const cached = refFieldsCache.get(schema); + if (cached) { + return cached; + } + + const unwrapped = unwrap(schema); + if (unwrapped.schema._def.typeName !== 'ZodObject') { + refFieldsCache.set(schema, EMPTY_REF_FIELDS); + return EMPTY_REF_FIELDS; + } + + const single = new Set(); + const list = new Set(); + const nested = new Map>(); + + const shape = unwrapped.schema._def.shape() as Record; + for (const [key, value] of Object.entries(shape)) { + const field = unwrap(value); + if (hasMarker(field.descriptions, SINGLE_REF_MARKER)) { + single.add(key); + continue; + } + if (hasMarker(field.descriptions, LIST_REF_MARKER) || isChildListUnion(field.schema)) { + list.add(key); + continue; + } + if (field.schema._def.typeName === 'ZodArray') { + const element = unwrap(field.schema._def.type as z.ZodTypeAny); + if (element.schema._def.typeName === 'ZodObject') { + const subKeys = new Set(); + const elementShape = element.schema._def.shape() as Record; + for (const [subKey, subValue] of Object.entries(elementShape)) { + if (hasMarker(unwrap(subValue).descriptions, SINGLE_REF_MARKER)) { + subKeys.add(subKey); + } + } + if (subKeys.size > 0) { + nested.set(key, subKeys); + } + } + } + } + + const result: RefFields = {single, list, nested}; + refFieldsCache.set(schema, result); + return result; +} + +/** + * Unwraps optional/nullable/default wrappers, collecting every description + * seen along the way (a marker may sit on the wrapper or on the inner type). + */ +function unwrap(schema: z.ZodTypeAny): {schema: z.ZodTypeAny; descriptions: string[]} { + const descriptions: string[] = []; + let current = schema; + for (;;) { + if (current.description) { + descriptions.push(current.description); + } + const typeName = current._def.typeName; + if (typeName === 'ZodOptional' || typeName === 'ZodNullable' || typeName === 'ZodDefault') { + current = current._def.innerType; + } else { + return {schema: current, descriptions}; + } + } +} + +function hasMarker(descriptions: string[], marker: string): boolean { + return descriptions.some(d => d.includes(marker)); +} + +/** Matches the binder's STRUCTURAL detection for `ChildList`-shaped unions. */ +function isChildListUnion(schema: z.ZodTypeAny): boolean { + if (schema._def.typeName !== 'ZodUnion') { + return false; + } + const options = schema._def.options as z.ZodTypeAny[]; + return options.some( + o => o._def.typeName === 'ZodObject' && o._def.shape().componentId && o._def.shape().path, + ); +} diff --git a/renderers/web_core/src/v0_9/state/surface-model.ts b/renderers/web_core/src/v0_9/state/surface-model.ts index 5686beb7ea..3853d6b8db 100644 --- a/renderers/web_core/src/v0_9/state/surface-model.ts +++ b/renderers/web_core/src/v0_9/state/surface-model.ts @@ -15,7 +15,7 @@ */ import {DataModel} from './data-model.js'; -import {Catalog, ComponentApi} from '../catalog/types.js'; +import {Catalog, ComponentApi, FunctionApi, FunctionImplementation} from '../catalog/types.js'; import {SurfaceComponentsModel} from './surface-components-model.js'; import {EventEmitter, EventSource} from '../common/events.js'; import {A2uiClientAction, A2uiClientActionSchema} from '../schema/client-to-server.js'; @@ -30,8 +30,15 @@ export type ActionListener = (action: A2uiClientAction) => void | Promise; * It coordinates data binding, component state, and action dispatching. * * @template T The concrete type of the ComponentApi from the catalog. + * @template F The catalog's function kind. Every state and messaging + * capability of the surface works with a schema-only catalog + * (`SurfaceModel`); only node-tree resolution needs + * implementations, and `NodeResolver` enforces that on its own constructor. */ -export class SurfaceModel { +export class SurfaceModel< + T extends ComponentApi = ComponentApi, + F extends FunctionApi = FunctionImplementation, +> { /** The data model for this surface. */ readonly dataModel: DataModel; /** The collection of component models for this surface. */ @@ -56,7 +63,7 @@ export class SurfaceModel { */ constructor( readonly id: string, - readonly catalog: Catalog, + readonly catalog: Catalog, readonly theme: any = {}, readonly sendDataModel: boolean = false, ) { diff --git a/samples/client/react/shell/src/App.tsx b/samples/client/react/shell/src/App.tsx index b4913f05ed..46f2073bfd 100644 --- a/samples/client/react/shell/src/App.tsx +++ b/samples/client/react/shell/src/App.tsx @@ -16,6 +16,7 @@ import {useState, useEffect, useCallback, useMemo, useRef, FormEvent} from 'react'; import { + A2uiNodeSurface, A2uiSurface, basicCatalog, MarkdownContext, @@ -96,6 +97,10 @@ interface ShellContentProps { } function ShellContent({config, client, sendAndProcessRef, processor}: ShellContentProps) { + const useNodeSurface = useMemo( + () => new URLSearchParams(window.location.search).has('nodes'), + [], + ); const [requesting, setRequesting] = useState(false); const [error, setError] = useState(null); const [messages, setMessages] = useState([]); @@ -316,12 +321,17 @@ function ShellContent({config, client, sendAndProcessRef, processor}: ShellConte {/* Error display */} {error &&
{error}
} - {/* Render all surfaces */} + {/* Render all surfaces. Add ?nodes to the URL to render through the + experimental node layer (A2uiNodeSurface) instead. */} {hasSurfaces && (
- {surfaces.map(surface => ( - - ))} + {surfaces.map(surface => + useNodeSurface ? ( + + ) : ( + + ), + )}
)}