Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 47 additions & 1 deletion examples/simple_chat/lib/primitives/message.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand Down Expand Up @@ -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<SurfaceDefinition?>(
valueListenable: surfaceContext.definition,
builder: (context, definition, _) {
final core.SurfaceModel<core.ComponentApi>? 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,
);
},
);
}
}
4 changes: 4 additions & 0 deletions packages/a2ui_core/lib/a2ui_core.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
43 changes: 33 additions & 10 deletions packages/a2ui_core/lib/src/core/contexts.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -24,11 +26,14 @@ typedef FunctionInvoker =
/// paths like `/users/0/name`. Also evaluates data bindings and
/// function calls.
class DataContext {
final SurfaceModel<ComponentApi> 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;
Expand All @@ -52,7 +57,7 @@ class DataContext {
for (final MapEntry<String, dynamic> 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;
}
Expand Down Expand Up @@ -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;
}
Expand All @@ -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<String, dynamic> 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) {
Expand All @@ -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<void> dispatchAction(Map<String, dynamic> action) {
Expand All @@ -143,7 +163,10 @@ extension CatalogInvokerExtension on Catalog {
Object? invoke(String name, Map<String, dynamic> 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);
}
Expand Down
12 changes: 2 additions & 10 deletions packages/a2ui_core/lib/src/core/surface_model.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -56,15 +54,9 @@ class SurfaceModel<T extends ComponentApi> {
),
);
_onAction.emit(action);
} else if (payload.containsKey('functionCall')) {
final callJson = payload['functionCall'] as Map<String, dynamic>;
final call = FunctionCall.fromJson(callJson);
catalog.invoke(
call.call,
Map<String, dynamic>.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.
Expand Down
181 changes: 181 additions & 0 deletions packages/a2ui_core/lib/src/nodes/component_node.dart
Original file line number Diff line number Diff line change
@@ -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<String, Object?>;

/// 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<NodeProps> _props;

/// Resolved, reactive properties. Read without subscribing via `peek()`.
ReadonlySignal<NodeProps> get props => _props;

final _onDestroyed = EventNotifier<void>();

/// Fires exactly once, when this node is disposed.
EventListenable<void> get onDestroyed => _onDestroyed;

List<void Function()> _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);
}
Comment on lines +76 to +78

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

If addCleanup is called on an already disposed node, it will append the cleanup function to _cleanups which will never be executed. We should guard addCleanup to ignore or warn when called after disposal.

Suggested change
void addCleanup(void Function() cleanup) {
_cleanups.add(cleanup);
}
void addCleanup(void Function() cleanup) {
if (_disposed) {
_log.warning('addCleanup called on a disposed ComponentNode ($instanceId)');
return;
}
_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 = [];
Comment on lines +95 to +112

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Iterating directly over _cleanups while executing cleanup functions can lead to a ConcurrentModificationError if any cleanup function triggers a modification of the _cleanups list (e.g., by transitively calling addCleanup or disposing related resources). To make this robust, copy the cleanups to a temporary list using List.of(_cleanups) and clear the original list before executing them.

  void dispose() {
    if (_disposed) {
      return;
    }
    _disposed = true;
    final cleanups = List<void Function()>.of(_cleanups);
    _cleanups.clear();
    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,
        );
      }
    }
  }

_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
/// `'<Action>'`.
Map<String, Object?> toJson() {
if (isPlaceholder) {
return {'id': componentId, 'type': placeholderType};
}
final serialized = <String, Object?>{'id': componentId, 'type': type};
for (final MapEntry<String, Object?> 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 '<Action>';
}
if (value is List) {
return value.map(_serializeValue).toList();
}
if (value is Map) {
return <String, Object?>{
for (final MapEntry<Object?, Object?> 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<String, Object?> entry in a.entries) {
if (!b.containsKey(entry.key) || !sameValue(entry.value, b[entry.key])) {
return false;
}
}
return true;
}
Comment on lines +168 to +181

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Iterating over a.entries allocates a MapEntry object for every property of the node on every shallow equality check. Since _shallowEqual is called frequently during updates, we can optimize this by iterating over a.keys instead, which avoids these unnecessary allocations.

Suggested change
bool _shallowEqual(NodeProps a, NodeProps b) {
if (identical(a, b)) {
return true;
}
if (a.length != b.length) {
return false;
}
for (final MapEntry<String, Object?> entry in a.entries) {
if (!b.containsKey(entry.key) || !sameValue(entry.value, b[entry.key])) {
return false;
}
}
return true;
}
bool _shallowEqual(NodeProps a, NodeProps b) {
if (identical(a, b)) {
return true;
}
if (a.length != b.length) {
return false;
}
for (final String key in a.keys) {
if (!b.containsKey(key) || !sameValue(a[key], b[key])) {
return false;
}
}
return true;
}

Loading
Loading