From 494dff312e50fdaf49d5032b5d2cb8caa419bffc Mon Sep 17 00:00:00 2001 From: Andrew Kolos Date: Wed, 15 Jul 2026 06:51:53 -0700 Subject: [PATCH 1/6] feat(a2ui_core): add ComponentNode tree and NodeResolver Port the node layer from web_core: NodeResolver turns a surface's flat component map into a live tree of resolved ComponentNodes. Child references resolve to live nodes, templates spawn one node per array item, missing components render as placeholders upgraded in place, and node identity is parent-scoped so shared components never tear down a sibling's subtree. Ref-field classification derives from the REF: description pointers already present in CommonSchemas. Includes the cross-language conformance suite (27 tests) ported from web_core's node-resolver.test.ts. --- packages/a2ui_core/lib/a2ui_core.dart | 3 + .../lib/src/nodes/component_node.dart | 174 ++++ .../lib/src/nodes/node_resolver.dart | 542 ++++++++++ .../a2ui_core/lib/src/nodes/ref_fields.dart | 154 +++ packages/a2ui_core/pubspec.yaml | 1 + .../a2ui_core/test/node_resolver_test.dart | 923 ++++++++++++++++++ 6 files changed, 1797 insertions(+) create mode 100644 packages/a2ui_core/lib/src/nodes/component_node.dart create mode 100644 packages/a2ui_core/lib/src/nodes/node_resolver.dart create mode 100644 packages/a2ui_core/lib/src/nodes/ref_fields.dart create mode 100644 packages/a2ui_core/test/node_resolver_test.dart diff --git a/packages/a2ui_core/lib/a2ui_core.dart b/packages/a2ui_core/lib/a2ui_core.dart index 4ca0acd27..3f1c42e60 100644 --- a/packages/a2ui_core/lib/a2ui_core.dart +++ b/packages/a2ui_core/lib/a2ui_core.dart @@ -18,6 +18,9 @@ export 'src/core/messages.dart'; export 'src/core/minimal_catalog.dart'; export 'src/core/surface_group_model.dart'; export 'src/core/surface_model.dart'; +export 'src/nodes/component_node.dart'; +export 'src/nodes/node_resolver.dart'; +export 'src/nodes/ref_fields.dart'; export 'src/primitives/cancellation.dart'; export 'src/primitives/data_path.dart'; export 'src/primitives/errors.dart'; diff --git a/packages/a2ui_core/lib/src/nodes/component_node.dart b/packages/a2ui_core/lib/src/nodes/component_node.dart new file mode 100644 index 000000000..8528d3877 --- /dev/null +++ b/packages/a2ui_core/lib/src/nodes/component_node.dart @@ -0,0 +1,174 @@ +// Copyright 2025 The Flutter Authors. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:logging/logging.dart'; + +import '../primitives/event_notifier.dart'; +import '../primitives/reactivity.dart'; + +final _log = Logger('a2ui_core.nodes'); + +/// The `type` of a node whose component definition has not arrived yet. +const String placeholderType = 'Placeholder'; + +/// Resolved node properties, keyed by the component's schema property names. +typedef NodeProps = Map; + +/// One resolved component instance in the rendered tree. +/// +/// A node's [props] hold fully resolved values: primitives for dynamic +/// values, ready-to-call closures for actions, and live [ComponentNode] +/// references (or lists 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. +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. + final String instanceId; + + /// The component id from the payload. + final String componentId; + + /// The catalog component type, or `'Placeholder'`. + final String type; + + /// The data model scope this node resolves against, e.g. `/items/0`. + final String dataPath; + + final Signal _props; + + /// Resolved, reactive properties. Read without subscribing via `peek()`. + ReadonlySignal get props => _props; + + final _onDestroyed = EventNotifier(); + + /// Fires exactly once, when this node is disposed. + EventListenable get onDestroyed => _onDestroyed; + + List _cleanups = []; + bool _disposed = false; + + ComponentNode( + this.instanceId, + this.componentId, + this.type, + this.dataPath, + NodeProps initialProps, + ) : _props = signal(initialProps); + + bool get disposed => _disposed; + + bool get isPlaceholder => type == placeholderType; + + /// Registers teardown work to run when this node is disposed. + void addCleanup(void Function() cleanup) { + _cleanups.add(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 lists keep their identity), so + /// shallow comparison is exact rather than heuristic. + void setProps(NodeProps next) { + if (_disposed) { + return; + } + final NodeProps previous = _props.peek(); + if (!_shallowEqual(previous, next)) { + _props.value = next; + } + } + + /// Tears down this node: runs registered cleanups, then fires + /// [onDestroyed]. Idempotent. + void dispose() { + if (_disposed) { + return; + } + _disposed = true; + for (final void Function() cleanup in _cleanups) { + try { + cleanup(); + } catch (error, stackTrace) { + // A failing cleanup must not prevent the remaining ones from running. + _log.severe( + 'ComponentNode cleanup error ($instanceId)', + error, + stackTrace, + ); + } + } + _cleanups = []; + _onDestroyed.emit(null); + _onDestroyed.dispose(); + } + + /// Serializes the resolved tree for debugging and headless assertions. + /// Child nodes serialize recursively; action closures and setters + /// serialize as the string `''`. + Map toJson() { + if (isPlaceholder) { + return {'id': componentId, 'type': placeholderType}; + } + final serialized = {'id': componentId, 'type': type}; + for (final MapEntry entry in _props.peek().entries) { + serialized[entry.key] = _serializeValue(entry.value); + } + return serialized; + } +} + +/// Whether two prop values are the same by the emission gate's standards: +/// reference identity, with value equality for primitives (equal strings are +/// not always [identical], so identity alone would report equal-value +/// updates as changes). +bool sameValue(Object? a, Object? b) { + if (identical(a, b)) return true; + if (a is String && b is String) return a == b; + if (a is num && b is num) return a == b; + if (a is bool && b is bool) return a == b; + return false; +} + +Object? _serializeValue(Object? value) { + if (value is ComponentNode) { + return value.toJson(); + } + if (value is Function) { + return ''; + } + if (value is List) { + return value.map(_serializeValue).toList(); + } + if (value is Map) { + return { + for (final MapEntry entry in value.entries) + entry.key as String: _serializeValue(entry.value), + }; + } + return value; +} + +bool _shallowEqual(NodeProps a, NodeProps b) { + if (identical(a, b)) { + return true; + } + if (a.length != b.length) { + return false; + } + for (final MapEntry entry in a.entries) { + if (!b.containsKey(entry.key) || !sameValue(entry.value, b[entry.key])) { + return false; + } + } + return true; +} diff --git a/packages/a2ui_core/lib/src/nodes/node_resolver.dart b/packages/a2ui_core/lib/src/nodes/node_resolver.dart new file mode 100644 index 000000000..eaafdb8f1 --- /dev/null +++ b/packages/a2ui_core/lib/src/nodes/node_resolver.dart @@ -0,0 +1,542 @@ +// Copyright 2025 The Flutter Authors. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:json_schema_builder/json_schema_builder.dart'; + +import '../core/catalog.dart'; +import '../core/component_model.dart'; +import '../core/contexts.dart'; +import '../core/messages.dart'; +import '../core/surface_model.dart'; +import '../primitives/errors.dart'; +import '../primitives/reactivity.dart'; +import '../rendering/binder.dart'; +import 'component_node.dart'; +import 'ref_fields.dart'; + +const String _rootComponentId = 'root'; +const String _rootDataPath = '/'; +const String _rootEdgeKey = '>root>root@/'; + +class _NodeRecord { + final ComponentNode node; + final String edgeKey; + + /// The node whose props reference this one; null for the root. + final ComponentNode? parent; + final RefFields refFields; + final ComponentModel? componentModel; + final GenericBinder? binder; + void Function()? binderUnsubscribe; + + /// Children this node currently references, keyed by edge. This parent + /// owns their disposal. + Map childEdges = {}; + + _NodeRecord({ + required this.node, + required this.edgeKey, + required this.parent, + required this.refFields, + this.componentModel, + this.binder, + }); +} + +/// The tree engine of the node layer: turns a surface's flat component map +/// into a live tree of resolved [ComponentNode]s rooted at [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 the same catalog instance the surface was +/// constructed with. Resolution executes catalog functions, so it needs a +/// catalog with implementations; [Catalog] only holds +/// [FunctionImplementation]s, so every constructable catalog qualifies. +/// +/// 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. +class NodeResolver { + final SurfaceModel _surface; + final Catalog _catalog; + + final Signal _rootNode = signal(null); + + /// The resolved root of the tree; null until the root component arrives. + ReadonlySignal get rootNode => _rootNode; + + final Map _records = {}; + final Map _nodesByEdge = {}; + final Map> _nodesByComponentId = {}; + + /// Parents holding a placeholder for a component id, awaiting its arrival. + final Map> _pendingParents = {}; + + late final void Function(ComponentModel) _onCreatedListener; + late final void Function(String) _onDeletedListener; + _NodeRecord? _rootRecord; + bool _disposed = false; + + NodeResolver(this._surface, this._catalog) { + if (!identical(_catalog, _surface.catalog)) { + throw A2uiStateError( + 'NodeResolver requires the same catalog instance its surface was ' + 'constructed with.', + ); + } + _onCreatedListener = _onComponentCreated; + _onDeletedListener = _onComponentDeleted; + _surface.componentsModel.onCreated.addListener(_onCreatedListener); + _surface.componentsModel.onDeleted.addListener(_onDeletedListener); + + if (_surface.componentsModel.get(_rootComponentId) != null) { + _buildRoot(); + } + } + + /// Number of live nodes (including placeholders). Exposed for tests and + /// devtools. + int get activeNodeCount => _records.length; + + bool get disposed => _disposed; + + /// Tears down the whole tree and stops tracking the surface. Idempotent. + void dispose() { + if (_disposed) { + return; + } + _disposed = true; + _surface.componentsModel.onCreated.removeListener(_onCreatedListener); + _surface.componentsModel.onDeleted.removeListener(_onDeletedListener); + for (final ComponentNode node in List.of(_records.keys)) { + _disposeNode(node); + } + _pendingParents.clear(); + _rootRecord = null; + _rootNode.value = null; + } + + void _buildRoot() { + final ComponentNode node = _createNode( + _rootComponentId, + _rootDataPath, + _rootEdgeKey, + null, + ); + _rootRecord = _records[node]; + _rootNode.value = node; + } + + void _onComponentCreated(ComponentModel component) { + if (_disposed) { + return; + } + if (component.id == _rootComponentId && _rootRecord == null) { + _buildRoot(); + } + final Set? waiting = _pendingParents.remove(component.id); + if (waiting != null) { + for (final ComponentNode parent in waiting) { + final _NodeRecord? record = _records[parent]; + if (record != null && !parent.disposed) { + _materialize(record); + } + } + } + } + + void _onComponentDeleted(String id) { + if (_disposed) { + return; + } + final Set? affected = _nodesByComponentId[id]; + if (affected == null) { + return; + } + final parentsToRefresh = {}; + var rootDeleted = false; + for (final ComponentNode node in List.of(affected)) { + final _NodeRecord? record = _records[node]; + if (record == null) { + continue; + } + if (record.parent != null) { + parentsToRefresh.add(record.parent!); + } else { + rootDeleted = true; + } + } + if (rootDeleted && _rootRecord != null) { + _disposeNode(_rootRecord!.node); + _rootRecord = null; + _rootNode.value = null; + } + for (final parent in parentsToRefresh) { + final _NodeRecord? record = _records[parent]; + if (record != null && !parent.disposed) { + _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. + ComponentNode _createNode( + String componentId, + String dataPath, + String edgeKey, + ComponentNode? parent, + ) { + final ComponentModel? model = _surface.componentsModel.get(componentId); + if (model == null) { + final _NodeRecord record = _registerNode( + _placeholderNode(componentId, dataPath), + edgeKey: edgeKey, + parent: parent, + refFields: RefFields.empty, + ); + if (parent != null) { + _pendingParents.putIfAbsent(componentId, () => {}).add(parent); + } + return record.node; + } + + final T? api = _catalog.components[model.type]; + if (api == null) { + _surface.dispatchError( + A2uiClientError( + code: 'UNKNOWN_COMPONENT_TYPE', + surfaceId: _surface.id, + message: + "Component '$componentId' has type '${model.type}', which is " + "not in catalog '${_catalog.id}'.", + ), + ); + return _registerNode( + _placeholderNode(componentId, dataPath), + edgeKey: edgeKey, + parent: parent, + refFields: RefFields.empty, + ).node; + } + + final Schema schema = api.schema; + final binder = GenericBinder( + ComponentContext(_surface, model, basePath: dataPath), + schema, + ); + final _NodeRecord record = _registerNode( + ComponentNode( + _instanceIdFor(componentId, dataPath), + componentId, + model.type, + dataPath, + const {}, + ), + edgeKey: edgeKey, + parent: parent, + refFields: extractRefFields(schema), + componentModel: model, + binder: binder, + ); + // subscribe fires synchronously with the current value, which seeds the + // first materialization. + record.binderUnsubscribe = binder.resolvedProps.subscribe((_) { + _materialize(record); + }); + return record.node; + } + + ComponentNode _placeholderNode(String componentId, String dataPath) { + return ComponentNode( + _instanceIdFor(componentId, dataPath), + componentId, + placeholderType, + dataPath, + const {}, + ); + } + + _NodeRecord _registerNode( + ComponentNode node, { + required String edgeKey, + required ComponentNode? parent, + required RefFields refFields, + ComponentModel? componentModel, + GenericBinder? binder, + }) { + final record = _NodeRecord( + node: node, + edgeKey: edgeKey, + parent: parent, + refFields: refFields, + componentModel: componentModel, + binder: binder, + ); + _records[node] = record; + _nodesByEdge[edgeKey] = node; + _nodesByComponentId.putIfAbsent(node.componentId, () => {}).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. + ComponentNode _childNode( + String componentId, + String dataPath, + String edgeKey, + ComponentNode parent, + ) { + final ComponentNode? existing = _nodesByEdge[edgeKey]; + if (_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 != null && !existing.disposed && existing.isPlaceholder) { + return existing; + } + if (existing != null && !existing.disposed) { + _disposeNode(existing); + } + _surface.dispatchError( + A2uiClientError( + code: 'CYCLIC_REFERENCE', + surfaceId: _surface.id, + message: + "Component '$componentId' at '$dataPath' is referenced by one " + 'of its own descendants; rendering a placeholder instead.', + ), + ); + return _registerNode( + _placeholderNode(componentId, dataPath), + edgeKey: edgeKey, + parent: parent, + refFields: RefFields.empty, + ).node; + } + if (existing != null && !existing.disposed) { + final ComponentModel? model = _surface.componentsModel.get(componentId); + final T? api = model == null ? null : _catalog.components[model.type]; + // 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. + final bool upToDate = + existing.componentId == componentId && + existing.dataPath == dataPath && + (existing.isPlaceholder + ? model == null || api == null + : model != null && existing.type == model.type); + if (upToDate) { + return existing; + } + _disposeNode(existing); + } + return _createNode(componentId, dataPath, edgeKey, parent); + } + + /// True when (componentId, dataPath) already appears in the parent chain. + bool _isCyclic(String componentId, String dataPath, ComponentNode parent) { + for ( + ComponentNode? node = parent; + node != null; + node = _records[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. + void _materialize(_NodeRecord record) { + if (record.node.disposed) { + return; + } + final Map raw = + record.binder?.resolvedProps.peek() ?? const {}; + final next = Map.from(raw); + final newEdges = {}; + + ComponentNode resolveChild( + String slot, + String componentId, + String dataPath, + ) { + final edgeKey = '${record.edgeKey}>$slot>$componentId@$dataPath'; + final ComponentNode child = _childNode( + componentId, + dataPath, + edgeKey, + record.node, + ); + newEdges[edgeKey] = child; + return child; + } + + for (final String key in record.refFields.single) { + final Object? value = next[key]; + if (value is String && value.isNotEmpty) { + next[key] = resolveChild(key, value, record.node.dataPath); + } + } + + for (final String key in record.refFields.list) { + final Object? value = next[key]; + if (value is! List) { + continue; + } + next[key] = List.generate(value.length, (index) { + final Object? item = value[index]; + if (item is ChildNode) { + return resolveChild('$key[$index]', item.id, item.basePath); + } + if (item is String && item.isNotEmpty) { + return resolveChild('$key[$index]', item, record.node.dataPath); + } + return item; + }); + } + + for (final MapEntry> nested + in record.refFields.nested.entries) { + final Object? value = next[nested.key]; + if (value is! List) { + continue; + } + next[nested.key] = List.generate(value.length, (index) { + final Object? item = value[index]; + if (item is! Map) { + return item; + } + Map? resolved; + for (final String subKey in nested.value) { + final Object? childId = item[subKey]; + if (childId is String && childId.isNotEmpty) { + resolved ??= Map.from(item); + resolved[subKey] = resolveChild( + '${nested.key}[$index].$subKey', + childId, + record.node.dataPath, + ); + } + } + return resolved ?? item; + }); + } + + for (final MapEntry edge + in record.childEdges.entries) { + if (newEdges.containsKey(edge.key)) { + continue; + } + final ComponentNode child = edge.value; + _disposeNode(child); + if (child.isPlaceholder) { + final bool stillWaiting = newEdges.values.any( + (other) => + other.isPlaceholder && other.componentId == child.componentId, + ); + if (!stillWaiting) { + _pendingParents[child.componentId]?.remove(record.node); + } + } + } + record.childEdges = newEdges; + + final NodeProps previous = record.node.props.peek(); + for (final String key in List.of(next.keys)) { + next[key] = _stabilize(previous[key], next[key]); + } + record.node.setProps(next); + } + + /// Disposes a node and, through parent-scoped ownership, its subtree. + void _disposeNode(ComponentNode node) { + if (node.disposed) { + return; + } + final _NodeRecord? record = _records[node]; + if (record != null) { + for (final ComponentNode child in record.childEdges.values) { + _disposeNode(child); + } + record.childEdges.clear(); + record.binderUnsubscribe?.call(); + record.binderUnsubscribe = null; + record.binder?.dispose(); + if (identical(_nodesByEdge[record.edgeKey], node)) { + _nodesByEdge.remove(record.edgeKey); + } + _records.remove(node); + } + final Set? byId = _nodesByComponentId[node.componentId]; + if (byId != null) { + byId.remove(node); + if (byId.isEmpty) { + _nodesByComponentId.remove(node.componentId); + } + } + for (final Set waiting in _pendingParents.values) { + waiting.remove(node); + } + node.dispose(); + } +} + +String _instanceIdFor(String componentId, String dataPath) { + if (dataPath == _rootDataPath) { + return componentId; + } + String trimmed = dataPath.replaceAll(RegExp(r'/+$'), ''); + if (trimmed.isEmpty) { + trimmed = _rootDataPath; + } + return '$componentId-[$trimmed]'; +} + +/// Returns `prev` whenever `next` is structurally identical to it, so +/// unchanged props keep reference identity across rebuilds. Child +/// [ComponentNode]s, action closures, and other non-container objects +/// compare by identity. +Object? _stabilize(Object? prev, Object? next) { + if (sameValue(prev, next)) { + return next; + } + if (prev is ComponentNode || next is ComponentNode) { + return next; + } + if (prev is List && next is List && prev.length == next.length) { + var allSame = true; + final out = List.generate(next.length, (index) { + final Object? stabilized = _stabilize(prev[index], next[index]); + if (!sameValue(stabilized, prev[index])) { + allSame = false; + } + return stabilized; + }); + return allSame ? prev : out; + } + if (prev is Map && next is Map && prev.length == next.length) { + var allSame = true; + final out = {}; + for (final MapEntry entry in next.entries) { + final key = entry.key as String; + final Object? stabilized = _stabilize(prev[key], entry.value); + out[key] = stabilized; + if (!prev.containsKey(key) || !sameValue(stabilized, prev[key])) { + allSame = false; + } + } + return allSame ? prev : out; + } + return next; +} diff --git a/packages/a2ui_core/lib/src/nodes/ref_fields.dart b/packages/a2ui_core/lib/src/nodes/ref_fields.dart new file mode 100644 index 000000000..073bbf02f --- /dev/null +++ b/packages/a2ui_core/lib/src/nodes/ref_fields.dart @@ -0,0 +1,154 @@ +// Copyright 2025 The Flutter Authors. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:json_schema_builder/json_schema_builder.dart'; + +/// The `REF:` description pointers from `CommonSchemas` that mark a property +/// as referencing child components. The same convention the capabilities +/// generator resolves into wire `$ref`s, reused here as the machine-readable +/// classification source. +const String _componentIdRef = r'REF:common_types.json#/$defs/ComponentId'; +const String _childListRef = r'REF:common_types.json#/$defs/ChildList'; + +/// Which properties of a component's schema reference child components. +class RefFields { + /// Properties holding a single child component id. + final Set single; + + /// Properties holding a `ChildList` (static id list or template). + final Set list; + + /// Properties holding a list of plain objects in which some keys are + /// single child references (e.g. a tab strip's `items[].child`), mapped to + /// those keys. + final Map> nested; + + const RefFields({ + required this.single, + required this.list, + required this.nested, + }); + + static const RefFields empty = RefFields(single: {}, list: {}, nested: {}); +} + +/// Memoized per underlying schema map ([Schema] is an extension type over +/// that map, so the map is the only stable runtime identity). A component +/// whose `schema` getter builds a fresh [Schema] per read defeats the memo, +/// so callers should read the getter once and share the instance. +final Expando _cache = Expando(); + +/// Derives the [RefFields] of a component schema. +/// +/// Detection is by the `REF:` description pointers above, plus the same +/// structural test the binder uses for `ChildList` unions (a combinator +/// member with both `componentId` and `path` properties), so catalogs that +/// build their own child-list schema need no pointer for list properties. +RefFields extractRefFields(Schema schema) { + final RefFields? cached = _cache[schema.value]; + if (cached != null) { + return cached; + } + + final Map properties = _mergedProperties( + _collectSchemas(schema.value), + ); + if (properties.isEmpty) { + _cache[schema.value] = RefFields.empty; + return RefFields.empty; + } + + final single = {}; + final list = {}; + final nested = >{}; + + for (final MapEntry entry in properties.entries) { + final Object? value = entry.value; + if (value is! Map) { + continue; + } + final List> propertySchemas = _collectSchemas(value); + if (_hasMarker(propertySchemas, _childListRef) || + propertySchemas.any(_isChildListShape)) { + list.add(entry.key); + continue; + } + if (_hasMarker(propertySchemas, _componentIdRef)) { + single.add(entry.key); + continue; + } + final Object? items = value['items']; + if (value['type'] == 'array' && items is Map) { + final Map itemProperties = _mergedProperties( + _collectSchemas(items), + ); + final subKeys = {}; + for (final MapEntry subEntry in itemProperties.entries) { + final Object? subValue = subEntry.value; + if (subValue is Map && + _hasMarker(_collectSchemas(subValue), _componentIdRef)) { + subKeys.add(subEntry.key); + } + } + if (subKeys.isNotEmpty) { + nested[entry.key] = subKeys; + } + } + } + + final result = RefFields(single: single, list: list, nested: nested); + _cache[schema.value] = result; + return result; +} + +/// Flattens a schema and its `allOf`/`anyOf`/`oneOf` members, mirroring the +/// binder's behavior scrape so both see the same shape. +List> _collectSchemas(Map schema) { + final collected = >[]; + void collect(Map current) { + collected.add(current); + for (final combinator in const ['allOf', 'anyOf', 'oneOf']) { + final Object? members = current[combinator]; + if (members is List) { + for (final Object? member in members) { + if (member is Map) { + collect(member); + } + } + } + } + } + + collect(schema); + return collected; +} + +Map _mergedProperties(List> schemas) { + final merged = {}; + for (final schema in schemas) { + final Object? properties = schema['properties']; + if (properties is Map) { + for (final MapEntry entry in properties.entries) { + merged[entry.key as String] = entry.value; + } + } + } + return merged; +} + +bool _hasMarker(List> schemas, String marker) { + return schemas.any((schema) { + final Object? description = schema['description']; + return description is String && description.startsWith(marker); + }); +} + +/// Matches the binder's structural detection for `ChildList`-shaped schemas: +/// an object with both `componentId` and `path` properties. +bool _isChildListShape(Map schema) { + final Object? properties = schema['properties']; + return properties is Map && + properties['componentId'] != null && + properties['path'] != null; +} diff --git a/packages/a2ui_core/pubspec.yaml b/packages/a2ui_core/pubspec.yaml index 455cd4f26..8440b9a4c 100644 --- a/packages/a2ui_core/pubspec.yaml +++ b/packages/a2ui_core/pubspec.yaml @@ -15,6 +15,7 @@ environment: dependencies: collection: ^1.18.0 json_schema_builder: ^0.1.3 + logging: ^1.3.0 meta: ^1.17.0 preact_signals: ^1.9.4 diff --git a/packages/a2ui_core/test/node_resolver_test.dart b/packages/a2ui_core/test/node_resolver_test.dart new file mode 100644 index 000000000..968fce6da --- /dev/null +++ b/packages/a2ui_core/test/node_resolver_test.dart @@ -0,0 +1,923 @@ +// Copyright 2025 The Flutter Authors. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +/// Conformance suite for the node layer, ported from web_core's +/// `node-resolver.test.ts` (itself ported from the Python reference +/// `test_node_graph.py` plus defect coverage: eager action resolution, +/// shared-node use-after-dispose, and whole-list template respawn). +/// +/// Known divergences from the TypeScript suite: +/// - The Dart binder synthesizes `setX` setters only for path-bound dynamic +/// props; web_core's synthesizes them for every two-way dynamic prop, so +/// the serialization test here expects no setters for literal values. +/// - The compile-time rejection of a schema-only catalog has no Dart +/// equivalent: `Catalog` only holds `FunctionImplementation`s, so a +/// signature-only catalog is not constructable at all. +library; + +import 'package:a2ui_core/a2ui_core.dart'; +import 'package:json_schema_builder/json_schema_builder.dart'; +import 'package:test/test.dart'; + +class _TestComponentApi extends ComponentApi { + @override + final String name; + @override + final Schema schema; + + _TestComponentApi(this.name, this.schema); +} + +class _ShoutFunction extends FunctionImplementation { + @override + String get name => 'shout'; + + @override + A2uiReturnType get returnType => A2uiReturnType.string; + + @override + Schema get argumentSchema => Schema.object( + properties: {'value': Schema.string()}, + required: ['value'], + ); + + @override + Object? execute( + Map args, + DataContext context, [ + CancellationSignal? cancellationSignal, + ]) { + return args['value'].toString().toUpperCase(); + } +} + +class _Opaque { + final int marker; + _Opaque(this.marker); +} + +Catalog makeCatalog() { + return Catalog( + id: 'node-test-catalog', + components: [ + _TestComponentApi( + 'Text', + Schema.object(properties: {'text': CommonSchemas.dynamicString}), + ), + _TestComponentApi( + 'Button', + Schema.object( + properties: { + 'label': CommonSchemas.dynamicString, + 'action': CommonSchemas.action, + }, + ), + ), + _TestComponentApi( + 'Card', + Schema.object(properties: {'child': CommonSchemas.componentId}), + ), + _TestComponentApi( + 'Column', + Schema.object(properties: {'children': CommonSchemas.childList}), + ), + _TestComponentApi( + 'Tabs', + Schema.object( + properties: { + 'items': Schema.list( + items: Schema.object( + properties: { + 'title': Schema.string(), + 'child': CommonSchemas.componentId, + }, + ), + ), + }, + ), + ), + ], + functions: [_ShoutFunction()], + ); +} + +typedef TestSetup = ({ + Catalog catalog, + SurfaceModel surface, + NodeResolver resolver, +}); + +TestSetup setup() { + final Catalog catalog = makeCatalog(); + final surface = SurfaceModel('surf-1', catalog: catalog); + final resolver = NodeResolver(surface, catalog); + return (catalog: catalog, surface: surface, resolver: resolver); +} + +void add( + SurfaceModel surface, + String id, + String type, + Map properties, +) { + surface.componentsModel.addComponent(ComponentModel(id, type, properties)); +} + +NodeProps props(ComponentNode node) => node.props.peek(); + +ComponentNode child(ComponentNode node, String key, [int? index]) { + final Object? value = index == null + ? props(node)[key] + : (props(node)[key] as List)[index]; + expect( + value, + isA(), + reason: 'expected $key[${index ?? ''}] to be a ComponentNode', + ); + return value as ComponentNode; +} + +/// Counts emissions of a signal, excluding the subscription-time run. +class EmissionCounter { + int _count = -1; + late final void Function() dispose; + + EmissionCounter(ReadonlySignal signal) { + dispose = effect(() { + signal.value; + _count++; + }); + } + + int get count => _count; +} + +Future flush() => Future.delayed(Duration.zero); + +void main() { + group('NodeResolver conformance (port of node-resolver.test.ts)', () { + test('resolves the root and upgrades and downgrades referenced children ' + '(lifecycle)', () { + final ( + catalog: Catalog catalog, + surface: SurfaceModel surface, + resolver: NodeResolver resolver, + ) = setup(); + add(surface, 'root', 'Column', { + 'children': ['child_1'], + }); + final ComponentNode? root = resolver.rootNode.value; + expect(root, isNotNull); + expect(root!.type, 'Column'); + expect(child(root, 'children', 0).type, placeholderType); + + add(surface, 'child_1', 'Text', {'text': 'Hello Node'}); + final ComponentNode upgraded = child(root, 'children', 0); + expect(upgraded.type, 'Text'); + expect(props(upgraded)['text'], 'Hello Node'); + + surface.componentsModel.removeComponent('child_1'); + expect(child(root, 'children', 0).type, placeholderType); + expect(upgraded.disposed, isTrue); + resolver.dispose(); + surface.dispose(); + }); + + test('tracks root creation and removal on rootNode', () { + final ( + catalog: Catalog catalog, + surface: SurfaceModel surface, + resolver: NodeResolver resolver, + ) = setup(); + expect(resolver.rootNode.value, isNull); + + add(surface, 'root', 'Column', {'children': []}); + final ComponentNode? root = resolver.rootNode.value; + expect(root, isA()); + expect(root!.componentId, 'root'); + expect(root.type, 'Column'); + + surface.componentsModel.removeComponent('root'); + expect(resolver.rootNode.value, isNull); + expect(root.disposed, isTrue); + resolver.dispose(); + }); + + test('exposes core node properties', () { + final ( + catalog: Catalog catalog, + surface: SurfaceModel surface, + resolver: NodeResolver resolver, + ) = setup(); + add(surface, 'root', 'Card', {'child': 'text-1'}); + add(surface, 'text-1', 'Text', {'text': 'Hi'}); + final ComponentNode root = resolver.rootNode.value!; + expect(root.instanceId, 'root'); + expect(root.dataPath, '/'); + final ComponentNode textNode = child(root, 'child'); + expect(textNode.instanceId, 'text-1'); + expect(textNode.componentId, 'text-1'); + expect(textNode.type, 'Text'); + expect(textNode.dataPath, '/'); + resolver.dispose(); + }); + + test('resolves data-bound properties reactively', () { + final ( + catalog: Catalog catalog, + surface: SurfaceModel surface, + resolver: NodeResolver resolver, + ) = setup(); + surface.dataModel.set('/username', 'Alice'); + add(surface, 'root', 'Text', { + 'text': {'path': '/username'}, + }); + final ComponentNode root = resolver.rootNode.value!; + expect(props(root)['text'], 'Alice'); + + surface.dataModel.set('/username', 'Bob'); + expect(props(root)['text'], 'Bob'); + resolver.dispose(); + }); + + test('resolves a single child reference to a live node', () { + final ( + catalog: Catalog catalog, + surface: SurfaceModel surface, + resolver: NodeResolver resolver, + ) = setup(); + add(surface, 'root', 'Card', {'child': 'text-1'}); + add(surface, 'text-1', 'Text', {'text': 'Hello'}); + final ComponentNode root = resolver.rootNode.value!; + final ComponentNode textNode = child(root, 'child'); + expect(textNode.type, 'Text'); + expect(props(textNode)['text'], 'Hello'); + resolver.dispose(); + }); + + test('resolves an explicit children list in order', () { + final ( + catalog: Catalog catalog, + surface: SurfaceModel surface, + resolver: NodeResolver resolver, + ) = setup(); + add(surface, 'root', 'Column', { + 'children': ['c1', 'c2'], + }); + add(surface, 'c1', 'Text', {'text': 'C1'}); + add(surface, 'c2', 'Text', {'text': 'C2'}); + final ComponentNode root = resolver.rootNode.value!; + final List children = (props(root)['children'] as List) + .cast(); + expect(children, hasLength(2)); + expect(props(children[0])['text'], 'C1'); + expect(props(children[1])['text'], 'C2'); + resolver.dispose(); + }); + + test('spawns one node per array item for a template child list', () { + final ( + catalog: Catalog catalog, + surface: SurfaceModel surface, + resolver: NodeResolver 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'}, + }); + final ComponentNode root = resolver.rootNode.value!; + final List children = (props(root)['children'] as List) + .cast(); + expect(children, hasLength(2)); + expect(children[0].instanceId, 'item_tpl-[/items/0]'); + expect(children[0].dataPath, '/items/0'); + expect(props(children[0])['text'], 'A'); + expect(props(children[1])['text'], 'B'); + resolver.dispose(); + }); + + test('renders placeholders progressively and emits the parent exactly once ' + 'on upgrade', () { + final ( + catalog: Catalog catalog, + surface: SurfaceModel surface, + resolver: NodeResolver resolver, + ) = setup(); + add(surface, 'root', 'Column', { + 'children': ['late'], + }); + final ComponentNode root = resolver.rootNode.value!; + final ComponentNode placeholder = child(root, 'children', 0); + expect(placeholder.type, placeholderType); + expect(placeholder.componentId, 'late'); + + var destroyed = 0; + placeholder.onDestroyed.addListener((_) { + destroyed++; + }); + final emissions = EmissionCounter(root.props); + + add(surface, 'late', 'Text', {'text': 'Arrived'}); + expect(emissions.count, 1); + final ComponentNode upgraded = child(root, 'children', 0); + expect(identical(upgraded, placeholder), isFalse); + expect(upgraded.type, 'Text'); + expect(props(upgraded)['text'], 'Arrived'); + expect(placeholder.disposed, isTrue); + expect(destroyed, 1); + emissions.dispose(); + resolver.dispose(); + }); + + test( + 'binds actions as closures that dispatch through the surface', + () async { + final ( + catalog: Catalog catalog, + surface: SurfaceModel surface, + resolver: NodeResolver resolver, + ) = setup(); + final actions = []; + surface.onAction.addListener(actions.add); + surface.dataModel.set('/current_id', 42); + add(surface, 'root', 'Button', { + 'label': 'Go', + 'action': { + 'event': { + 'name': 'submit', + 'context': { + 'itemId': {'path': '/current_id'}, + }, + }, + }, + }); + final ComponentNode root = resolver.rootNode.value!; + final Object? fire = props(root)['action']; + expect(fire, isA()); + (fire as Function)(); + await flush(); + + expect(actions, hasLength(1)); + expect(actions[0].name, 'submit'); + expect(actions[0].surfaceId, 'surf-1'); + expect(actions[0].sourceComponentId, 'root'); + expect(actions[0].context, {'itemId': 42}); + resolver.dispose(); + }, + ); + + test('resolves an unresolved binding to null without failing', () { + final ( + catalog: Catalog catalog, + surface: SurfaceModel surface, + resolver: NodeResolver resolver, + ) = setup(); + add(surface, 'root', 'Text', { + 'text': {'path': '/missing'}, + }); + final ComponentNode root = resolver.rootNode.value!; + expect(props(root)['text'], isNull); + resolver.dispose(); + }); + + test( + 'reconciles explicit children list changes, reusing surviving nodes', + () { + final ( + catalog: Catalog catalog, + surface: SurfaceModel surface, + resolver: NodeResolver 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'}); + final ComponentNode root = resolver.rootNode.value!; + final List before = (props(root)['children'] as List) + .cast(); + + surface.componentsModel.get('root')!.properties = { + 'children': ['c1', 'c3'], + }; + + final List after = (props(root)['children'] as List) + .cast(); + expect(after, hasLength(2)); + expect(identical(after[0], before[0]), isTrue); + expect(props(after[1])['text'], 'C3'); + expect(before[1].disposed, isTrue); + resolver.dispose(); + }, + ); + + test('reconciles a swap from explicit children to a template', () { + final ( + catalog: Catalog catalog, + surface: SurfaceModel surface, + resolver: NodeResolver 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'}, + }); + final ComponentNode root = resolver.rootNode.value!; + final ComponentNode explicitChild = child(root, 'children', 0); + + surface.componentsModel.get('root')!.properties = { + 'children': {'componentId': 'item_tpl', 'path': '/items'}, + }; + + final List children = (props(root)['children'] as List) + .cast(); + expect(children, hasLength(1)); + expect(props(children[0])['text'], 'T0'); + expect(explicitChild.disposed, isTrue); + resolver.dispose(); + }); + + test('resolves function-call bindings reactively', () { + final ( + catalog: Catalog catalog, + surface: SurfaceModel surface, + resolver: NodeResolver resolver, + ) = setup(); + surface.dataModel.set('/username', 'alice'); + add(surface, 'root', 'Text', { + 'text': { + 'call': 'shout', + 'args': { + 'value': {'path': '/username'}, + }, + }, + }); + final ComponentNode root = resolver.rootNode.value!; + expect(props(root)['text'], 'ALICE'); + + surface.dataModel.set('/username', 'bob'); + expect(props(root)['text'], 'BOB'); + resolver.dispose(); + }); + + test('resolves nested child references inside item arrays', () { + final ( + catalog: Catalog catalog, + surface: SurfaceModel surface, + resolver: NodeResolver 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'}); + final ComponentNode root = resolver.rootNode.value!; + final List> items = (props(root)['items'] as List) + .cast>(); + expect(items, hasLength(2)); + expect(items[0]['title'], 'One'); + final Object? first = items[0]['child']; + expect(first, isA()); + expect(props(first as ComponentNode)['text'], 'First'); + final Object? second = items[1]['child']; + expect(second, isA()); + expect(props(second as ComponentNode)['text'], 'Second'); + resolver.dispose(); + }); + + test('reconciles a deleted component back to a placeholder, leaving ' + 'siblings alone', () { + final ( + catalog: Catalog catalog, + surface: SurfaceModel surface, + resolver: NodeResolver resolver, + ) = setup(); + add(surface, 'root', 'Column', { + 'children': ['c1', 'c2'], + }); + add(surface, 'c1', 'Text', {'text': 'C1'}); + add(surface, 'c2', 'Text', {'text': 'C2'}); + final ComponentNode root = resolver.rootNode.value!; + final List before = (props(root)['children'] as List) + .cast(); + + surface.componentsModel.removeComponent('c2'); + + final List after = (props(root)['children'] as List) + .cast(); + expect(identical(after[0], before[0]), isTrue); + expect(after[1].type, placeholderType); + expect(before[1].disposed, isTrue); + resolver.dispose(); + }); + + test( + 're-spawns template children as the bound array grows and shrinks', + () { + final ( + catalog: Catalog catalog, + surface: SurfaceModel surface, + resolver: NodeResolver 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'}, + }); + final ComponentNode root = resolver.rootNode.value!; + expect(props(root)['children'] as List, hasLength(1)); + + surface.dataModel.set('/items', [ + {'name': 'A'}, + {'name': 'B'}, + {'name': 'C'}, + ]); + final List grown = (props(root)['children'] as List) + .cast(); + expect(grown, hasLength(3)); + expect(props(grown[2])['text'], 'C'); + + surface.dataModel.set('/items', [ + {'name': 'A'}, + ]); + final List shrunk = (props(root)['children'] as List) + .cast(); + expect(shrunk, hasLength(1)); + expect(grown[1].disposed, isTrue); + expect(grown[2].disposed, isTrue); + resolver.dispose(); + }, + ); + + test('serializes the resolved tree, rendering actions and placeholders ' + 'specially', () { + final ( + catalog: Catalog catalog, + surface: SurfaceModel surface, + resolver: NodeResolver 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'}, + }, + }); + final ComponentNode root = resolver.rootNode.value!; + // Unlike web_core, the Dart binder synthesizes setters only for + // path-bound props, so the literal-valued Text and Button carry none. + expect(root.toJson(), { + 'id': 'root', + 'type': 'Column', + 'children': [ + { + 'id': 'card', + 'type': 'Card', + 'child': {'id': 'txt', 'type': 'Text', 'text': 'Hello'}, + }, + {'id': 'btn', 'type': 'Button', 'label': 'Go', 'action': ''}, + {'id': 'late', 'type': placeholderType}, + ], + }); + resolver.dispose(); + }); + }); + + group('NodeResolver defect coverage (fixes over the Python reference)', () { + test('resolves action context at dispatch time, not bind time ' + '(late resolution)', () async { + final ( + catalog: Catalog catalog, + surface: SurfaceModel surface, + resolver: NodeResolver resolver, + ) = setup(); + final actions = []; + surface.onAction.addListener(actions.add); + surface.dataModel.set('/current_id', 'stale'); + add(surface, 'root', 'Button', { + 'action': { + 'event': { + 'name': 'submit', + 'context': { + 'itemId': {'path': '/current_id'}, + }, + }, + }, + }); + final ComponentNode root = resolver.rootNode.value!; + + surface.dataModel.set('/current_id', 'fresh'); + (props(root)['action'] as Function)(); + await flush(); + + expect(actions, hasLength(1)); + expect(actions[0].context, {'itemId': 'fresh'}); + resolver.dispose(); + }); + + test('keeps a shared child alive for one parent when the other stops ' + 'referencing it', () { + final ( + catalog: Catalog catalog, + surface: SurfaceModel surface, + resolver: NodeResolver 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'}, + }); + final ComponentNode root = resolver.rootNode.value!; + final ComponentNode cardA = child(root, 'children', 0); + final ComponentNode cardB = child(root, 'children', 1); + final ComponentNode sharedViaA = child(cardA, 'child'); + final ComponentNode sharedViaB = child(cardB, 'child'); + expect(identical(sharedViaA, sharedViaB), isFalse); + + surface.componentsModel.get('card_a')!.properties = {}; + + expect(sharedViaA.disposed, isTrue); + expect(sharedViaB.disposed, isFalse); + surface.dataModel.set('/label', 'still updating'); + expect(props(sharedViaB)['text'], 'still updating'); + resolver.dispose(); + }); + + test('keeps surviving template nodes across array growth and shrink ' + '(key stability)', () { + final ( + catalog: Catalog catalog, + surface: SurfaceModel surface, + resolver: NodeResolver 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'}, + }); + final ComponentNode root = resolver.rootNode.value!; + final before = List.of( + (props(root)['children'] as List).cast(), + ); + + surface.dataModel.set('/items', [ + {'name': 'A'}, + {'name': 'B'}, + {'name': 'C'}, + ]); + final List grown = (props(root)['children'] as List) + .cast(); + expect(identical(grown[0], before[0]), isTrue); + expect(identical(grown[1], before[1]), isTrue); + expect(before[0].disposed, isFalse); + expect(before[1].disposed, isFalse); + + surface.dataModel.set('/items', [ + {'name': 'A'}, + ]); + final List shrunk = (props(root)['children'] as List) + .cast(); + expect(identical(shrunk[0], before[0]), isTrue); + expect(before[1].disposed, isTrue); + resolver.dispose(); + }); + + test('does not emit a parent props signal when only a child property ' + 'changes (no bubbling)', () { + final ( + catalog: Catalog catalog, + surface: SurfaceModel surface, + resolver: NodeResolver 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'}, + }); + final ComponentNode root = resolver.rootNode.value!; + final ComponentNode boundText = child(root, 'children', 0); + final ComponentNode templateColumn = child(root, 'children', 1); + final ComponentNode item0 = child(templateColumn, 'children', 0); + + final rootEmissions = EmissionCounter(root.props); + final templateColumnEmissions = EmissionCounter(templateColumn.props); + final boundEmissions = EmissionCounter(boundText.props); + final item0Emissions = EmissionCounter(item0.props); + + surface.dataModel.set('/username', 'Bob'); + expect(boundEmissions.count, 1); + expect(props(boundText)['text'], 'Bob'); + expect(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'); + expect(props(item0)['text'], 'A2'); + expect(item0Emissions.count, greaterThanOrEqualTo(1)); + expect(templateColumnEmissions.count, 0); + expect(rootEmissions.count, 0); + + rootEmissions.dispose(); + templateColumnEmissions.dispose(); + boundEmissions.dispose(); + item0Emissions.dispose(); + resolver.dispose(); + }); + }); + + group('NodeResolver malformed and unusual payloads', () { + test('renders cyclic references as placeholders instead of recursing', () { + final ( + catalog: Catalog catalog, + surface: SurfaceModel surface, + resolver: NodeResolver resolver, + ) = setup(); + final errors = []; + surface.onError.addListener(errors.add); + add(surface, 'root', 'Card', {'child': 'a'}); + add(surface, 'a', 'Card', {'child': 'b'}); + add(surface, 'b', 'Card', {'child': 'a'}); + + final ComponentNode root = resolver.rootNode.value!; + final ComponentNode a = child(root, 'child'); + final ComponentNode b = child(a, 'child'); + final ComponentNode backReference = child(b, 'child'); + expect(backReference.type, placeholderType); + expect(backReference.componentId, 'a'); + expect(errors.any((e) => e.code == 'CYCLIC_REFERENCE'), isTrue); + expect(resolver.activeNodeCount, lessThanOrEqualTo(5)); + resolver.dispose(); + }); + + test('renders a self-referencing component as a placeholder child', () { + final ( + catalog: Catalog catalog, + surface: SurfaceModel surface, + resolver: NodeResolver resolver, + ) = setup(); + add(surface, 'root', 'Card', {'child': 'root'}); + final ComponentNode root = resolver.rootNode.value!; + expect(child(root, 'child').type, placeholderType); + resolver.dispose(); + }); + + test('propagates changes to non-plain object values', () { + // Class instances have no entries for key-wise stabilization to + // compare, so the engine must treat them as always-changed. + final ( + catalog: Catalog catalog, + surface: SurfaceModel surface, + resolver: NodeResolver resolver, + ) = setup(); + final first = _Opaque(1); + final second = _Opaque(2); + surface.dataModel.set('/blob', {'wrapper': first}); + add(surface, 'root', 'Text', { + 'text': {'path': '/blob'}, + }); + final ComponentNode root = resolver.rootNode.value!; + expect(identical((props(root)['text'] as Map)['wrapper'], first), isTrue); + + surface.dataModel.set('/blob', {'wrapper': second}); + expect( + identical((props(root)['text'] as Map)['wrapper'], second), + isTrue, + ); + resolver.dispose(); + }); + + test('keeps a stable placeholder for a component whose type is not in the ' + 'catalog', () { + final ( + catalog: Catalog catalog, + surface: SurfaceModel surface, + resolver: NodeResolver resolver, + ) = setup(); + final errors = []; + surface.onError.addListener(errors.add); + add(surface, 'root', 'Card', {'child': 'weird'}); + add(surface, 'weird', 'Bogus', {}); + + final ComponentNode root = resolver.rootNode.value!; + final ComponentNode placeholder = child(root, 'child'); + expect(placeholder.type, placeholderType); + final int reportsBefore = errors + .where((e) => e.code == 'UNKNOWN_COMPONENT_TYPE') + .length; + + surface.componentsModel.get('root')!.properties = {'child': 'weird'}; + surface.componentsModel.get('root')!.properties = {'child': 'weird'}; + + expect(identical(child(root, 'child'), placeholder), isTrue); + expect( + errors.where((e) => e.code == 'UNKNOWN_COMPONENT_TYPE').length, + reportsBefore, + ); + resolver.dispose(); + }); + }); + + group('NodeResolver construction gate', () { + test('rejects a catalog instance other than the surface catalog', () { + final Catalog catalogA = makeCatalog(); + final Catalog catalogB = makeCatalog(); + final surface = SurfaceModel('surf-1', catalog: catalogA); + expect( + () => NodeResolver(surface, catalogB), + throwsA( + isA().having( + (e) => e.message, + 'message', + contains('same catalog instance'), + ), + ), + ); + }); + + test( + 'disposes the whole tree with the resolver, leaving no live nodes', + () { + final ( + catalog: Catalog catalog, + surface: SurfaceModel surface, + resolver: NodeResolver 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'}, + }); + final ComponentNode root = resolver.rootNode.value!; + expect(resolver.activeNodeCount, greaterThanOrEqualTo(6)); + + resolver.dispose(); + expect(resolver.activeNodeCount, 0); + expect(resolver.rootNode.value, isNull); + expect(root.disposed, isTrue); + + // A data change after disposal must not resurrect any binding. + surface.dataModel.set('/items', [ + {'name': 'X'}, + ]); + expect(resolver.activeNodeCount, 0); + }, + ); + }); +} From a82ac498fcdccd55d5351f94741a1c25dd47a7fe Mon Sep 17 00:00:00 2001 From: Andrew Kolos Date: Wed, 15 Jul 2026 07:02:42 -0700 Subject: [PATCH 2/6] feat(genui): add NodeSurface, a node-layer-backed sibling of Surface NodeSurface renders the live resolved tree from a NodeResolver instead of rebuilding from an immutable SurfaceDefinition snapshot: each node's subtree rebuilds only when that node's own resolved properties change. Catalog views are reused unchanged; the node layer supplies resolved children (template expansion, placeholder upgrades, list reconciliation), and child references the schema does not mark fall back to the legacy id-walking path. --- packages/genui/lib/src/widgets.dart | 1 + .../genui/lib/src/widgets/node_surface.dart | 385 ++++++++++++++++++ .../genui/test/widgets/node_surface_test.dart | 196 +++++++++ 3 files changed, 582 insertions(+) create mode 100644 packages/genui/lib/src/widgets/node_surface.dart create mode 100644 packages/genui/test/widgets/node_surface_test.dart diff --git a/packages/genui/lib/src/widgets.dart b/packages/genui/lib/src/widgets.dart index 5d9821650..dd78f40aa 100644 --- a/packages/genui/lib/src/widgets.dart +++ b/packages/genui/lib/src/widgets.dart @@ -10,5 +10,6 @@ library; export 'widgets/fallback_widget.dart'; +export 'widgets/node_surface.dart'; export 'widgets/surface.dart'; export 'widgets/widget_utilities.dart'; diff --git a/packages/genui/lib/src/widgets/node_surface.dart b/packages/genui/lib/src/widgets/node_surface.dart new file mode 100644 index 000000000..efab5a0bb --- /dev/null +++ b/packages/genui/lib/src/widgets/node_surface.dart @@ -0,0 +1,385 @@ +// Copyright 2025 The Flutter Authors. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:a2ui_core/a2ui_core.dart' as core; +import 'package:collection/collection.dart'; +import 'package:flutter/widgets.dart'; + +import '../model/catalog.dart'; +import '../model/catalog_item.dart'; +import '../model/data_model.dart'; +import '../model/ui_models.dart'; +import '../primitives/logging.dart'; +import '../primitives/simple_items.dart'; +import 'fallback_widget.dart'; +import 'surface.dart'; + +/// Experimental sibling of [Surface] backed by the a2ui_core node layer. +/// +/// Where [Surface] rebuilds the whole widget tree from an immutable +/// [SurfaceDefinition] snapshot on every update, this widget renders the +/// live resolved tree from a [core.NodeResolver]: each node's subtree +/// rebuilds only when that node's own resolved properties change. +/// +/// Catalog views are reused unchanged. They receive the component's raw +/// properties and bind dynamic values and dispatch actions themselves, +/// exactly as under [Surface]; the node layer contributes the children: +/// child-reference properties arrive as resolved nodes (templates expanded +/// one node per data item, missing components as placeholders swapped in +/// place, list changes reconciled with surviving nodes reused). Properties +/// the engine cannot classify as child references fall back to the legacy +/// id-walking path, so catalogs work before their schemas declare +/// references. +class NodeSurface extends StatefulWidget { + /// Creates a [NodeSurface]. + const NodeSurface({ + super.key, + required this.surface, + required this.catalog, + required this.onEvent, + this.defaultBuilder, + this.reportError, + }); + + /// The live surface model to resolve and render. + final core.SurfaceModel surface; + + /// The catalog providing the widget builders. + final Catalog catalog; + + /// Called with every UI event dispatched from this surface's widgets. + final UiEventCallback onEvent; + + /// A builder for the widget to display before the root component arrives. + final WidgetBuilder? defaultBuilder; + + /// Called when building a component fails. Defaults to logging. + final void Function(Object error, StackTrace? stackTrace)? reportError; + + @override + State createState() => _NodeSurfaceState(); +} + +class _NodeSurfaceState extends State { + late core.NodeResolver _resolver; + late InMemoryDataModel _dataModel; + + @override + void initState() { + super.initState(); + _attach(); + } + + @override + void didUpdateWidget(NodeSurface oldWidget) { + super.didUpdateWidget(oldWidget); + if (!identical(oldWidget.surface, widget.surface)) { + _detach(); + _attach(); + } + } + + @override + void dispose() { + _detach(); + super.dispose(); + } + + void _attach() { + _dataModel = InMemoryDataModel.wrap(widget.surface.dataModel); + _resolver = core.NodeResolver( + widget.surface, + widget.surface.catalog, + ); + } + + void _detach() { + _resolver.dispose(); + _dataModel.dispose(); + } + + @override + Widget build(BuildContext context) { + return _SignalBuilder( + signal: _resolver.rootNode, + builder: (context, root) { + if (root == null) { + return widget.defaultBuilder?.call(context) ?? + const SizedBox.shrink(); + } + return _buildNode(root); + }, + ); + } + + Widget _buildNode(core.ComponentNode node) { + return _SignalBuilder( + // Keyed by node identity: when the resolver replaces a node (a + // placeholder upgrade, an id change), the old element's subscription + // is disposed with it. + key: ObjectKey(node), + signal: node.props, + builder: (context, resolvedProps) => + _buildNodeWidget(context, node, resolvedProps), + ); + } + + Widget _buildNodeWidget( + BuildContext context, + core.ComponentNode node, + core.NodeProps resolvedProps, + ) { + if (node.isPlaceholder) { + // The parent re-emits with the real node when the definition arrives. + return const SizedBox.shrink(); + } + try { + final core.ComponentModel? model = widget.surface.componentsModel.get( + node.componentId, + ); + if (model == null) { + return const SizedBox.shrink(); + } + + final JsonMap data = JsonMap.from(model.properties); + final childByInstanceId = {}; + + String adopt(core.ComponentNode child) { + childByInstanceId[child.instanceId] = child; + return child.instanceId; + } + + final core.ComponentApi? api = + widget.surface.catalog.components[node.type]; + final core.RefFields refFields = api == null + ? core.RefFields.empty + : core.extractRefFields(api.schema); + + for (final String key in refFields.single) { + final Object? value = resolvedProps[key]; + if (value is core.ComponentNode) { + data[key] = adopt(value); + } + } + for (final String key in refFields.list) { + final Object? value = resolvedProps[key]; + if (value is List) { + data[key] = [ + for (final Object? item in value) + if (item is core.ComponentNode) adopt(item) else item, + ]; + } + } + for (final MapEntry> nested + in refFields.nested.entries) { + final Object? value = resolvedProps[nested.key]; + if (value is List) { + data[nested.key] = [ + for (final Object? item in value) + if (item is Map) + { + for (final MapEntry entry in item.entries) + entry.key as String: entry.value is core.ComponentNode + ? adopt(entry.value as core.ComponentNode) + : entry.value, + } + else + item, + ]; + } + } + + final dataContext = DataContext( + _dataModel, + DataPath(node.dataPath), + functions: widget.catalog.functions, + ); + + return _buildCatalogWidget( + context: context, + componentId: node.componentId, + type: node.type, + data: data, + dataContext: dataContext, + buildChild: (String id, [DataContext? childDataContext]) { + final core.ComponentNode? child = childByInstanceId[id]; + if (child != null) { + return _buildNode(child); + } + // The schema did not mark this property as a child reference, so + // the engine resolved no node for it; walk the raw definition the + // way [Surface] does. + return _buildLegacyWidget( + context, + id, + childDataContext ?? dataContext, + ); + }, + ); + } catch (exception, stackTrace) { + _reportError(exception, stackTrace); + return FallbackWidget(error: exception, stackTrace: stackTrace); + } + } + + /// The legacy fallback: renders a component and its descendants directly + /// from the raw definitions, bypassing the node layer. Used for child + /// references the engine could not classify from the schema. + Widget _buildLegacyWidget( + BuildContext context, + String componentId, + DataContext dataContext, + ) { + try { + final core.ComponentModel? model = widget.surface.componentsModel.get( + componentId, + ); + if (model == null) { + return const SizedBox.shrink(); + } + return _buildCatalogWidget( + context: context, + componentId: componentId, + type: model.type, + data: JsonMap.from(model.properties), + dataContext: dataContext, + buildChild: (String id, [DataContext? childDataContext]) => + _buildLegacyWidget(context, id, childDataContext ?? dataContext), + ); + } catch (exception, stackTrace) { + _reportError(exception, stackTrace); + return FallbackWidget(error: exception, stackTrace: stackTrace); + } + } + + Widget _buildCatalogWidget({ + required BuildContext context, + required String componentId, + required String type, + required JsonMap data, + required DataContext dataContext, + required ChildBuilderCallback buildChild, + }) { + return widget.catalog.buildWidget( + CatalogItemContext( + id: componentId, + type: type, + data: data, + buildChild: buildChild, + dispatchEvent: _dispatchEvent, + buildContext: context, + dataContext: dataContext, + getComponent: _getComponent, + getCatalogItem: (String type) => + widget.catalog.items.firstWhereOrNull((item) => item.name == type), + surfaceId: widget.surface.id, + reportError: _reportError, + ), + ); + } + + /// Resolves both raw component ids and node instance ids (which layout + /// views receive as children and pass back for weight lookups). + Component? _getComponent(String id) { + final String componentId = _instanceComponentId(id); + final core.ComponentModel? model = widget.surface.componentsModel.get( + componentId, + ); + return model == null ? null : Component.fromCore(model); + } + + /// A template-spawned instance id has the scoped data path appended + /// (`item-card-[/items/0]`); everywhere else the instance id is the + /// component id. + static String _instanceComponentId(String id) { + if (!id.endsWith(']')) { + return id; + } + final int marker = id.lastIndexOf('-['); + return marker == -1 ? id : id.substring(0, marker); + } + + void _dispatchEvent(UiEvent event) { + final Map eventMap = { + ...event.toMap(), + surfaceIdKey: widget.surface.id, + }; + final UiEvent newEvent = event.isUserAction + ? UserActionEvent.fromMap(eventMap) + : UiEvent.fromMap(eventMap); + widget.onEvent(newEvent); + } + + void _reportError(Object error, StackTrace? stackTrace) { + if (widget.reportError != null) { + widget.reportError!(error, stackTrace); + return; + } + genUiLogger.severe( + 'Error building node surface ${widget.surface.id}', + error, + stackTrace, + ); + } +} + +/// Rebuilds when a [core.ReadonlySignal] emits a new value. +class _SignalBuilder extends StatefulWidget { + const _SignalBuilder({ + super.key, + required this.signal, + required this.builder, + }); + + final core.ReadonlySignal signal; + final Widget Function(BuildContext context, T value) builder; + + @override + State<_SignalBuilder> createState() => _SignalBuilderState(); +} + +class _SignalBuilderState extends State<_SignalBuilder> { + late T _value; + void Function()? _unsubscribe; + + @override + void initState() { + super.initState(); + _subscribe(); + } + + @override + void didUpdateWidget(_SignalBuilder oldWidget) { + super.didUpdateWidget(oldWidget); + if (!identical(oldWidget.signal, widget.signal)) { + _unsubscribe?.call(); + _subscribe(); + } + } + + @override + void dispose() { + _unsubscribe?.call(); + super.dispose(); + } + + void _subscribe() { + _value = widget.signal.peek(); + // subscribe fires synchronously with the current value; the identity + // check absorbs that first call so no setState happens during + // initState/didUpdateWidget. + _unsubscribe = widget.signal.subscribe((value) { + if (identical(value, _value)) { + return; + } + _value = value; + if (mounted) { + setState(() {}); + } + }); + } + + @override + Widget build(BuildContext context) => widget.builder(context, _value); +} diff --git a/packages/genui/test/widgets/node_surface_test.dart b/packages/genui/test/widgets/node_surface_test.dart new file mode 100644 index 000000000..4072acb85 --- /dev/null +++ b/packages/genui/test/widgets/node_surface_test.dart @@ -0,0 +1,196 @@ +// Copyright 2025 The Flutter Authors. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:a2ui_core/a2ui_core.dart' as core; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:genui/genui.dart'; +// coreCatalogFor is internal; this test exercises the same wiring +// SurfaceController performs. +import 'package:genui/src/model/catalog.dart' show coreCatalogFor; + +void main() { + final testCatalog = Catalog([ + BasicCatalogItems.text, + BasicCatalogItems.column, + BasicCatalogItems.button, + ], catalogId: 'test_catalog'); + + (core.SurfaceModel, List) createSurface() { + final core.Catalog coreCatalog = coreCatalogFor( + testCatalog, + ); + final surface = core.SurfaceModel( + 'node-surface-test', + catalog: coreCatalog, + ); + return (surface, []); + } + + Widget host( + core.SurfaceModel surface, + List events, + ) { + return MaterialApp( + home: NodeSurface( + surface: surface, + catalog: testCatalog, + onEvent: events.add, + ), + ); + } + + void add( + core.SurfaceModel surface, + String id, + String type, + Map properties, + ) { + surface.componentsModel.addComponent( + core.ComponentModel(id, type, properties), + ); + } + + testWidgets('renders a column of texts through the node path', ( + WidgetTester tester, + ) async { + final (core.SurfaceModel surface, List events) = + createSurface(); + add(surface, 'root', 'Column', { + 'children': ['t1', 't2'], + }); + add(surface, 't1', 'Text', {'text': 'First'}); + add(surface, 't2', 'Text', {'text': 'Second'}); + + await tester.pumpWidget(host(surface, events)); + await tester.pumpAndSettle(); + + expect(find.text('First'), findsOneWidget); + expect(find.text('Second'), findsOneWidget); + expect(find.byType(Column), findsWidgets); + }); + + testWidgets('upgrades a placeholder in place when its component arrives', ( + WidgetTester tester, + ) async { + final (core.SurfaceModel surface, List events) = + createSurface(); + add(surface, 'root', 'Column', { + 'children': ['late'], + }); + + await tester.pumpWidget(host(surface, events)); + await tester.pumpAndSettle(); + expect(find.text('Arrived'), findsNothing); + + add(surface, 'late', 'Text', {'text': 'Arrived'}); + await tester.pumpAndSettle(); + expect(find.text('Arrived'), findsOneWidget); + }); + + testWidgets('updates a data-bound text when the data model changes', ( + WidgetTester tester, + ) async { + final (core.SurfaceModel surface, List events) = + createSurface(); + surface.dataModel.set('/message', 'Hello'); + add(surface, 'root', 'Text', { + 'text': {'path': '/message'}, + }); + + await tester.pumpWidget(host(surface, events)); + await tester.pumpAndSettle(); + expect(find.text('Hello'), findsOneWidget); + + surface.dataModel.set('/message', 'Goodbye'); + await tester.pumpAndSettle(); + expect(find.text('Goodbye'), findsOneWidget); + expect(find.text('Hello'), findsNothing); + }); + + testWidgets('expands a template child list, one node per data item', ( + WidgetTester tester, + ) async { + final (core.SurfaceModel surface, List events) = + createSurface(); + surface.dataModel.set('/items', [ + {'name': 'Alpha'}, + {'name': 'Beta'}, + ]); + add(surface, 'root', 'Column', { + 'children': {'componentId': 'item_tpl', 'path': '/items'}, + }); + add(surface, 'item_tpl', 'Text', { + 'text': {'path': 'name'}, + }); + + await tester.pumpWidget(host(surface, events)); + await tester.pumpAndSettle(); + expect(find.text('Alpha'), findsOneWidget); + expect(find.text('Beta'), findsOneWidget); + + surface.dataModel.set('/items', [ + {'name': 'Alpha'}, + {'name': 'Beta'}, + {'name': 'Gamma'}, + ]); + await tester.pumpAndSettle(); + expect(find.text('Gamma'), findsOneWidget); + }); + + testWidgets( + 'renders unmarked single-child references through the legacy fallback ' + 'and dispatches actions', + (WidgetTester tester) async { + final ( + core.SurfaceModel surface, + List events, + ) = createSurface(); + add(surface, 'root', 'Button', { + 'child': 'label', + 'action': { + 'event': {'name': 'pressed'}, + }, + }); + add(surface, 'label', 'Text', {'text': 'Press me'}); + + await tester.pumpWidget(host(surface, events)); + await tester.pumpAndSettle(); + expect(find.text('Press me'), findsOneWidget); + + await tester.tap(find.byType(ElevatedButton)); + await tester.pumpAndSettle(); + + expect(events, hasLength(1)); + expect(events.single.isUserAction, isTrue); + expect(events.single.surfaceId, 'node-surface-test'); + }, + ); + + testWidgets('reconciles an explicit children list change', ( + WidgetTester tester, + ) async { + final (core.SurfaceModel surface, List events) = + createSurface(); + add(surface, 'root', 'Column', { + 'children': ['t1', 't2'], + }); + add(surface, 't1', 'Text', {'text': 'Keep'}); + add(surface, 't2', 'Text', {'text': 'Drop'}); + add(surface, 't3', 'Text', {'text': 'New'}); + + await tester.pumpWidget(host(surface, events)); + await tester.pumpAndSettle(); + expect(find.text('Keep'), findsOneWidget); + expect(find.text('Drop'), findsOneWidget); + + surface.componentsModel.get('root')!.properties = { + 'children': ['t1', 't3'], + }; + await tester.pumpAndSettle(); + expect(find.text('Keep'), findsOneWidget); + expect(find.text('Drop'), findsNothing); + expect(find.text('New'), findsOneWidget); + }); +} From d19dd9bb8737f997616ead98d0bf1c0f3f2fae57 Mon Sep 17 00:00:00 2001 From: Andrew Kolos Date: Wed, 15 Jul 2026 07:57:40 -0700 Subject: [PATCH 3/6] feat(genui): wire NodeSurface into simple_chat behind a dart-define SurfaceController gains liveSurfaceFor, exposing the live core surface model NodeSurface renders from. simple_chat renders surfaces through NodeSurface when built with --dart-define=nodes=true; the default build is unchanged. NodeSurface logs on attach so runs can confirm which path rendered. --- .../simple_chat/lib/primitives/message.dart | 48 ++++++++++++++++++- .../lib/src/engine/surface_controller.dart | 7 +++ .../genui/lib/src/widgets/node_surface.dart | 4 ++ 3 files changed, 58 insertions(+), 1 deletion(-) diff --git a/examples/simple_chat/lib/primitives/message.dart b/examples/simple_chat/lib/primitives/message.dart index b7e767c7a..8ee6a2ebc 100644 --- a/examples/simple_chat/lib/primitives/message.dart +++ b/examples/simple_chat/lib/primitives/message.dart @@ -2,10 +2,15 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. +import 'package:a2ui_core/a2ui_core.dart' as core; import 'package:flutter/material.dart'; import 'package:flutter_markdown_plus/flutter_markdown_plus.dart'; import 'package:genui/genui.dart'; +/// Renders surfaces through the experimental node layer (`NodeSurface`) +/// instead of `Surface`. Enable with `--dart-define=nodes=true`. +const bool _useNodeLayer = bool.fromEnvironment('nodes'); + class Message { Message({this.text, this.surfaceId, this.isUser = false}) : assert((surfaceId == null) != (text == null)); @@ -40,6 +45,47 @@ class MessageView extends StatelessWidget { host != null, 'A SurfaceHost is required to render surface $surfaceId', ); - return Surface(surfaceContext: host!.contextFor(surfaceId)); + final SurfaceHost surfaceHost = host!; + if (_useNodeLayer && surfaceHost is SurfaceController) { + return _NodeLayerMessageSurface( + controller: surfaceHost, + surfaceId: surfaceId, + ); + } + return Surface(surfaceContext: surfaceHost.contextFor(surfaceId)); + } +} + +/// Renders a surface through [NodeSurface] once its core model exists, +/// watching the definition snapshot only to learn about creation. +class _NodeLayerMessageSurface extends StatelessWidget { + const _NodeLayerMessageSurface({ + required this.controller, + required this.surfaceId, + }); + + final SurfaceController controller; + final String surfaceId; + + @override + Widget build(BuildContext context) { + final SurfaceContext surfaceContext = controller.contextFor(surfaceId); + return ValueListenableBuilder( + valueListenable: surfaceContext.definition, + builder: (context, definition, _) { + final core.SurfaceModel? surface = controller + .liveSurfaceFor(surfaceId); + final Catalog? catalog = surfaceContext.catalog; + if (definition == null || surface == null || catalog == null) { + return const SizedBox.shrink(); + } + return NodeSurface( + surface: surface, + catalog: catalog, + onEvent: surfaceContext.handleUiEvent, + reportError: surfaceContext.reportError, + ); + }, + ); } } diff --git a/packages/genui/lib/src/engine/surface_controller.dart b/packages/genui/lib/src/engine/surface_controller.dart index 4a7c2ec19..498bcdf59 100644 --- a/packages/genui/lib/src/engine/surface_controller.dart +++ b/packages/genui/lib/src/engine/surface_controller.dart @@ -106,6 +106,13 @@ interface class SurfaceController implements SurfaceHost, A2uiMessageSink { /// The registry of surfaces managed by this controller. surface_reg.SurfaceRegistry get registry => _registry; + /// The live core surface model for [surfaceId], or null if the surface + /// does not exist. Experimental: exists to construct a `NodeSurface`, + /// which renders from the live model instead of [SurfaceContext.definition] + /// snapshots. + core.SurfaceModel? liveSurfaceFor(String surfaceId) => + _registry.getLiveSurface(surfaceId); + DataModel _dataModelFor(String surfaceId) { final DataModel? live = _liveDataModels[surfaceId]; if (live != null) return live; diff --git a/packages/genui/lib/src/widgets/node_surface.dart b/packages/genui/lib/src/widgets/node_surface.dart index efab5a0bb..ebc8ae044 100644 --- a/packages/genui/lib/src/widgets/node_surface.dart +++ b/packages/genui/lib/src/widgets/node_surface.dart @@ -87,6 +87,10 @@ class _NodeSurfaceState extends State { } void _attach() { + genUiLogger.info( + 'NodeSurface attached to surface ${widget.surface.id}; rendering ' + 'through the node layer', + ); _dataModel = InMemoryDataModel.wrap(widget.surface.dataModel); _resolver = core.NodeResolver( widget.surface, From 54c7a4140bdfbbd6f4baa945b6fde73542b48ad2 Mon Sep 17 00:00:00 2001 From: Andrew Kolos Date: Wed, 22 Jul 2026 19:15:46 -0700 Subject: [PATCH 4/6] fix(a2ui_core): align functionCall and missing-function handling with the node-layer contract Resolve a missing catalog function to null with an EXPRESSION_ERROR event instead of an uncaught throw during node creation, and stop re-processing a functionCall action's result in SurfaceModel.dispatchAction (the action closure already executes it through the evaluator). Adds conformance tests for both. --- packages/a2ui_core/lib/src/core/contexts.dart | 43 ++++-- .../a2ui_core/lib/src/core/surface_model.dart | 12 +- .../a2ui_core/test/node_resolver_test.dart | 122 ++++++++++++++++++ 3 files changed, 157 insertions(+), 20 deletions(-) diff --git a/packages/a2ui_core/lib/src/core/contexts.dart b/packages/a2ui_core/lib/src/core/contexts.dart index ca18ac4d1..07f40336e 100644 --- a/packages/a2ui_core/lib/src/core/contexts.dart +++ b/packages/a2ui_core/lib/src/core/contexts.dart @@ -2,11 +2,13 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. +import '../primitives/errors.dart'; import '../primitives/reactivity.dart'; import 'catalog.dart'; import 'common.dart'; import 'component_model.dart'; import 'data_model.dart'; +import 'messages.dart'; import 'surface_model.dart'; /// A function that invokes a catalog function by name. @@ -24,11 +26,14 @@ typedef FunctionInvoker = /// paths like `/users/0/name`. Also evaluates data bindings and /// function calls. class DataContext { + final SurfaceModel surface; final DataModel dataModel; final FunctionInvoker _invoke; final String path; - DataContext(this.dataModel, this._invoke, this.path); + DataContext(this.surface, this.path) + : dataModel = surface.dataModel, + _invoke = surface.catalog.invoke; String resolvePath(String relativePath) { if (relativePath.startsWith('/')) return relativePath; @@ -52,7 +57,7 @@ class DataContext { for (final MapEntry entry in call.args.entries) { args[entry.key] = resolveSync(entry.value); } - final Object? result = _invoke(call.call, args, this); + final Object? result = _evaluateFunction(call.call, args); if (result is ReadonlySignal) { return result.value; } @@ -87,7 +92,7 @@ class DataContext { ); args[entry.key] = resolved.value; } - final Object? result = _invoke(call.call, args, this); + final Object? result = _evaluateFunction(call.call, args); if (result is ReadonlySignal) { return result.value; } @@ -97,8 +102,27 @@ class DataContext { return signal(value); } + /// Invokes a catalog function. A throw is reported as an EXPRESSION_ERROR + /// on the surface and resolves to null, so a bad function reference + /// degrades the bound value instead of crashing resolution. + Object? _evaluateFunction(String name, Map args) { + try { + return _invoke(name, args, this); + } catch (error) { + surface.dispatchError( + A2uiClientError( + code: 'EXPRESSION_ERROR', + surfaceId: surface.id, + message: error is A2uiError ? error.message : error.toString(), + details: error is A2uiExpressionError ? error.details : null, + ), + ); + return null; + } + } + DataContext nested(String relativePath) { - return DataContext(dataModel, _invoke, resolvePath(relativePath)); + return DataContext(surface, resolvePath(relativePath)); } void set(String relativePath, Object? value) { @@ -113,11 +137,7 @@ class ComponentContext { final DataContext dataContext; ComponentContext(this.surface, this.componentModel, {String? basePath}) - : dataContext = DataContext( - surface.dataModel, - surface.catalog.invoke, - basePath ?? '/', - ); + : dataContext = DataContext(surface, basePath ?? '/'); /// Dispatches an action from the component. Future dispatchAction(Map action) { @@ -143,7 +163,10 @@ extension CatalogInvokerExtension on Catalog { Object? invoke(String name, Map args, DataContext context) { final FunctionImplementation? fn = functions[name]; if (fn == null) { - throw ArgumentError('Function not found: $name'); + throw A2uiExpressionError( + "Function not found in catalog '$id': $name", + expression: name, + ); } return fn.execute(args, context); } diff --git a/packages/a2ui_core/lib/src/core/surface_model.dart b/packages/a2ui_core/lib/src/core/surface_model.dart index 5abc02aac..d9011be7a 100644 --- a/packages/a2ui_core/lib/src/core/surface_model.dart +++ b/packages/a2ui_core/lib/src/core/surface_model.dart @@ -6,9 +6,7 @@ import 'dart:async'; import '../primitives/event_notifier.dart'; import 'catalog.dart'; -import 'common.dart'; import 'component_model.dart'; -import 'contexts.dart'; import 'data_model.dart'; import 'messages.dart'; @@ -56,15 +54,9 @@ class SurfaceModel { ), ); _onAction.emit(action); - } else if (payload.containsKey('functionCall')) { - final callJson = payload['functionCall'] as Map; - final call = FunctionCall.fromJson(callJson); - catalog.invoke( - call.call, - Map.from(call.args), - DataContext(dataModel, catalog.invoke, '/'), - ); } + // functionCall actions are executed by the action closure while it + // resolves the payload; only server-bound events are emitted here. } /// Dispatches an error from this surface. diff --git a/packages/a2ui_core/test/node_resolver_test.dart b/packages/a2ui_core/test/node_resolver_test.dart index 968fce6da..4d616e667 100644 --- a/packages/a2ui_core/test/node_resolver_test.dart +++ b/packages/a2ui_core/test/node_resolver_test.dart @@ -52,6 +52,29 @@ class _ShoutFunction extends FunctionImplementation { } } +class _PingFunction extends FunctionImplementation { + int invocationCount = 0; + + @override + String get name => 'ping'; + + @override + A2uiReturnType get returnType => A2uiReturnType.string; + + @override + Schema get argumentSchema => Schema.object(properties: {}); + + @override + Object? execute( + Map args, + DataContext context, [ + CancellationSignal? cancellationSignal, + ]) { + invocationCount++; + return 'pong'; + } +} + class _Opaque { final int marker; _Opaque(this.marker); @@ -796,6 +819,28 @@ void main() { resolver.dispose(); }); + test('resolves a call to an unknown function to null and dispatches an ' + 'expression error instead of throwing', () { + final ( + catalog: Catalog catalog, + surface: SurfaceModel surface, + resolver: NodeResolver resolver, + ) = setup(); + final errors = []; + surface.onError.addListener(errors.add); + add(surface, 'root', 'Text', { + 'text': {'call': 'unregistered', 'args': {}}, + }); + + final ComponentNode root = resolver.rootNode.value!; + expect(props(root)['text'], isNull); + expect(errors, hasLength(1)); + expect(errors[0].code, 'EXPRESSION_ERROR'); + expect(errors[0].surfaceId, 'surf-1'); + expect(errors[0].message, contains('Function not found')); + resolver.dispose(); + }); + test('renders a self-referencing component as a placeholder child', () { final ( catalog: Catalog catalog, @@ -864,6 +909,83 @@ void main() { }); }); + group('NodeResolver functionCall actions', () { + test('executes a functionCall action through the evaluator when fired, ' + 'emitting no action event', () async { + final ping = _PingFunction(); + final catalog = Catalog( + id: 'function-action-catalog', + components: [ + _TestComponentApi( + 'Button', + Schema.object( + properties: { + 'label': CommonSchemas.dynamicString, + 'action': CommonSchemas.action, + }, + ), + ), + ], + functions: [ping], + ); + final surface = SurfaceModel('surf-1', catalog: catalog); + final resolver = NodeResolver(surface, catalog); + final actions = []; + final errors = []; + surface.onAction.addListener(actions.add); + surface.onError.addListener(errors.add); + add(surface, 'root', 'Button', { + 'label': 'Go', + 'action': { + 'functionCall': {'call': 'ping', 'args': {}}, + }, + }); + + final ComponentNode root = resolver.rootNode.value!; + expect(ping.invocationCount, 0); + final Object? fire = props(root)['action']; + expect(fire, isA()); + (fire as Function)(); + await flush(); + + expect(ping.invocationCount, 1); + expect(actions, isEmpty); + expect(errors, isEmpty); + resolver.dispose(); + }); + + test('resolves an unknown function in a functionCall action to an ' + 'expression error instead of throwing', () async { + final ( + catalog: Catalog catalog, + surface: SurfaceModel surface, + resolver: NodeResolver resolver, + ) = setup(); + final actions = []; + final errors = []; + surface.onAction.addListener(actions.add); + surface.onError.addListener(errors.add); + add(surface, 'root', 'Button', { + 'label': 'Go', + 'action': { + 'functionCall': {'call': 'unregistered', 'args': {}}, + }, + }); + + final ComponentNode root = resolver.rootNode.value!; + final Object? fire = props(root)['action']; + expect(fire, isA()); + (fire as Function)(); + await flush(); + + expect(errors, hasLength(1)); + expect(errors[0].code, 'EXPRESSION_ERROR'); + expect(errors[0].message, contains('Function not found')); + expect(actions, isEmpty); + resolver.dispose(); + }); + }); + group('NodeResolver construction gate', () { test('rejects a catalog instance other than the surface catalog', () { final Catalog catalogA = makeCatalog(); From 725bff4003d1008e13a4f242e3593cf171077c03 Mon Sep 17 00:00:00 2001 From: Andrew Kolos Date: Wed, 29 Jul 2026 07:57:54 -0700 Subject: [PATCH 5/6] feat(a2ui_core): resolve dynamic properties to ResolvedBinding Dynamic properties in node props now resolve to a ResolvedBinding: a snapshot of the current value plus a write capability present only when the payload bound a data path, so an unchecked write does not compile. Replaces the synthesized set closures. Bindings compare by snapshot value and writability, keeping the emission gate's no-op suppression exact. Serialization emits the snapshot value. Named ResolvedBinding because DataBinding is the wire model of the {"path"} payload spelling. --- packages/a2ui_core/lib/a2ui_core.dart | 1 + .../lib/src/nodes/component_node.dart | 18 ++- .../lib/src/nodes/resolved_binding.dart | 72 +++++++++ .../a2ui_core/lib/src/rendering/binder.dart | 26 ++-- .../a2ui_core/test/binder_lifecycle_test.dart | 7 +- packages/a2ui_core/test/binder_test.dart | 11 +- .../a2ui_core/test/node_resolver_test.dart | 143 ++++++++++++++---- 7 files changed, 228 insertions(+), 50 deletions(-) create mode 100644 packages/a2ui_core/lib/src/nodes/resolved_binding.dart diff --git a/packages/a2ui_core/lib/a2ui_core.dart b/packages/a2ui_core/lib/a2ui_core.dart index 3f1c42e60..41a137aa3 100644 --- a/packages/a2ui_core/lib/a2ui_core.dart +++ b/packages/a2ui_core/lib/a2ui_core.dart @@ -21,6 +21,7 @@ export 'src/core/surface_model.dart'; export 'src/nodes/component_node.dart'; export 'src/nodes/node_resolver.dart'; export 'src/nodes/ref_fields.dart'; +export 'src/nodes/resolved_binding.dart'; export 'src/primitives/cancellation.dart'; export 'src/primitives/data_path.dart'; export 'src/primitives/errors.dart'; diff --git a/packages/a2ui_core/lib/src/nodes/component_node.dart b/packages/a2ui_core/lib/src/nodes/component_node.dart index 8528d3877..bdddcec6d 100644 --- a/packages/a2ui_core/lib/src/nodes/component_node.dart +++ b/packages/a2ui_core/lib/src/nodes/component_node.dart @@ -6,6 +6,7 @@ import 'package:logging/logging.dart'; import '../primitives/event_notifier.dart'; import '../primitives/reactivity.dart'; +import 'resolved_binding.dart'; final _log = Logger('a2ui_core.nodes'); @@ -17,9 +18,11 @@ typedef NodeProps = Map; /// One resolved component instance in the rendered tree. /// -/// A node's [props] hold fully resolved values: primitives for dynamic -/// values, ready-to-call closures for actions, and live [ComponentNode] -/// references (or lists of them) for child properties. +/// A node's [props] hold fully resolved values: a [ResolvedBinding] +/// (snapshot plus optional write) for each dynamic value, ready-to-call +/// closures for actions, and live [ComponentNode] references (or lists of +/// them) for child properties. The tree is whatever is reachable from the +/// resolver's root; there is no separate graph structure or traversal API. /// /// Emission contract: [props] emits when this node's own resolved properties /// change, including when a child *reference* is replaced (a placeholder @@ -113,8 +116,9 @@ class ComponentNode { } /// Serializes the resolved tree for debugging and headless assertions. - /// Child nodes serialize recursively; action closures and setters - /// serialize as the string `''`. + /// Child nodes serialize recursively, bindings serialize as their + /// snapshot value, and action closures serialize as the string + /// `''`. Map toJson() { if (isPlaceholder) { return {'id': componentId, 'type': placeholderType}; @@ -136,6 +140,7 @@ bool sameValue(Object? a, Object? b) { if (a is String && b is String) return a == b; if (a is num && b is num) return a == b; if (a is bool && b is bool) return a == b; + if (a is ResolvedBinding && b is ResolvedBinding) return a == b; return false; } @@ -143,6 +148,9 @@ Object? _serializeValue(Object? value) { if (value is ComponentNode) { return value.toJson(); } + if (value is ResolvedBinding) { + return _serializeValue(value.value); + } if (value is Function) { return ''; } diff --git a/packages/a2ui_core/lib/src/nodes/resolved_binding.dart b/packages/a2ui_core/lib/src/nodes/resolved_binding.dart new file mode 100644 index 000000000..a19e6ba67 --- /dev/null +++ b/packages/a2ui_core/lib/src/nodes/resolved_binding.dart @@ -0,0 +1,72 @@ +// Copyright 2025 The Flutter Authors. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +/// A resolved two-way value in a node's props: a snapshot of the current +/// value, plus a write capability when (and only when) the payload bound a +/// data path. +/// +/// This is the Dart spelling of the cross-SDK `DataBinding` concept; the +/// name differs because `DataBinding` here is the wire model of the +/// `{"path": ...}` payload spelling. +/// +/// `set` is null when the payload supplied a literal or a function call, so +/// an unchecked write does not compile under sound null safety. A null `set` +/// maps directly onto disabled-control idioms (`onChanged: null`). +/// +/// The snapshot is pinned at emission: a new binding is emitted through the +/// node's props whenever the underlying value changes, so reading `value` +/// never observes a state no emission delivered. +final class ResolvedBinding { + final T value; + final void Function(T)? set; + + const ResolvedBinding(this.value, {this.set}); + + /// Whether writes have a destination (the payload bound a data path). + bool get writable => set != null; + + /// Bindings compare by snapshot value and writability, so the node + /// emission gate can suppress no-op updates even though each update + /// constructs a new binding instance. + @override + bool operator ==(Object other) => + other is ResolvedBinding && + writable == other.writable && + _valueEquals(value, other.value); + + @override + int get hashCode => Object.hash(_valueHash(value), writable); +} + +bool _valueEquals(Object? a, Object? b) { + if (identical(a, b)) return true; + if (a is List && b is List) { + if (a.length != b.length) return false; + for (var i = 0; i < a.length; i++) { + if (!_valueEquals(a[i], b[i])) return false; + } + return true; + } + if (a is Map && b is Map) { + if (a.length != b.length) return false; + for (final MapEntry entry in a.entries) { + if (!b.containsKey(entry.key) || + !_valueEquals(entry.value, b[entry.key])) { + return false; + } + } + return true; + } + return a == b; +} + +int _valueHash(Object? value) { + if (value is List) return Object.hashAll(value.map(_valueHash)); + if (value is Map) { + return Object.hashAllUnordered( + value.entries.map((e) => Object.hash(e.key, _valueHash(e.value))), + ); + } + return value.hashCode; +} diff --git a/packages/a2ui_core/lib/src/rendering/binder.dart b/packages/a2ui_core/lib/src/rendering/binder.dart index 0887f6726..f7b5645ef 100644 --- a/packages/a2ui_core/lib/src/rendering/binder.dart +++ b/packages/a2ui_core/lib/src/rendering/binder.dart @@ -7,6 +7,7 @@ import 'package:json_schema_builder/json_schema_builder.dart'; import '../core/common.dart'; import '../core/component_model.dart'; import '../core/contexts.dart'; +import '../nodes/resolved_binding.dart'; import '../primitives/reactivity.dart'; /// Represents the intended runtime behavior of a property parsed from @@ -88,14 +89,22 @@ class GenericBinder { case Behavior.dynamic: final ReadonlySignal sig = context.dataContext .resolveListenable(value); + final void Function(Object?)? write = + (value is Map && value.containsKey('path')) + ? ((newValue) => + context.dataContext.set(value['path'] as String, newValue)) + : null; if (!isSync) { _subscriptions.add( sig.subscribe((newValue) { - _updateDeepValue(path, newValue); + _updateDeepValue( + path, + ResolvedBinding(newValue, set: write), + ); }), ); } - return sig.value; + return ResolvedBinding(sig.value, set: write); case Behavior.action: return () async { @@ -223,19 +232,6 @@ class GenericBinder { result['validationErrors'] = errors; } - // Add setters for dynamic properties - for (final MapEntry entry in shape.entries) { - if (entry.value.type == Behavior.dynamic) { - final String key = entry.key; - final setterName = 'set${key[0].toUpperCase()}${key.substring(1)}'; - final Object? rawValue = value[key]; - if (rawValue is Map && rawValue.containsKey('path')) { - result[setterName] = (Object? newValue) { - context.dataContext.set(rawValue['path'] as String, newValue); - }; - } - } - } return result; case Behavior.array: diff --git a/packages/a2ui_core/test/binder_lifecycle_test.dart b/packages/a2ui_core/test/binder_lifecycle_test.dart index f273e68c0..da4ff2fca 100644 --- a/packages/a2ui_core/test/binder_lifecycle_test.dart +++ b/packages/a2ui_core/test/binder_lifecycle_test.dart @@ -26,13 +26,16 @@ void main() { final context = ComponentContext(surface, comp); final binder = GenericBinder(context, MinimalTextApi().schema); - expect(binder.resolvedProps.value['text'], 'initial'); + expect( + (binder.resolvedProps.value['text'] as ResolvedBinding).value, + 'initial', + ); binder.dispose(); surface.dataModel.set('/val', 'updated'); expect( - binder.resolvedProps.value['text'], + (binder.resolvedProps.value['text'] as ResolvedBinding).value, 'initial', reason: 'binder should not react after dispose', ); diff --git a/packages/a2ui_core/test/binder_test.dart b/packages/a2ui_core/test/binder_test.dart index 10ac27646..77c14983e 100644 --- a/packages/a2ui_core/test/binder_test.dart +++ b/packages/a2ui_core/test/binder_test.dart @@ -6,6 +6,7 @@ import 'package:a2ui_core/src/core/component_model.dart'; import 'package:a2ui_core/src/core/contexts.dart'; import 'package:a2ui_core/src/core/minimal_catalog.dart'; import 'package:a2ui_core/src/core/surface_model.dart'; +import 'package:a2ui_core/src/nodes/resolved_binding.dart'; import 'package:a2ui_core/src/rendering/binder.dart'; import 'package:test/test.dart'; @@ -29,10 +30,16 @@ void main() { final context = ComponentContext(surface, comp); final binder = GenericBinder(context, MinimalTextApi().schema); - expect(binder.resolvedProps.value['text'], 'initial'); + expect( + (binder.resolvedProps.value['text'] as ResolvedBinding).value, + 'initial', + ); surface.dataModel.set('/val', 'updated'); - expect(binder.resolvedProps.value['text'], 'updated'); + expect( + (binder.resolvedProps.value['text'] as ResolvedBinding).value, + 'updated', + ); }); test('resolves actions into callbacks', () async { diff --git a/packages/a2ui_core/test/node_resolver_test.dart b/packages/a2ui_core/test/node_resolver_test.dart index 4d616e667..e187ae6c6 100644 --- a/packages/a2ui_core/test/node_resolver_test.dart +++ b/packages/a2ui_core/test/node_resolver_test.dart @@ -8,9 +8,12 @@ /// shared-node use-after-dispose, and whole-list template respawn). /// /// Known divergences from the TypeScript suite: -/// - The Dart binder synthesizes `setX` setters only for path-bound dynamic -/// props; web_core's synthesizes them for every two-way dynamic prop, so -/// the serialization test here expects no setters for literal values. +/// - Dynamic properties resolve to a [ResolvedBinding] (snapshot value plus +/// a write capability only where the payload bound a path), implementing +/// the cross-SDK data-binding contract. web_core still resolves dynamic +/// properties to a plain value with a synthesized `setX` sibling; it +/// adopts the binding shape with the v1.0 patchset, since changing it is +/// breaking for published code. /// - The compile-time rejection of a schema-only catalog has no Dart /// equivalent: `Catalog` only holds `FunctionImplementation`s, so a /// signature-only catalog is not constructable at all. @@ -149,6 +152,10 @@ void add( NodeProps props(ComponentNode node) => node.props.peek(); +/// Unwraps the [ResolvedBinding] snapshot for a dynamic property. +Object? bound(ComponentNode node, String key) => + (props(node)[key] as ResolvedBinding?)?.value; + ComponentNode child(ComponentNode node, String key, [int? index]) { final Object? value = index == null ? props(node)[key] @@ -198,7 +205,7 @@ void main() { add(surface, 'child_1', 'Text', {'text': 'Hello Node'}); final ComponentNode upgraded = child(root, 'children', 0); expect(upgraded.type, 'Text'); - expect(props(upgraded)['text'], 'Hello Node'); + expect(bound(upgraded, 'text'), 'Hello Node'); surface.componentsModel.removeComponent('child_1'); expect(child(root, 'children', 0).type, placeholderType); @@ -257,10 +264,10 @@ void main() { 'text': {'path': '/username'}, }); final ComponentNode root = resolver.rootNode.value!; - expect(props(root)['text'], 'Alice'); + expect(bound(root, 'text'), 'Alice'); surface.dataModel.set('/username', 'Bob'); - expect(props(root)['text'], 'Bob'); + expect(bound(root, 'text'), 'Bob'); resolver.dispose(); }); @@ -275,7 +282,7 @@ void main() { final ComponentNode root = resolver.rootNode.value!; final ComponentNode textNode = child(root, 'child'); expect(textNode.type, 'Text'); - expect(props(textNode)['text'], 'Hello'); + expect(bound(textNode, 'text'), 'Hello'); resolver.dispose(); }); @@ -294,8 +301,8 @@ void main() { final List children = (props(root)['children'] as List) .cast(); expect(children, hasLength(2)); - expect(props(children[0])['text'], 'C1'); - expect(props(children[1])['text'], 'C2'); + expect(bound(children[0], 'text'), 'C1'); + expect(bound(children[1], 'text'), 'C2'); resolver.dispose(); }); @@ -321,8 +328,8 @@ void main() { expect(children, hasLength(2)); expect(children[0].instanceId, 'item_tpl-[/items/0]'); expect(children[0].dataPath, '/items/0'); - expect(props(children[0])['text'], 'A'); - expect(props(children[1])['text'], 'B'); + expect(bound(children[0], 'text'), 'A'); + expect(bound(children[1], 'text'), 'B'); resolver.dispose(); }); @@ -352,7 +359,7 @@ void main() { final ComponentNode upgraded = child(root, 'children', 0); expect(identical(upgraded, placeholder), isFalse); expect(upgraded.type, 'Text'); - expect(props(upgraded)['text'], 'Arrived'); + expect(bound(upgraded, 'text'), 'Arrived'); expect(placeholder.disposed, isTrue); expect(destroyed, 1); emissions.dispose(); @@ -406,7 +413,7 @@ void main() { 'text': {'path': '/missing'}, }); final ComponentNode root = resolver.rootNode.value!; - expect(props(root)['text'], isNull); + expect(bound(root, 'text'), isNull); resolver.dispose(); }); @@ -436,7 +443,7 @@ void main() { .cast(); expect(after, hasLength(2)); expect(identical(after[0], before[0]), isTrue); - expect(props(after[1])['text'], 'C3'); + expect(bound(after[1], 'text'), 'C3'); expect(before[1].disposed, isTrue); resolver.dispose(); }, @@ -468,7 +475,7 @@ void main() { final List children = (props(root)['children'] as List) .cast(); expect(children, hasLength(1)); - expect(props(children[0])['text'], 'T0'); + expect(bound(children[0], 'text'), 'T0'); expect(explicitChild.disposed, isTrue); resolver.dispose(); }); @@ -489,10 +496,10 @@ void main() { }, }); final ComponentNode root = resolver.rootNode.value!; - expect(props(root)['text'], 'ALICE'); + expect(bound(root, 'text'), 'ALICE'); surface.dataModel.set('/username', 'bob'); - expect(props(root)['text'], 'BOB'); + expect(bound(root, 'text'), 'BOB'); resolver.dispose(); }); @@ -517,10 +524,10 @@ void main() { expect(items[0]['title'], 'One'); final Object? first = items[0]['child']; expect(first, isA()); - expect(props(first as ComponentNode)['text'], 'First'); + expect(bound(first as ComponentNode, 'text'), 'First'); final Object? second = items[1]['child']; expect(second, isA()); - expect(props(second as ComponentNode)['text'], 'Second'); + expect(bound(second as ComponentNode, 'text'), 'Second'); resolver.dispose(); }); @@ -578,7 +585,7 @@ void main() { final List grown = (props(root)['children'] as List) .cast(); expect(grown, hasLength(3)); - expect(props(grown[2])['text'], 'C'); + expect(bound(grown[2], 'text'), 'C'); surface.dataModel.set('/items', [ {'name': 'A'}, @@ -690,7 +697,7 @@ void main() { expect(sharedViaA.disposed, isTrue); expect(sharedViaB.disposed, isFalse); surface.dataModel.set('/label', 'still updating'); - expect(props(sharedViaB)['text'], 'still updating'); + expect(bound(sharedViaB, 'text'), 'still updating'); resolver.dispose(); }); @@ -774,7 +781,7 @@ void main() { surface.dataModel.set('/username', 'Bob'); expect(boundEmissions.count, 1); - expect(props(boundText)['text'], 'Bob'); + expect(bound(boundText, 'text'), 'Bob'); expect(rootEmissions.count, 0); // Editing one item's field re-fires the template's array @@ -782,7 +789,7 @@ void main() { // update while the template parent's props stay identity-stable and // silent. surface.dataModel.set('/items/0/name', 'A2'); - expect(props(item0)['text'], 'A2'); + expect(bound(item0, 'text'), 'A2'); expect(item0Emissions.count, greaterThanOrEqualTo(1)); expect(templateColumnEmissions.count, 0); expect(rootEmissions.count, 0); @@ -833,7 +840,7 @@ void main() { }); final ComponentNode root = resolver.rootNode.value!; - expect(props(root)['text'], isNull); + expect(bound(root, 'text'), isNull); expect(errors, hasLength(1)); expect(errors[0].code, 'EXPRESSION_ERROR'); expect(errors[0].surfaceId, 'surf-1'); @@ -868,11 +875,11 @@ void main() { 'text': {'path': '/blob'}, }); final ComponentNode root = resolver.rootNode.value!; - expect(identical((props(root)['text'] as Map)['wrapper'], first), isTrue); + expect(identical((bound(root, 'text') as Map)['wrapper'], first), isTrue); surface.dataModel.set('/blob', {'wrapper': second}); expect( - identical((props(root)['text'] as Map)['wrapper'], second), + identical((bound(root, 'text') as Map)['wrapper'], second), isTrue, ); resolver.dispose(); @@ -909,6 +916,90 @@ void main() { }); }); + group('NodeResolver resolved bindings (write-path contract)', () { + test('exposes dynamic values as bindings, writable iff path-bound', () { + final ( + catalog: Catalog catalog, + surface: SurfaceModel surface, + resolver: NodeResolver resolver, + ) = setup(); + surface.dataModel.set('/label', 'Go'); + add(surface, 'root', 'Column', { + 'children': ['txt', 'btn'], + }); + add(surface, 'txt', 'Text', {'text': 'Hi'}); + add(surface, 'btn', 'Button', { + 'label': {'path': '/label'}, + }); + final ComponentNode root = resolver.rootNode.value!; + + final literal = + props(child(root, 'children', 0))['text'] as ResolvedBinding; + expect(literal.value, 'Hi'); + expect(literal.writable, isFalse); + expect(literal.set, isNull); + + final pathBound = + props(child(root, 'children', 1))['label'] + as ResolvedBinding; + expect(pathBound.value, 'Go'); + expect(pathBound.writable, isTrue); + pathBound.set!('Next'); + expect(surface.dataModel.get('/label'), 'Next'); + expect(bound(child(root, 'children', 1), 'label'), 'Next'); + resolver.dispose(); + }); + + test('writes through a template item binding land at the item scope', () { + final ( + catalog: Catalog catalog, + surface: SurfaceModel surface, + resolver: NodeResolver 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'}, + }); + final ComponentNode root = resolver.rootNode.value!; + final List children = (props(root)['children'] as List) + .cast(); + + final second = props(children[1])['text'] as ResolvedBinding; + second.set!('B2'); + + expect(surface.dataModel.get('/items/1/name'), 'B2'); + expect(surface.dataModel.get('/items/0/name'), 'A'); + expect(bound(children[0], 'text'), 'A'); + expect(bound(children[1], 'text'), 'B2'); + resolver.dispose(); + }); + + test('serializes path-bound values as their snapshot, no setter ' + 'entries', () { + final ( + catalog: Catalog catalog, + surface: SurfaceModel surface, + resolver: NodeResolver resolver, + ) = setup(); + surface.dataModel.set('/t', 'Hello'); + add(surface, 'root', 'Text', { + 'text': {'path': '/t'}, + }); + expect(resolver.rootNode.value!.toJson(), { + 'id': 'root', + 'type': 'Text', + 'text': 'Hello', + }); + resolver.dispose(); + }); + }); + group('NodeResolver functionCall actions', () { test('executes a functionCall action through the evaluator when fired, ' 'emitting no action event', () async { From be8cbe9e291cd0dc42125734033d8787464d8285 Mon Sep 17 00:00:00 2001 From: Andrew Kolos Date: Fri, 31 Jul 2026 09:38:26 -0700 Subject: [PATCH 6/6] chore(a2ui_core): clean up node-layer comments Remove wording that only made sense relative to the design discussions ("legacy", "data-binding contract", "payload spelling", roadmap notes), coined terms with no in-code anchor ("emission gate", "tree engine"), a stale header claim that the TypeScript suite still resolves dynamic properties to value + setter pairs, and a serialization-test comment describing setter synthesis the binder no longer performs. Rename _buildLegacyWidget to _buildFromDefinition and point change-detection docs at the shallow comparison in ComponentNode.setProps. --- .../a2ui_core/lib/src/core/surface_model.dart | 4 +- .../lib/src/nodes/component_node.dart | 13 +++-- .../lib/src/nodes/node_resolver.dart | 21 ++++---- .../lib/src/nodes/resolved_binding.dart | 11 ++-- .../a2ui_core/test/node_resolver_test.dart | 31 ++++------- .../genui/lib/src/widgets/node_surface.dart | 22 ++++---- .../genui/test/widgets/node_surface_test.dart | 51 +++++++++---------- 7 files changed, 67 insertions(+), 86 deletions(-) diff --git a/packages/a2ui_core/lib/src/core/surface_model.dart b/packages/a2ui_core/lib/src/core/surface_model.dart index d9011be7a..62afcedf1 100644 --- a/packages/a2ui_core/lib/src/core/surface_model.dart +++ b/packages/a2ui_core/lib/src/core/surface_model.dart @@ -55,8 +55,8 @@ class SurfaceModel { ); _onAction.emit(action); } - // functionCall actions are executed by the action closure while it - // resolves the payload; only server-bound events are emitted here. + // functionCall payloads run inside GenericBinder's payload resolution; + // only server-bound events are emitted here. } /// Dispatches an error from this surface. diff --git a/packages/a2ui_core/lib/src/nodes/component_node.dart b/packages/a2ui_core/lib/src/nodes/component_node.dart index bdddcec6d..1d5e1b91b 100644 --- a/packages/a2ui_core/lib/src/nodes/component_node.dart +++ b/packages/a2ui_core/lib/src/nodes/component_node.dart @@ -78,9 +78,8 @@ class ComponentNode { } /// 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 lists keep their identity), so - /// shallow comparison is exact rather than heuristic. + /// a change. Callers must keep unchanged values reference-identical; the + /// shallow comparison is exact only under that invariant. void setProps(NodeProps next) { if (_disposed) { return; @@ -131,10 +130,10 @@ class ComponentNode { } } -/// Whether two prop values are the same by the emission gate's standards: -/// reference identity, with value equality for primitives (equal strings are -/// not always [identical], so identity alone would report equal-value -/// updates as changes). +/// Whether two prop values count as unchanged for the shallow comparison in +/// [ComponentNode.setProps]: reference identity, with value equality for +/// primitives (equal strings are not always [identical], so identity alone +/// would report equal-value updates as changes). bool sameValue(Object? a, Object? b) { if (identical(a, b)) return true; if (a is String && b is String) return a == b; diff --git a/packages/a2ui_core/lib/src/nodes/node_resolver.dart b/packages/a2ui_core/lib/src/nodes/node_resolver.dart index eaafdb8f1..c963c9a84 100644 --- a/packages/a2ui_core/lib/src/nodes/node_resolver.dart +++ b/packages/a2ui_core/lib/src/nodes/node_resolver.dart @@ -44,18 +44,16 @@ class _NodeRecord { }); } -/// The tree engine of the node layer: turns a surface's flat component map -/// into a live tree of resolved [ComponentNode]s rooted at [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. +/// Turns a surface's flat component map into a live tree of resolved +/// [ComponentNode]s rooted at [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 the same catalog instance the surface was -/// constructed with. Resolution executes catalog functions, so it needs a -/// catalog with implementations; [Catalog] only holds -/// [FunctionImplementation]s, so every constructable catalog qualifies. +/// constructed with. /// /// Node identity is parent-scoped: each referencing position gets its own /// node, so one component id mounted at two positions yields two nodes and @@ -356,7 +354,8 @@ class NodeResolver { /// 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. + /// identity so the shallow comparison in [ComponentNode.setProps] stays + /// exact. void _materialize(_NodeRecord record) { if (record.node.disposed) { return; diff --git a/packages/a2ui_core/lib/src/nodes/resolved_binding.dart b/packages/a2ui_core/lib/src/nodes/resolved_binding.dart index a19e6ba67..68282cf28 100644 --- a/packages/a2ui_core/lib/src/nodes/resolved_binding.dart +++ b/packages/a2ui_core/lib/src/nodes/resolved_binding.dart @@ -6,9 +6,8 @@ /// value, plus a write capability when (and only when) the payload bound a /// data path. /// -/// This is the Dart spelling of the cross-SDK `DataBinding` concept; the -/// name differs because `DataBinding` here is the wire model of the -/// `{"path": ...}` payload spelling. +/// Not to be confused with `DataBinding`, the wire model of the +/// `{"path": ...}` payload. /// /// `set` is null when the payload supplied a literal or a function call, so /// an unchecked write does not compile under sound null safety. A null `set` @@ -26,9 +25,9 @@ final class ResolvedBinding { /// Whether writes have a destination (the payload bound a data path). bool get writable => set != null; - /// Bindings compare by snapshot value and writability, so the node - /// emission gate can suppress no-op updates even though each update - /// constructs a new binding instance. + /// Bindings compare by snapshot value and writability, so the shallow + /// comparison in the node's `setProps` can suppress no-op updates even + /// though each update constructs a new binding instance. @override bool operator ==(Object other) => other is ResolvedBinding && diff --git a/packages/a2ui_core/test/node_resolver_test.dart b/packages/a2ui_core/test/node_resolver_test.dart index e187ae6c6..ea0721c3b 100644 --- a/packages/a2ui_core/test/node_resolver_test.dart +++ b/packages/a2ui_core/test/node_resolver_test.dart @@ -3,20 +3,12 @@ // found in the LICENSE file. /// Conformance suite for the node layer, ported from web_core's -/// `node-resolver.test.ts` (itself ported from the Python reference -/// `test_node_graph.py` plus defect coverage: eager action resolution, -/// shared-node use-after-dispose, and whole-list template respawn). +/// `node-resolver.test.ts`. /// -/// Known divergences from the TypeScript suite: -/// - Dynamic properties resolve to a [ResolvedBinding] (snapshot value plus -/// a write capability only where the payload bound a path), implementing -/// the cross-SDK data-binding contract. web_core still resolves dynamic -/// properties to a plain value with a synthesized `setX` sibling; it -/// adopts the binding shape with the v1.0 patchset, since changing it is -/// breaking for published code. -/// - The compile-time rejection of a schema-only catalog has no Dart -/// equivalent: `Catalog` only holds `FunctionImplementation`s, so a -/// signature-only catalog is not constructable at all. +/// Known divergence from the TypeScript suite: the compile-time rejection of +/// a schema-only catalog has no Dart equivalent, because `Catalog` only +/// holds `FunctionImplementation`s and a signature-only catalog is not +/// constructable at all. library; import 'package:a2ui_core/a2ui_core.dart'; @@ -618,8 +610,6 @@ void main() { }, }); final ComponentNode root = resolver.rootNode.value!; - // Unlike web_core, the Dart binder synthesizes setters only for - // path-bound props, so the literal-valued Text and Button carry none. expect(root.toJson(), { 'id': 'root', 'type': 'Column', @@ -785,9 +775,8 @@ void main() { expect(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. + // subscription; the item node must update while the template + // parent's props stay identity-stable and silent. surface.dataModel.set('/items/0/name', 'A2'); expect(bound(item0, 'text'), 'A2'); expect(item0Emissions.count, greaterThanOrEqualTo(1)); @@ -861,8 +850,8 @@ void main() { }); test('propagates changes to non-plain object values', () { - // Class instances have no entries for key-wise stabilization to - // compare, so the engine must treat them as always-changed. + // Class instances have no entries to compare key by key, so the + // resolver must treat them as always-changed. final ( catalog: Catalog catalog, surface: SurfaceModel surface, @@ -916,7 +905,7 @@ void main() { }); }); - group('NodeResolver resolved bindings (write-path contract)', () { + group('NodeResolver resolved bindings (write path)', () { test('exposes dynamic values as bindings, writable iff path-bound', () { final ( catalog: Catalog catalog, diff --git a/packages/genui/lib/src/widgets/node_surface.dart b/packages/genui/lib/src/widgets/node_surface.dart index ebc8ae044..13eaa6dbf 100644 --- a/packages/genui/lib/src/widgets/node_surface.dart +++ b/packages/genui/lib/src/widgets/node_surface.dart @@ -28,9 +28,9 @@ import 'surface.dart'; /// child-reference properties arrive as resolved nodes (templates expanded /// one node per data item, missing components as placeholders swapped in /// place, list changes reconciled with surviving nodes reused). Properties -/// the engine cannot classify as child references fall back to the legacy -/// id-walking path, so catalogs work before their schemas declare -/// references. +/// the resolver cannot classify as child references are rendered by walking +/// the raw definitions by id, as [Surface] does, so catalogs whose schemas +/// do not declare references still work. class NodeSurface extends StatefulWidget { /// Creates a [NodeSurface]. const NodeSurface({ @@ -212,9 +212,9 @@ class _NodeSurfaceState extends State { return _buildNode(child); } // The schema did not mark this property as a child reference, so - // the engine resolved no node for it; walk the raw definition the - // way [Surface] does. - return _buildLegacyWidget( + // the resolver produced no node for it; walk the raw definition + // the way [Surface] does. + return _buildFromDefinition( context, id, childDataContext ?? dataContext, @@ -227,10 +227,10 @@ class _NodeSurfaceState extends State { } } - /// The legacy fallback: renders a component and its descendants directly - /// from the raw definitions, bypassing the node layer. Used for child - /// references the engine could not classify from the schema. - Widget _buildLegacyWidget( + /// Renders a component and its descendants directly from the raw + /// definitions, bypassing the node layer. Used for child references the + /// resolver could not classify from the schema. + Widget _buildFromDefinition( BuildContext context, String componentId, DataContext dataContext, @@ -249,7 +249,7 @@ class _NodeSurfaceState extends State { data: JsonMap.from(model.properties), dataContext: dataContext, buildChild: (String id, [DataContext? childDataContext]) => - _buildLegacyWidget(context, id, childDataContext ?? dataContext), + _buildFromDefinition(context, id, childDataContext ?? dataContext), ); } catch (exception, stackTrace) { _reportError(exception, stackTrace); diff --git a/packages/genui/test/widgets/node_surface_test.dart b/packages/genui/test/widgets/node_surface_test.dart index 4072acb85..17d6b3c94 100644 --- a/packages/genui/test/widgets/node_surface_test.dart +++ b/packages/genui/test/widgets/node_surface_test.dart @@ -139,34 +139,29 @@ void main() { expect(find.text('Gamma'), findsOneWidget); }); - testWidgets( - 'renders unmarked single-child references through the legacy fallback ' - 'and dispatches actions', - (WidgetTester tester) async { - final ( - core.SurfaceModel surface, - List events, - ) = createSurface(); - add(surface, 'root', 'Button', { - 'child': 'label', - 'action': { - 'event': {'name': 'pressed'}, - }, - }); - add(surface, 'label', 'Text', {'text': 'Press me'}); - - await tester.pumpWidget(host(surface, events)); - await tester.pumpAndSettle(); - expect(find.text('Press me'), findsOneWidget); - - await tester.tap(find.byType(ElevatedButton)); - await tester.pumpAndSettle(); - - expect(events, hasLength(1)); - expect(events.single.isUserAction, isTrue); - expect(events.single.surfaceId, 'node-surface-test'); - }, - ); + testWidgets('renders unmarked single-child references from raw definitions ' + 'and dispatches actions', (WidgetTester tester) async { + final (core.SurfaceModel surface, List events) = + createSurface(); + add(surface, 'root', 'Button', { + 'child': 'label', + 'action': { + 'event': {'name': 'pressed'}, + }, + }); + add(surface, 'label', 'Text', {'text': 'Press me'}); + + await tester.pumpWidget(host(surface, events)); + await tester.pumpAndSettle(); + expect(find.text('Press me'), findsOneWidget); + + await tester.tap(find.byType(ElevatedButton)); + await tester.pumpAndSettle(); + + expect(events, hasLength(1)); + expect(events.single.isUserAction, isTrue); + expect(events.single.surfaceId, 'node-surface-test'); + }); testWidgets('reconciles an explicit children list change', ( WidgetTester tester,