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/a2ui_core/lib/a2ui_core.dart b/packages/a2ui_core/lib/a2ui_core.dart index 4ca0acd27..41a137aa3 100644 --- a/packages/a2ui_core/lib/a2ui_core.dart +++ b/packages/a2ui_core/lib/a2ui_core.dart @@ -18,6 +18,10 @@ 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/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/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..62afcedf1 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 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 new file mode 100644 index 000000000..1d5e1b91b --- /dev/null +++ b/packages/a2ui_core/lib/src/nodes/component_node.dart @@ -0,0 +1,181 @@ +// 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'; +import 'resolved_binding.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: 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 +/// 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 must keep unchanged values reference-identical; the + /// shallow comparison is exact only under that invariant. + 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, bindings serialize as their + /// snapshot value, and action closures 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 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; + 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; +} + +Object? _serializeValue(Object? value) { + if (value is ComponentNode) { + return value.toJson(); + } + if (value is ResolvedBinding) { + return _serializeValue(value.value); + } + 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..c963c9a84 --- /dev/null +++ b/packages/a2ui_core/lib/src/nodes/node_resolver.dart @@ -0,0 +1,541 @@ +// 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, + }); +} + +/// 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. +/// +/// 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 shallow comparison in [ComponentNode.setProps] 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/lib/src/nodes/resolved_binding.dart b/packages/a2ui_core/lib/src/nodes/resolved_binding.dart new file mode 100644 index 000000000..68282cf28 --- /dev/null +++ b/packages/a2ui_core/lib/src/nodes/resolved_binding.dart @@ -0,0 +1,71 @@ +// 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. +/// +/// 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` +/// 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 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 && + 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/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/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 new file mode 100644 index 000000000..ea0721c3b --- /dev/null +++ b/packages/a2ui_core/test/node_resolver_test.dart @@ -0,0 +1,1125 @@ +// 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`. +/// +/// 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'; +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 _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); +} + +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(); + +/// 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] + : (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(bound(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(bound(root, 'text'), 'Alice'); + + surface.dataModel.set('/username', 'Bob'); + expect(bound(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(bound(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(bound(children[0], 'text'), 'C1'); + expect(bound(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(bound(children[0], 'text'), 'A'); + expect(bound(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(bound(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(bound(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(bound(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(bound(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(bound(root, 'text'), 'ALICE'); + + surface.dataModel.set('/username', 'bob'); + expect(bound(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(bound(first as ComponentNode, 'text'), 'First'); + final Object? second = items[1]['child']; + expect(second, isA()); + expect(bound(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(bound(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!; + 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(bound(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(bound(boundText, 'text'), 'Bob'); + expect(rootEmissions.count, 0); + + // Editing one item's field re-fires the template's array + // 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)); + 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('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(bound(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, + 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 to compare key by key, so the + // resolver 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((bound(root, 'text') as Map)['wrapper'], first), isTrue); + + surface.dataModel.set('/blob', {'wrapper': second}); + expect( + identical((bound(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 resolved bindings (write path)', () { + 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 { + 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(); + 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); + }, + ); + }); +} 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.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..13eaa6dbf --- /dev/null +++ b/packages/genui/lib/src/widgets/node_surface.dart @@ -0,0 +1,389 @@ +// 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 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({ + 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() { + 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, + 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 resolver produced no node for it; walk the raw definition + // the way [Surface] does. + return _buildFromDefinition( + context, + id, + childDataContext ?? dataContext, + ); + }, + ); + } catch (exception, stackTrace) { + _reportError(exception, stackTrace); + return FallbackWidget(error: exception, stackTrace: stackTrace); + } + } + + /// 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, + ) { + 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]) => + _buildFromDefinition(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..17d6b3c94 --- /dev/null +++ b/packages/genui/test/widgets/node_surface_test.dart @@ -0,0 +1,191 @@ +// 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 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, + ) 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); + }); +}