From 0ed51f36b2243e94577ccfe0eee5b5a87dd2565d Mon Sep 17 00:00:00 2001 From: Andrew Kolos Date: Tue, 26 May 2026 14:14:44 -0700 Subject: [PATCH 01/49] feat(a2ui_core): tighten protocol parsing, expose effect, copy mutable JSON MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four substrate-level changes the genui facade migration depends on or benefits from. Bundled because they're all isolated to a2ui_core and each is small enough to review at the same sitting. 1. messages.dart: parse-time envelope validation. - Reject envelopes missing the `version` field or carrying a version other than 'v0.9' (was: accepted anything). - Reject envelopes that contain more than one action key (createSurface/updateComponents/updateDataModel/deleteSurface) — the v0.9 schema models these as `oneOf`, but the old code silently dispatched on whichever check matched first. - UpdateDataModelMessage gains a `hasValue` field plus a `.removeKey` named constructor. The wire distinction between `"value": null` (set to null) and an omitted `value` key (remove the key / sparse-clear a list index) now round-trips losslessly. Runtime DataModel.set behavior is unchanged; the distinction only matters for observers/relayers that inspect the message. 2. data_model.dart: defensively copy incoming Map/List values. Wraps every container the caller hands to DataModel (constructor `initialData` and `set()` writes) with a deep mutable copy so later nested writes don't blow up when callers passed `const` literals or otherwise-unmodifiable views. Test added covering the "set('/') of a const map, then set('/nested/count')" path. 3. data_path.dart: drop RFC 6901 `~0`/`~1` escape interpretation. The web_core reference renderer never implemented escaping despite the v0.9 spec citing RFC 6901; a2ui_core was the only implementation diverging. Aligns Dart with web_core. Affects only keys containing literal '~' or '/' — extremely rare in data model usage. 4. reactivity.dart: add `effect` to the re-exported symbols. preact_signals provides `effect()` as a one-shot wrapper for `Effect(fn)()`; exporting it lets consumers avoid the `Effect()..call()` ceremony at the call site. --- .../a2ui_core/lib/src/core/data_model.dart | 24 +++++++-- packages/a2ui_core/lib/src/core/messages.dart | 49 +++++++++++++++++-- .../lib/src/primitives/data_path.dart | 12 +++-- .../lib/src/primitives/reactivity.dart | 10 +++- packages/a2ui_core/test/data_model_test.dart | 17 +++++++ packages/a2ui_core/test/data_path_test.dart | 17 +------ packages/a2ui_core/test/messages_test.dart | 43 ++++++++++++++++ 7 files changed, 143 insertions(+), 29 deletions(-) diff --git a/packages/a2ui_core/lib/src/core/data_model.dart b/packages/a2ui_core/lib/src/core/data_model.dart index 7dd5f9252..198aeb6cb 100644 --- a/packages/a2ui_core/lib/src/core/data_model.dart +++ b/packages/a2ui_core/lib/src/core/data_model.dart @@ -12,13 +12,29 @@ import '../primitives/reactivity.dart'; /// allocate a billion-element list. const int maxAutoVivifyIndex = 10000; +/// Returns a mutable copy of JSON-like maps/lists so later nested writes do +/// not fail when callers pass const or otherwise unmodifiable literals. +Object? _mutableJsonLike(Object? value) { + if (value is Map) { + return { + for (final entry in value.entries) + entry.key.toString(): _mutableJsonLike(entry.value), + }; + } + if (value is List) { + return [for (final item in value) _mutableJsonLike(item)]; + } + return value; +} + /// A standalone, observable data store representing the client-side state. /// It handles JSON Pointer path resolution and reactive signal management. class DataModel { Object? _data; final Map>> _signals = {}; - DataModel([Object? initialData]) : _data = initialData ?? {}; + DataModel([Object? initialData]) + : _data = _mutableJsonLike(initialData ?? {}); /// Synchronously gets data at a specific JSON pointer path. Object? get(String path) { @@ -49,7 +65,7 @@ class DataModel { batch(() { if (dataPath.isEmpty) { - _data = value; + _data = _mutableJsonLike(value); } else { _data ??= {}; Object? current = _data; @@ -102,7 +118,7 @@ class DataModel { if (value == null) { current.remove(lastSegment); } else { - current[lastSegment] = value; + current[lastSegment] = _mutableJsonLike(value); } } else if (current is List) { final int? index = int.tryParse(lastSegment); @@ -121,7 +137,7 @@ class DataModel { while (current.length <= index) { current.add(null); } - current[index] = value; + current[index] = _mutableJsonLike(value); } } diff --git a/packages/a2ui_core/lib/src/core/messages.dart b/packages/a2ui_core/lib/src/core/messages.dart index 923c52695..d7ac608bd 100644 --- a/packages/a2ui_core/lib/src/core/messages.dart +++ b/packages/a2ui_core/lib/src/core/messages.dart @@ -10,6 +10,11 @@ abstract class A2uiMessage { A2uiMessage({this.version = 'v0.9'}); /// Deserializes a JSON envelope into a typed [A2uiMessage]. + /// + /// Throws [A2uiValidationError] if the `version` field is missing or is + /// not exactly `'v0.9'`, or if the envelope does not contain exactly one + /// of the known action keys (`createSurface`, `updateComponents`, + /// `updateDataModel`, `deleteSurface`). factory A2uiMessage.fromJson(Map json) { final Object? rawVersion = json['version']; if (rawVersion is! String) { @@ -65,11 +70,20 @@ abstract class A2uiMessage { if (json.containsKey('updateDataModel')) { final body = json['updateDataModel'] as Map; - return UpdateDataModelMessage( + // Preserve the wire-level distinction between "value omitted" (remove + // the key) and "value: null" (set to null) — see UpdateDataModelMessage. + if (body.containsKey('value')) { + return UpdateDataModelMessage( + version: version, + surfaceId: body['surfaceId'] as String, + path: body['path'] as String?, + value: body['value'], + ); + } + return UpdateDataModelMessage.removeKey( version: version, surfaceId: body['surfaceId'] as String, path: body['path'] as String?, - value: body['value'], ); } @@ -137,17 +151,44 @@ class UpdateComponentsMessage extends A2uiMessage { } /// Updates the data model for an existing surface. +/// +/// The wire protocol distinguishes between two intents the renderer guide +/// treats differently: +/// +/// - `"value": ` (present, possibly `null`): set the value at [path]. +/// - omitted `value` key: remove the key at [path] (sparse-clear for lists). +/// +/// To keep that distinction lossless through parse/serialize, the default +/// constructor marks `hasValue = true` (the message carries an explicit +/// value, which may be `null`), and [UpdateDataModelMessage.removeKey] +/// constructs the "value omitted" form. class UpdateDataModelMessage extends A2uiMessage { final String surfaceId; final String? path; final Object? value; + /// True iff the message carries an explicit `value` on the wire. When + /// false, the `value` key is absent from the JSON envelope and the + /// receiver should treat the message as a "remove key at [path]". + final bool hasValue; + + /// Constructs a message that sets [path] to [value]. `value` is part of + /// the envelope even when `null`. UpdateDataModelMessage({ super.version, required this.surfaceId, this.path, this.value, - }); + }) : hasValue = true; + + /// Constructs a message that removes the key at [path] (no `value` field + /// on the wire). + UpdateDataModelMessage.removeKey({ + super.version, + required this.surfaceId, + this.path, + }) : value = null, + hasValue = false; @override Map toJson() => { @@ -155,7 +196,7 @@ class UpdateDataModelMessage extends A2uiMessage { 'updateDataModel': { 'surfaceId': surfaceId, if (path != null) 'path': path, - if (value != null) 'value': value, + if (hasValue) 'value': value, }, }; } diff --git a/packages/a2ui_core/lib/src/primitives/data_path.dart b/packages/a2ui_core/lib/src/primitives/data_path.dart index bb5b3a269..4d8bd9cc7 100644 --- a/packages/a2ui_core/lib/src/primitives/data_path.dart +++ b/packages/a2ui_core/lib/src/primitives/data_path.dart @@ -4,7 +4,11 @@ import 'package:collection/collection.dart'; -/// A class for handling JSON Pointer (RFC 6901) paths. +/// A class for handling JSON Pointer-style paths. +/// +/// Splits paths on `/` only; does not implement RFC 6901 `~0`/`~1` +/// escaping for `~`/`/` within segments. This matches the web_core +/// reference implementation, despite the v0.9 spec citing RFC 6901. class DataPath { final List segments; @@ -29,9 +33,7 @@ class DataPath { return DataPath([]); } - final List segments = normalized.split('/').map((s) { - return s.replaceAll('~1', '/').replaceAll('~0', '~'); - }).toList(); + final List segments = normalized.split('/'); return DataPath(segments); } @@ -68,7 +70,7 @@ class DataPath { @override String toString() { if (segments.isEmpty) return '/'; - return '/${segments.map((s) => s.replaceAll('~', '~0').replaceAll('/', '~1')).join('/')}'; + return '/${segments.join('/')}'; } @override diff --git a/packages/a2ui_core/lib/src/primitives/reactivity.dart b/packages/a2ui_core/lib/src/primitives/reactivity.dart index c867253ba..1b06466cf 100644 --- a/packages/a2ui_core/lib/src/primitives/reactivity.dart +++ b/packages/a2ui_core/lib/src/primitives/reactivity.dart @@ -10,4 +10,12 @@ library; export 'package:preact_signals/preact_signals.dart' - show Computed, Effect, ReadonlySignal, Signal, batch, computed, signal; + show + Computed, + Effect, + ReadonlySignal, + Signal, + batch, + computed, + effect, + signal; diff --git a/packages/a2ui_core/test/data_model_test.dart b/packages/a2ui_core/test/data_model_test.dart index 698a9896e..1012d0a19 100644 --- a/packages/a2ui_core/test/data_model_test.dart +++ b/packages/a2ui_core/test/data_model_test.dart @@ -145,6 +145,23 @@ void main() { expect(model.get('/'), isEmpty); }); + test('copies immutable containers before nested writes', () { + final model = DataModel(); + model.set('/', const { + 'experience': '2-5', + 'nested': {'count': 1}, + 'items': ['a'], + }); + + model.set('/experience', '5+'); + model.set('/nested/count', 2); + model.set('/items/1', 'b'); + + expect(model.get('/experience'), '5+'); + expect(model.get('/nested'), {'count': 2}); + expect(model.get('/items'), ['a', 'b']); + }); + test('rejects excessively large list indices to prevent OOM', () { final model = DataModel(); expect( diff --git a/packages/a2ui_core/test/data_path_test.dart b/packages/a2ui_core/test/data_path_test.dart index 0d2e232d8..c1fa7e1d4 100644 --- a/packages/a2ui_core/test/data_path_test.dart +++ b/packages/a2ui_core/test/data_path_test.dart @@ -19,9 +19,9 @@ void main() { expect(path.toString(), '/foo/bar'); }); - test('parses escaped segments', () { + test('preserves ~ characters literally (no RFC 6901 escaping)', () { final path = DataPath.parse('/foo~1bar/baz~0qux'); - expect(path.segments, ['foo/bar', 'baz~qux']); + expect(path.segments, ['foo~1bar', 'baz~0qux']); expect(path.toString(), '/foo~1bar/baz~0qux'); }); @@ -48,18 +48,5 @@ void main() { expect(DataPath.parse('/a/b'), equals(DataPath.parse('/a/b'))); expect(DataPath.parse('/a/b'), isNot(equals(DataPath.parse('/a/c')))); }); - - test('hashCode distinguishes segments from slashes in keys', () { - // Per RFC 6901 section 3, '~1' escapes a literal '/' within a key name. - // DataPath(['a', 'b']) represents JSON Pointer "/a/b" (two keys). - // DataPath(['a/b']) represents JSON Pointer "/a~1b" (one key: "a/b"). - // These are semantically different pointers and must have different - // hash codes for correctness in hash-based collections. - final twoSegments = DataPath(['a', 'b']); - final oneSegment = DataPath(['a/b']); - - expect(twoSegments, isNot(equals(oneSegment))); - expect(twoSegments.hashCode, isNot(equals(oneSegment.hashCode))); - }); }); } diff --git a/packages/a2ui_core/test/messages_test.dart b/packages/a2ui_core/test/messages_test.dart index d6438f57c..8c090c75c 100644 --- a/packages/a2ui_core/test/messages_test.dart +++ b/packages/a2ui_core/test/messages_test.dart @@ -82,6 +82,49 @@ void main() { final ud = msg as UpdateDataModelMessage; expect(ud.path, isNull); expect(ud.value, isNull); + expect(ud.hasValue, isFalse); + }); + + test('parses updateDataModel with explicit null value', () { + // Per the v0.9 spec, `value: null` and an absent `value` key carry + // different intent (set-to-null vs remove-key). They must round-trip + // distinctly so callers (and senders) preserve the distinction. + final msg = A2uiMessage.fromJson({ + 'version': 'v0.9', + 'updateDataModel': {'surfaceId': 's1', 'path': '/x', 'value': null}, + }); + + final ud = msg as UpdateDataModelMessage; + expect(ud.value, isNull); + expect(ud.hasValue, isTrue); + }); + + test('round-trips an explicit-null updateDataModel value', () { + final original = A2uiMessage.fromJson({ + 'version': 'v0.9', + 'updateDataModel': {'surfaceId': 's1', 'path': '/x', 'value': null}, + }); + final Map json = original.toJson(); + final body = json['updateDataModel'] as Map; + expect(body.containsKey('value'), isTrue); + expect(body['value'], isNull); + + final reparsed = A2uiMessage.fromJson(json) as UpdateDataModelMessage; + expect(reparsed.hasValue, isTrue); + expect(reparsed.value, isNull); + }); + + test('round-trips an omitted updateDataModel value as omitted', () { + final omitted = UpdateDataModelMessage.removeKey( + surfaceId: 's1', + path: '/x', + ); + final body = omitted.toJson()['updateDataModel'] as Map; + expect(body.containsKey('value'), isFalse); + + final reparsed = + A2uiMessage.fromJson(omitted.toJson()) as UpdateDataModelMessage; + expect(reparsed.hasValue, isFalse); }); test('parses deleteSurface', () { From 63ff2efe8a4b073b27a89d0fb4ab5e5644588a98 Mon Sep 17 00:00:00 2001 From: Andrew Kolos Date: Tue, 26 May 2026 14:15:10 -0700 Subject: [PATCH 02/49] refactor(genui): rebase data substrate on a2ui_core.DataModel (compat facade) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wraps a2ui_core.DataModel inside genui's existing DataModel/InMemoryDataModel public API. Adds a2ui_core to the genui pubspec. Preserves the existing GenUI surface (DataPath, ValueListenable subscriptions, getValue, update(DataPath, value), bindExternalState, DataModelTypeException) so no consumer rename is required. Substantive pieces inside this commit: 1. data_model.dart: InMemoryDataModel becomes a compat facade. - Constructor `InMemoryDataModel()` constructs a fresh core.DataModel and owns it. `InMemoryDataModel.wrap(core.DataModel)` is @internal and lets the controller share a single live model with the surface. - update(DataPath, value) -> core.set(path.toString(), value). - subscribe(DataPath) returns a _SignalNotifier — a ChangeNotifier backed by a preact_signals effect over core.watch. The effect forces a notification on every source change (including `==`-equal values) so in-place Map/List mutations propagate. - getValue(DataPath) preserves the typed-read throw via DataModelTypeException so legacy callers keep their type check. - bindExternalState is preserved as both instance method and a top-level helper. - DataContext keeps its existing public surface (subscribe, subscribeStream, getValue, update, nested, resolvePath, resolve, evaluateConditionStream). Internally it routes through the wrapped DataModel. 2. widget_utilities.dart: Bound* widgets rewritten on preact_signals internally, but the constructor parameters and builder signature are unchanged. Two non-obvious wrinkles preserved from this branch's earlier exploration: - BoundValue uses a signal+listener bridge (not core.computed) because computed gates downstream notifications on `==` equality, which for Maps/Lists is identity-based — so a2ui_core's bubble notifications on in-place mutations would be silently dropped. The bridge force-mirrors the source value on every change. - _SignalBuilder is a small private StatefulWidget that subscribes to a ReadonlySignal via preact_signals' effect(). signals_flutter is NOT compatible (different signals package). 3. client_function.dart: ExecutionContext interface picks up the compat-facade signatures (`Object` for path args so callers can pass either DataPath or String). format_string.dart drops a couple of redundant DataPath constructors at call sites. 4. test/widgets/widget_utilities_test.dart: regression for the BoundObject/BoundList in-place mutation case — without the bridge fix, these would silently miss bubble-notifications. Test/dev_tools/example fallout from this commit is minimal because the public API shape is unchanged. --- .../lib/src/functions/format_string.dart | 16 +- .../genui/lib/src/model/client_function.dart | 12 +- packages/genui/lib/src/model/data_model.dart | 586 +++++++----------- .../lib/src/widgets/widget_utilities.dart | 332 +++++----- packages/genui/pubspec.yaml | 1 + .../test/widgets/widget_utilities_test.dart | 72 +++ 6 files changed, 464 insertions(+), 555 deletions(-) create mode 100644 packages/genui/test/widgets/widget_utilities_test.dart diff --git a/packages/genui/lib/src/functions/format_string.dart b/packages/genui/lib/src/functions/format_string.dart index c966ec01f..ccdd8aa2f 100644 --- a/packages/genui/lib/src/functions/format_string.dart +++ b/packages/genui/lib/src/functions/format_string.dart @@ -7,7 +7,7 @@ import 'package:meta/meta.dart'; import 'package:stream_transform/stream_transform.dart'; import '../model/client_function.dart'; -import '../model/data_model.dart'; +import '../model/data_path.dart'; import '../primitives/logging.dart'; import '../primitives/simple_items.dart'; import '../utils/stream_extensions.dart'; @@ -92,7 +92,7 @@ class ExpressionParser { /// any data path dependencies within the arguments will be added to the set. Stream evaluateFunctionCall( JsonMap callDefinition, { - Set? dependencies, + Set? dependencies, int depth = 0, }) { if (depth > _maxRecursionDepth) { @@ -184,7 +184,7 @@ class ExpressionParser { Stream _parseStringWithInterpolations( String input, - Set? dependencies, { + Set? dependencies, { int depth = 0, }) { if (depth > _maxRecursionDepth) { @@ -284,7 +284,7 @@ class ExpressionParser { Object? _evaluateExpression( String content, int depth, - Set? dependencies, + Set? dependencies, ) { if (depth > _maxRecursionDepth) { throw RecursionExpectedException( @@ -320,7 +320,7 @@ class ExpressionParser { Map _parseNamedArgs( String argsStr, int depth, - Set? dependencies, + Set? dependencies, ) { final args = {}; var i = 0; @@ -379,7 +379,7 @@ class ExpressionParser { String input, int start, int depth, - Set? dependencies, + Set? dependencies, ) { if (start >= input.length) return (null, start); @@ -440,10 +440,10 @@ class ExpressionParser { return (_resolvePath(token, dependencies), i); } - Stream _resolvePath(String pathStr, Set? dependencies) { + Stream _resolvePath(String pathStr, Set? dependencies) { pathStr = pathStr.trim(); if (dependencies != null) { - dependencies.add(context.resolvePath(DataPath(pathStr))); + dependencies.add(context.resolvePath(DataPath(pathStr)).toString()); return Stream.value(null); } return context.subscribeStream(DataPath(pathStr)); diff --git a/packages/genui/lib/src/model/client_function.dart b/packages/genui/lib/src/model/client_function.dart index d025cacf4..e2d9e1984 100644 --- a/packages/genui/lib/src/model/client_function.dart +++ b/packages/genui/lib/src/model/client_function.dart @@ -20,22 +20,22 @@ abstract interface class ExecutionContext { ClientFunction? getFunction(String name); /// Subscribes to a path, resolving it against the current context. - ValueListenable subscribe(DataPath path); + ValueListenable subscribe(Object path); /// Subscribes to a path and returns a [Stream]. - Stream subscribeStream(DataPath path); + Stream subscribeStream(Object path); /// Gets a value, resolving the path against the current context. - T? getValue(DataPath path); + T? getValue(Object path); /// Updates the data model, resolving the path against the current context. - void update(DataPath path, Object? contents); + void update(Object path, Object? contents); /// Creates a new, nested ExecutionContext for a child widget. - ExecutionContext nested(DataPath relativePath); + ExecutionContext nested(Object relativePath); /// Resolves a path against the current context's path. - DataPath resolvePath(DataPath pathToResolve); + DataPath resolvePath(Object pathToResolve); /// Resolves any dynamic values (bindings or function calls) in the given /// value. diff --git a/packages/genui/lib/src/model/data_model.dart b/packages/genui/lib/src/model/data_model.dart index cdd031110..9d1a548ea 100644 --- a/packages/genui/lib/src/model/data_model.dart +++ b/packages/genui/lib/src/model/data_model.dart @@ -3,8 +3,8 @@ // found in the LICENSE file. import 'dart:async'; -import 'dart:convert'; +import 'package:a2ui_core/a2ui_core.dart' as core; import 'package:flutter/foundation.dart'; import 'package:stream_transform/stream_transform.dart'; @@ -12,20 +12,152 @@ import '../primitives/logging.dart'; import '../primitives/simple_items.dart'; import '../utils/stream_extensions.dart'; import 'client_function.dart' as cf; - import 'data_path.dart'; export 'data_path.dart'; +/// Converts either a legacy [DataPath] or a string path into [DataPath]. +DataPath _toDataPath(Object path) => + path is DataPath ? path : DataPath('$path'); + +/// Exception thrown when a value in the [DataModel] is not of the expected +/// type. +class DataModelTypeException implements Exception { + /// Creates a [DataModelTypeException]. + DataModelTypeException({ + required this.path, + required this.expectedType, + required this.actualType, + }); + + /// The path where the type mismatch occurred. + final DataPath path; + + /// The expected type. + final Type expectedType; + + /// The actual type found. + final Type actualType; + + @override + String toString() { + return 'DataModelTypeException: Expected $expectedType at $path, ' + 'but found $actualType'; + } +} + +/// Manages the application's data model and provides a subscription-based +/// mechanism for reactive UI updates. +abstract interface class DataModel { + /// Updates the data model at a specific absolute path and notifies all + /// relevant subscribers. + void update(DataPath absolutePath, Object? contents); + + /// Subscribes to a specific absolute path in the data model. + ValueNotifier subscribe(DataPath absolutePath); + + /// Binds an external state [source] to a [path] in the DataModel. + void Function() bindExternalState({ + required DataPath path, + required ValueListenable source, + bool twoWay = false, + }); + + /// Disposes resources and bindings. + void dispose(); + + /// Retrieves a static, one-time value from the data model at the + /// specified absolute path without creating a subscription. + T? getValue(DataPath absolutePath); +} + +/// Standard in-memory implementation of [DataModel]. +/// +/// This is a source-compatible facade over `a2ui_core.DataModel`; it keeps the +/// legacy GenUI API shape while delegating storage and reactivity to the core +/// substrate. +class InMemoryDataModel implements DataModel { + /// Creates an empty in-memory data model. + InMemoryDataModel() : _core = core.DataModel(), _ownsCore = true; + + /// Wraps an existing core data model. + @internal + InMemoryDataModel.wrap(core.DataModel coreDataModel) + : _core = coreDataModel, + _ownsCore = false; + + final core.DataModel _core; + final bool _ownsCore; + final List _externalSubscriptions = []; + + /// The wrapped core data model. Intended for GenUI internals only. + @internal + core.DataModel get coreDataModel => _core; + + @override + void update(DataPath absolutePath, Object? contents) { + _core.set(absolutePath.toString(), contents); + } + + @override + ValueNotifier subscribe(DataPath absolutePath) { + return _SignalNotifier(_core.watch(absolutePath.toString())); + } + + @override + void Function() bindExternalState({ + required DataPath path, + required ValueListenable source, + bool twoWay = false, + }) { + final VoidCallback cleanup = bindExternalStateForDataModel( + dataModel: this, + path: path, + source: source, + twoWay: twoWay, + ); + _externalSubscriptions.add(cleanup); + return () { + cleanup(); + _externalSubscriptions.remove(cleanup); + }; + } + + @override + void dispose() { + for (final callback in List.of(_externalSubscriptions)) { + callback(); + } + _externalSubscriptions.clear(); + if (_ownsCore) { + _core.dispose(); + } + } + + @override + T? getValue(DataPath absolutePath) { + final Object? value = _core.get(absolutePath.toString()); + if (value != null && value is! T) { + throw DataModelTypeException( + path: absolutePath, + expectedType: T, + actualType: value.runtimeType, + ); + } + return value as T?; + } +} + /// A contextual view of the main DataModel, used by widgets to resolve /// relative and absolute paths. class DataContext implements cf.ExecutionContext { /// Creates a [DataContext] for the given [path]. DataContext( this._dataModel, - this.path, { + Object path, { Iterable? functions, - }) : _functions = { + }) : path = _toDataPath(path), + _functions = { if (functions != null) for (final f in functions) f.name: f, }; @@ -49,16 +181,16 @@ class DataContext implements cf.ExecutionContext { /// Subscribes to a path, resolving it against the current context. @override - ValueNotifier subscribe(DataPath path) { + ValueListenable subscribe(Object path) { final DataPath absolutePath = resolvePath(path); return _dataModel.subscribe(absolutePath); } /// Subscribes to a path and returns a [Stream]. @override - Stream subscribeStream(DataPath path) { + Stream subscribeStream(Object path) { late StreamController controller; - ValueNotifier? notifier; + ValueListenable? notifier; void listener() { if (!controller.isClosed) { @@ -73,8 +205,11 @@ class DataContext implements cf.ExecutionContext { notifier!.addListener(listener); }, onCancel: () { - notifier?.removeListener(listener); - notifier?.dispose(); + final currentNotifier = notifier; + currentNotifier?.removeListener(listener); + if (currentNotifier is ChangeNotifier) { + (currentNotifier as ChangeNotifier).dispose(); + } notifier = null; controller.close(); }, @@ -84,31 +219,27 @@ class DataContext implements cf.ExecutionContext { /// Gets a value, resolving the path against the current context. @override - T? getValue(DataPath path) => _dataModel.getValue(resolvePath(path)); + T? getValue(Object path) => _dataModel.getValue(resolvePath(path)); /// Updates the data model, resolving the path against the current context. @override - void update(DataPath path, Object? contents) => + void update(Object path, Object? contents) => _dataModel.update(resolvePath(path), contents); /// Creates a new, nested DataContext for a child widget. - /// - /// Used by list/template widgets to create a context for their children. @override - DataContext nested(DataPath relativePath) => + DataContext nested(Object relativePath) => DataContext._(_dataModel, resolvePath(relativePath), _functions); /// Resolves a path against the current context's path. @override - DataPath resolvePath(DataPath pathToResolve) => - pathToResolve.isAbsolute ? pathToResolve : path.join(pathToResolve); + DataPath resolvePath(Object pathToResolve) { + final DataPath path = _toDataPath(pathToResolve); + return path.isAbsolute ? path : this.path.join(path); + } /// Resolves any dynamic values (bindings or function calls) in the given /// value. - /// - /// String values are treated as literals (no interpolation). - /// Maps with a 'path' key are resolved to the value at that path. - /// Maps with a 'call' key are executed as functions. @override Stream resolve(Object? value) => _evaluateStream(value); @@ -137,7 +268,6 @@ class DataContext implements cf.ExecutionContext { return Stream.value(null); } - // Resolve arguments final Map args = {}; final Object? argsJson = callDefinition['args']; @@ -182,7 +312,6 @@ class DataContext implements cf.ExecutionContext { } /// Resolves a context map definition against a [DataContext]. -/// Future resolveContext( DataContext dataContext, JsonMap? contextDefinition, @@ -198,349 +327,105 @@ Future resolveContext( return resolved; } -/// Exception thrown when a value in the [DataModel] is not of the expected -/// type. -class DataModelTypeException implements Exception { - /// Creates a [DataModelTypeException]. - DataModelTypeException({ - required this.path, - required this.expectedType, - required this.actualType, - }); - - /// The path where the type mismatch occurred. - final DataPath path; - - /// The expected type. - final Type expectedType; - - /// The actual type found. - final Type actualType; - - @override - String toString() { - return 'DataModelTypeException: Expected $expectedType at $path, ' - 'but found $actualType'; +/// Binds an external state [source] to a [path] in a GenUI [DataModel]. +/// +/// Kept as a top-level helper for branch-internal code; the legacy public API +/// is [DataModel.bindExternalState]. +VoidCallback bindExternalStateForDataModel({ + required DataModel dataModel, + required DataPath path, + required ValueListenable source, + bool twoWay = false, +}) { + dataModel.update(path, source.value); + + void onSourceChanged() { + final T newValue = source.value; + final T? currentValue = dataModel.getValue(path); + if (currentValue != newValue) { + dataModel.update(path, newValue); + } } -} - -/// Manages the application's data model and provides a subscription-based -/// mechanism for reactive UI updates. -abstract interface class DataModel { - /// Updates the data model at a specific absolute path and notifies all - /// relevant subscribers. - /// - /// If [absolutePath] is root, the entire data model is replaced - /// (if contents is a Map). - void update(DataPath absolutePath, Object? contents); - - /// Subscribes to a specific absolute path in the data model. - ValueNotifier subscribe(DataPath absolutePath); - - /// Binds an external state [source] to a [path] in the DataModel. - /// - /// **Side Effect:** Calling this method immediately performs a synchronous - /// `update()` on the DataModel at the specified [path] using the current - /// value of the [source]. - /// - /// If [twoWay] is true, changes in the DataModel at [path] will also - /// update the [source] (assuming [source] is a [ValueNotifier]). - /// - /// Returns a function that disposes the binding. - void Function() bindExternalState({ - required DataPath path, - required ValueListenable source, - bool twoWay = false, - }); - - /// Disposes resources and bindings. - void dispose(); - - /// Retrieves a static, one-time value from the data model at the - /// specified absolute path without creating a subscription. - T? getValue(DataPath absolutePath); -} - -/// Standard in-memory implementation of [DataModel]. -class InMemoryDataModel implements DataModel { - JsonMap _data = {}; - final Map> _subscriptions = {}; - - final List _cleanupCallbacks = []; - @override - void update(DataPath absolutePath, Object? contents) { - genUiLogger.info( - 'DataModel.update: path=$absolutePath, contents=' - '${const JsonEncoder.withIndent(' ').convert(contents)}', - ); + source.addListener(onSourceChanged); - if (absolutePath == DataPath.root) { - if (contents is Map) { - _data = Map.from(contents); - } else { - genUiLogger.warning( - 'DataModel.update: contents for root path is not a Map: $contents', - ); - if (contents == null) { - _data = {}; + VoidCallback? removeModelListener; + if (twoWay) { + if (source is! ValueNotifier) { + genUiLogger.warning( + 'bindExternalState: twoWay is true but source is not a ValueNotifier.', + ); + } else { + final ValueNotifier notifier = source; + final ValueListenable subscription = dataModel.subscribe(path); + + void onModelChanged() { + final T? modelValue = subscription.value; + if (modelValue != null && modelValue != notifier.value) { + notifier.value = modelValue; } } - _notifySubscribers(DataPath.root); - return; - } - - _updateValue(_data, absolutePath.segments, contents); - _notifySubscribers(absolutePath); - } - @override - ValueNotifier subscribe(DataPath absolutePath) { - genUiLogger.finer('DataModel.subscribe: path=$absolutePath'); - if (_subscriptions.containsKey(absolutePath)) { - final notifier = - _subscriptions[absolutePath]! as _RefCountedValueNotifier; - notifier.incrementRef(); - return notifier; + subscription.addListener(onModelChanged); + removeModelListener = () { + subscription.removeListener(onModelChanged); + final currentSubscription = subscription; + if (currentSubscription is ChangeNotifier) { + (currentSubscription as ChangeNotifier).dispose(); + } + }; } - - final T? initialValue = getValue(absolutePath); - final notifier = _RefCountedValueNotifier( - initialValue, - onDispose: () { - _subscriptions.remove(absolutePath); - }, - ); - _subscriptions[absolutePath] = notifier; - return notifier; } - final List _externalSubscriptions = []; - - @override - void Function() bindExternalState({ - required DataPath path, - required ValueListenable source, - bool twoWay = false, - }) { - update(path, source.value); + return () { + source.removeListener(onSourceChanged); + removeModelListener?.call(); + }; +} - void onSourceChanged() { - final T newValue = source.value; - final T? currentValue = getValue(path); - if (currentValue != newValue) { - update(path, newValue); - } - } +/// Compatibility alias for branch-local callers that used the temporary +/// top-level helper name. +VoidCallback bindExternalState({ + required DataModel dataModel, + required Object path, + required ValueListenable source, + bool twoWay = false, +}) { + return bindExternalStateForDataModel( + dataModel: dataModel, + path: _toDataPath(path), + source: source, + twoWay: twoWay, + ); +} - source.addListener(onSourceChanged); - void removeSourceListener() => source.removeListener(onSourceChanged); - _externalSubscriptions.add(removeSourceListener); - - VoidCallback? removeModelListener; - if (twoWay) { - if (source is! ValueNotifier) { - genUiLogger.warning( - 'bindExternalState: twoWay is true but source is not a ' - 'ValueNotifier.', - ); +/// Bridges a preact_signals [core.ReadonlySignal] to a Flutter +/// [ValueNotifier]. +class _SignalNotifier extends ValueNotifier { + _SignalNotifier(this._signal) : super(_cast(_signal.peek())) { + _disposeEffect = core.effect(() { + final T? newValue = _cast(_signal.value); + if (newValue == value) { + notifyListeners(); } else { - final ValueNotifier notifier = source; - final ValueNotifier subscription = subscribe(path); - - void onModelChanged() { - final T? modelValue = subscription.value; - if (modelValue != null && modelValue != notifier.value) { - notifier.value = modelValue; - } - } - - subscription.addListener(onModelChanged); - removeModelListener = () { - subscription.removeListener(onModelChanged); - // When we are done with the subscription, we should dispose it to - // decrement ref count. - subscription.dispose(); - }; - _externalSubscriptions.add(removeModelListener); + value = newValue; } - } - - return () { - removeSourceListener(); - _externalSubscriptions.remove(removeSourceListener); - - if (removeModelListener != null) { - removeModelListener(); - _externalSubscriptions.remove(removeModelListener); - } - }; - } - - @override - void dispose() { - for (final VoidCallback callback in _cleanupCallbacks) { - callback(); - } - _cleanupCallbacks.clear(); - - for (final VoidCallback callback in _externalSubscriptions) { - callback(); - } - _externalSubscriptions.clear(); - - // The DataModel does not own the refcounts of the returned notifiers. - // They are owned by the subscribers who called subscribe(). - // We only need to clear our cache. Let the subscribers dispose them. - _subscriptions.clear(); + }); } - @override - T? getValue(DataPath absolutePath) { - if (absolutePath == DataPath.root) { - _checkType(_data, absolutePath); - return _data as T?; - } - final Object? value = _getValue(_data, absolutePath.segments); - _checkType(value, absolutePath); - return value as T?; - } + final core.ReadonlySignal _signal; + late final void Function() _disposeEffect; + bool _isDisposed = false; - void _checkType(Object? value, DataPath path) { - if (value != null && value is! T) { + static T? _cast(Object? v) { + if (v != null && v is! T) { throw DataModelTypeException( - path: path, + path: DataPath.root, expectedType: T, - actualType: value.runtimeType, + actualType: v.runtimeType, ); } - } - - Object? _getValue(Object? current, List segments) { - if (segments.isEmpty) { - return current; - } - - final String segment = segments.first; - final List remaining = segments.sublist(1); - - if (current is Map) { - return _getValue(current[segment], remaining); - } else if (current is List) { - final int? index = int.tryParse(segment); - if (index != null && index >= 0 && index < current.length) { - return _getValue(current[index], remaining); - } - } - return null; - } - - void _updateValue(Object? current, List segments, Object? value) { - if (segments.isEmpty) { - return; - } - - final String segment = segments.first; - final List remaining = segments.sublist(1); - - if (current is Map) { - if (remaining.isEmpty) { - if (value == null) { - current.remove(segment); - } else { - current[segment] = value; - } - return; - } - - Object? nextNode = current[segment]; - if (nextNode == null) { - if (value == null) { - return; - } - - final String nextSegment = remaining.first; - final isNextSegmentListIndex = int.tryParse(nextSegment) != null; - nextNode = isNextSegmentListIndex ? [] : {}; - current[segment] = nextNode; - } - _updateValue(nextNode, remaining, value); - } else if (current is List) { - final int? index = int.tryParse(segment); - if (index != null && index >= 0) { - if (remaining.isEmpty) { - if (index < current.length) { - if (value == null) { - current[index] = value; - } else { - current[index] = value; - } - } else if (index == current.length) { - if (value != null) current.add(value); - } - } else { - if (index < current.length) { - _updateValue(current[index], remaining, value); - } else if (index == current.length) { - final String nextSegment = remaining.first; - final isNextSegmentListIndex = int.tryParse(nextSegment) != null; - final Object newItem = isNextSegmentListIndex - ? [] - : {}; - current.add(newItem); - _updateValue(newItem, remaining, value); - } - } - } - } - } - - void _notifySubscribers(DataPath path) { - if (_subscriptions.containsKey(path)) { - _subscriptions[path]!.value = getValue(path); - } - - var parent = path; - while (!parent.isAbsolute || parent.segments.isNotEmpty) { - if (parent == DataPath.root) break; - if (!parent.isAbsolute && parent.segments.isEmpty) break; - parent = parent.dirname; - final _RefCountedValueNotifier? notifier = - _subscriptions[parent]; - if (notifier != null) { - final Object? newValue = getValue(parent); - if (newValue != notifier.value) { - notifier.value = newValue; - } else { - // _updateValue mutates containers in place, which means - // listeners on this ancestor won't get automatically notified by - // the `ValueNotifier.value` setter. - notifier.forceNotify(); - } - } - } - - for (final DataPath p in _subscriptions.keys.toList()) { - if (p.startsWith(path) && p != path) { - _subscriptions[p]!.value = getValue(p); - } - } - } -} - -class _RefCountedValueNotifier extends ValueNotifier { - _RefCountedValueNotifier(super.value, {this.onDispose}); - - final VoidCallback? onDispose; - int _refCount = 1; - bool _isDisposed = false; - - void incrementRef() { - _refCount++; - } - - void forceNotify() { - notifyListeners(); + return v as T?; } @override @@ -548,16 +433,13 @@ class _RefCountedValueNotifier extends ValueNotifier { if (_isDisposed) { genUiLogger.warning( 'Attempt to dispose of already disposed notifier', - '_RefCountedValueNotifier.dispose Error', + '_SignalNotifier.dispose Error', StackTrace.current, ); return; } - _refCount--; - if (_refCount <= 0) { - _isDisposed = true; - onDispose?.call(); - super.dispose(); - } + _isDisposed = true; + _disposeEffect(); + super.dispose(); } } diff --git a/packages/genui/lib/src/widgets/widget_utilities.dart b/packages/genui/lib/src/widgets/widget_utilities.dart index e356a785c..d84950d86 100644 --- a/packages/genui/lib/src/widgets/widget_utilities.dart +++ b/packages/genui/lib/src/widgets/widget_utilities.dart @@ -4,6 +4,7 @@ import 'dart:async'; +import 'package:a2ui_core/a2ui_core.dart' as core; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; @@ -14,10 +15,9 @@ export '../model/data_model.dart' show resolveContext; /// A builder widget that simplifies handling of nullable `ValueListenable`s. /// -/// This widget listens to a `ValueListenable` and rebuilds its child -/// whenever the value changes. If the value is `null`, it returns a -/// `SizedBox.shrink()`, effectively hiding the child. If the value is not -/// `null`, it calls the `builder` function with the non-nullable value. +/// Listens to a `ValueListenable` and rebuilds its child whenever the +/// value changes. Returns `SizedBox.shrink()` for null; otherwise calls +/// [builder] with the non-null value. class OptionalValueBuilder extends StatelessWidget { /// The `ValueListenable` to listen to. final ValueListenable listenable; @@ -47,8 +47,7 @@ class OptionalValueBuilder extends StatelessWidget { /// A widget that binds to a value in the [DataContext] and rebuilds when it /// changes. /// -/// This widget handles the lifecycle of the underlying [ValueNotifier], -/// ensuring it is disposed when the widget is unmounted. +/// Subclasses provide type-specific conversion via [BoundValueState.convert]. abstract class BoundValue extends StatefulWidget { /// Creates a [BoundValue]. const BoundValue({ @@ -71,14 +70,18 @@ abstract class BoundValue extends StatefulWidget { State> createState(); } -/// State class for [BoundValue]. +/// Backing state for [BoundValue]. Manages a preact_signals signal that +/// mirrors the resolved value. Function-call values (`{call: ...}`) are +/// driven by a [StreamSubscription] that pushes into the signal. abstract class BoundValueState> extends State { - ValueNotifier? _notifier; + late core.ReadonlySignal _signal; + StreamSubscription? _streamSub; + void Function()? _disposeBridge; @override void initState() { super.initState(); - _initNotifier(); + _setupSignal(); } @override @@ -86,40 +89,139 @@ abstract class BoundValueState> extends State { super.didUpdateWidget(oldWidget); if (widget.value != oldWidget.value || widget.dataContext != oldWidget.dataContext) { - _disposeNotifier(); - _initNotifier(); + _disposeSignal(); + _setupSignal(); } } @override void dispose() { - _disposeNotifier(); + _disposeSignal(); super.dispose(); } - void _initNotifier() { - _notifier = createNotifier(); + void _setupSignal() { + final Object? raw = widget.value; + + if (raw is Map && raw.containsKey('path')) { + final path = DataPath(raw['path'] as String); + final ValueListenable source = widget.dataContext + .subscribe(path); + // Bridge the legacy ValueListenable facade to a signal so the + // signal-backed BoundValue implementation can stay granular. + final core.Signal bridge = core.signal(convert(source.value)); + + void listener() { + bridge.set(convert(source.value), force: true); + } + + source.addListener(listener); + _disposeBridge = () { + source.removeListener(listener); + final currentSource = source; + if (currentSource is ChangeNotifier) { + (currentSource as ChangeNotifier).dispose(); + } + }; + _signal = bridge; + } else if (raw is Map && raw.containsKey('call')) { + // Function-call resolution stays Stream-based for now; bridge to signal. + final core.Signal s = core.signal(null); + _streamSub = widget.dataContext + .resolve(raw) + .listen( + (Object? v) => s.value = convert(v), + onError: (Object error) { + genUiLogger.warning('Error in Bound stream', error); + }, + ); + _signal = s; + } else { + _signal = core.signal(convert(raw)); + } } - void _disposeNotifier() { - _notifier?.dispose(); - _notifier = null; + void _disposeSignal() { + _streamSub?.cancel(); + _streamSub = null; + _disposeBridge?.call(); + _disposeBridge = null; + // preact_signals don't require explicit disposal; subscriptions are torn + // down when the consuming Effect (in _SignalBuilder) is disposed. } - /// Subclasses implement this to create the specific notifier type. - ValueNotifier createNotifier(); + /// Converts a raw resolved value into the typed [T?]. + T? convert(Object? value); @override Widget build(BuildContext context) { - return ValueListenableBuilder( - valueListenable: _notifier!, - builder: (context, value, child) { - return widget.builder(context, value); - }, + return _SignalBuilder( + signal: _signal, + builder: (ctx, value) => widget.builder(ctx, value), ); } } +/// Subscribes to a preact_signals [core.ReadonlySignal] and rebuilds when it +/// changes. Stand-in for `Watch` from a signals-Flutter package, since +/// `signals_flutter` is built on a different (incompatible) signals library. +class _SignalBuilder extends StatefulWidget { + const _SignalBuilder({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()? _disposeEffect; + bool _initialRun = true; + + @override + void initState() { + super.initState(); + _value = widget.signal.peek(); + _subscribe(); + } + + @override + void didUpdateWidget(_SignalBuilder oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.signal != oldWidget.signal) { + _disposeEffect?.call(); + _initialRun = true; + _value = widget.signal.peek(); + _subscribe(); + } + } + + void _subscribe() { + _disposeEffect = core.effect(() { + final T newValue = widget.signal.value; + // First run is the dependency-tracking pass; value already set from peek. + if (_initialRun) { + _initialRun = false; + return; + } + if (mounted) { + setState(() => _value = newValue); + } + }); + } + + @override + void dispose() { + _disposeEffect?.call(); + super.dispose(); + } + + @override + Widget build(BuildContext context) => widget.builder(context, _value); +} + /// Binds to a [String] value. class BoundString extends BoundValue { /// Creates a [BoundString]. @@ -136,17 +238,7 @@ class BoundString extends BoundValue { class _BoundStringState extends BoundValueState { @override - ValueNotifier createNotifier() { - return switch (widget.value) { - {'path': String path} => _ToStringNotifier( - widget.dataContext.subscribe(DataPath(path)), - ), - {'call': _} => _StreamToValueNotifier( - widget.dataContext.resolve(widget.value).map((v) => v?.toString()), - ), - Object? value => ValueNotifier(value?.toString()), - }; - } + String? convert(Object? value) => value?.toString(); } /// Binds to a [bool] value. @@ -165,20 +257,14 @@ class BoundBool extends BoundValue { class _BoundBoolState extends BoundValueState { @override - ValueNotifier createNotifier() { - return switch (widget.value) { - {'path': String path} => _ToBoolNotifier( - widget.dataContext.subscribe(DataPath(path)), - ), - {'call': _} => _StreamToValueNotifier( - widget.dataContext.resolve(widget.value).map((v) { - if (v is bool) return v; - return v != null; - }), - ), - bool value => ValueNotifier(value), - _ => ValueNotifier(null), - }; + bool? convert(Object? value) { + if (value is bool) return value; + if (value is String) { + if (value.toLowerCase() == 'true') return true; + if (value.toLowerCase() == 'false') return false; + } + if (value is num) return value != 0; + return null; } } @@ -198,21 +284,10 @@ class BoundNumber extends BoundValue { class _BoundNumberState extends BoundValueState { @override - ValueNotifier createNotifier() { - return switch (widget.value) { - {'path': String path} => _ToNumberNotifier( - widget.dataContext.subscribe(DataPath(path)), - ), - {'call': _} => _StreamToValueNotifier( - widget.dataContext.resolve(widget.value).map((v) { - if (v is num) return v; - if (v is String) return num.tryParse(v); - return null; - }), - ), - num value => ValueNotifier(value), - _ => ValueNotifier(null), - }; + num? convert(Object? value) { + if (value is num) return value; + if (value is String) return num.tryParse(value); + return null; } } @@ -232,22 +307,9 @@ class BoundList extends BoundValue> { class _BoundListState extends BoundValueState, BoundList> { @override - ValueNotifier?> createNotifier() { - return switch (widget.value) { - {'path': String path} => widget.dataContext.subscribe>( - DataPath(path), - ), - {'call': _} => _StreamToValueNotifier?>( - widget.dataContext.resolve(widget.value).map((v) { - if (v is List) return v.cast(); - return null; - }), - ), - List value => ValueNotifier?>( - value.cast(), - ), - _ => ValueNotifier?>(null), - }; + List? convert(Object? value) { + if (value is List) return value.cast(); + return null; } } @@ -267,113 +329,5 @@ class BoundObject extends BoundValue { class _BoundObjectState extends BoundValueState { @override - ValueNotifier createNotifier() { - return switch (widget.value) { - {'path': String path} => widget.dataContext.subscribe( - DataPath(path), - ), - {'call': _} => _StreamToValueNotifier( - widget.dataContext.resolve(widget.value), - ), - Object? value => ValueNotifier(value), - }; - } -} - -class _StreamToValueNotifier extends ValueNotifier { - _StreamToValueNotifier(Stream stream, [T? initialValue]) - : super(initialValue) { - _subscription = stream.listen( - (value) => this.value = value, - onError: (Object error) { - // We log the error but don't crash. - // ValueNotifier doesn't support error state. - genUiLogger.warning( - 'Error in stream subscription for ValueNotifier', - error, - ); - }, - ); - } - - StreamSubscription? _subscription; - - @override - void dispose() { - _subscription?.cancel(); - super.dispose(); - } -} - -class _ToStringNotifier extends ValueNotifier { - _ToStringNotifier(this._source) : super(_source.value?.toString()) { - _source.addListener(_update); - } - - final ValueNotifier _source; - - void _update() { - super.value = _source.value?.toString(); - } - - @override - void dispose() { - _source.removeListener(_update); - _source.dispose(); - super.dispose(); - } -} - -class _ToBoolNotifier extends ValueNotifier { - _ToBoolNotifier(this._source) : super(_convert(_source.value)) { - _source.addListener(_update); - } - - final ValueNotifier _source; - - static bool? _convert(Object? value) { - if (value is bool) return value; - if (value is String) { - if (value.toLowerCase() == 'true') return true; - if (value.toLowerCase() == 'false') return false; - } - if (value is num) return value != 0; - return null; - } - - void _update() { - super.value = _convert(_source.value); - } - - @override - void dispose() { - _source.removeListener(_update); - _source.dispose(); - super.dispose(); - } -} - -class _ToNumberNotifier extends ValueNotifier { - _ToNumberNotifier(this._source) : super(_convert(_source.value)) { - _source.addListener(_update); - } - - final ValueNotifier _source; - - static num? _convert(Object? value) { - if (value is num) return value; - if (value is String) return num.tryParse(value); - return null; - } - - void _update() { - super.value = _convert(_source.value); - } - - @override - void dispose() { - _source.removeListener(_update); - _source.dispose(); - super.dispose(); - } + Object? convert(Object? value) => value; } diff --git a/packages/genui/pubspec.yaml b/packages/genui/pubspec.yaml index 8580f3eb2..71506f532 100644 --- a/packages/genui/pubspec.yaml +++ b/packages/genui/pubspec.yaml @@ -16,6 +16,7 @@ environment: resolution: workspace dependencies: + a2ui_core: ^0.0.1-dev002 audioplayers: ^6.6.0 collection: ^1.19.1 flutter: diff --git a/packages/genui/test/widgets/widget_utilities_test.dart b/packages/genui/test/widgets/widget_utilities_test.dart new file mode 100644 index 000000000..372547a10 --- /dev/null +++ b/packages/genui/test/widgets/widget_utilities_test.dart @@ -0,0 +1,72 @@ +// Copyright 2025 The Flutter Authors. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:genui/genui.dart'; + +void main() { + group('BoundObject', () { + testWidgets('rebuilds on in-place map mutation at a parent path', ( + tester, + ) async { + final dataModel = InMemoryDataModel(); + dataModel.update(DataPath('/map'), {'count': 1}); + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: BoundObject( + dataContext: DataContext(dataModel, '/'), + value: const {'path': '/map'}, + builder: (context, value) { + if (value is Map) { + return Text('count=${value['count']}'); + } + return const Text('no map'); + }, + ), + ), + ); + expect(find.text('count=1'), findsOneWidget); + + // Mutate a nested key. a2ui_core's DataModel updates the map in place + // and bubble-notifies the parent path /map. BoundObject must rebuild + // even though the resolved value at /map is the same Map instance. + dataModel.update(DataPath('/map/count'), 2); + await tester.pump(); + + expect(find.text('count=2'), findsOneWidget); + }); + }); + + group('BoundList', () { + testWidgets('rebuilds on in-place list mutation at the bound path', ( + tester, + ) async { + final dataModel = InMemoryDataModel(); + dataModel.update(DataPath('/items'), ['a', 'b']); + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: BoundList( + dataContext: DataContext(dataModel, '/'), + value: const {'path': '/items'}, + builder: (context, value) { + if (value == null) return const Text('null'); + return Text('count=${value.length}'); + }, + ), + ), + ); + expect(find.text('count=2'), findsOneWidget); + + dataModel.update(DataPath('/items/2'), 'c'); + await tester.pump(); + + expect(find.text('count=3'), findsOneWidget); + }); + }); +} From 7e26a0be7eb281cc171732255b75730554effcfe Mon Sep 17 00:00:00 2001 From: Andrew Kolos Date: Tue, 26 May 2026 14:15:43 -0700 Subject: [PATCH 03/49] refactor(genui): live a2ui_core surface state behind SurfaceDefinition facade MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Surfaces internally become live core.SurfaceModel instances managed by core.SurfaceGroupModel, with the existing SurfaceDefinition / Component public API preserved as a snapshot facade. GenUI's own renderer reads the live model for granular per-component rebuilds; external code that implements SurfaceContext keeps using the legacy snapshot API. Substantive pieces: 1. surface_registry.dart owns the live core.SurfaceModel for each active surface and exposes both: - watchSurface(id): ValueListenable (live) - watchDefinition(id): ValueListenable (snapshot) The latter materializes a SurfaceDefinition.fromCore lazily when listeners actually read it, and re-materializes on per-component updates. removeSurface only nulls the notifier values — the notifiers stay alive across delete so re-create-with-same-id finds widgets still attached. 2. interfaces/surface_context.dart exposes the legacy `definition: ValueListenable` plus a new @internal LiveSurfaceContext extension that adds `surface: ValueListenable`. External SurfaceContext implementations only need to provide the legacy `definition` snapshot path; GenUI's own controller provides both. 3. widgets/surface.dart has two render paths: - _buildLiveWidget: used when the context is LiveSurfaceContext. Wraps each rendered component in a _ComponentBuilder that subscribes to that component's onUpdated, so an UpdateComponents for one component rebuilds only its subtree (#811 §3.5). - _buildWidgetFromDefinition: used when the context only provides the legacy snapshot path. Rebuilds from the snapshot on every definition change (pre-#811 behavior). This dual path is the cost of the compat-facade strategy. Custom SurfaceContext implementations stay on the legacy path; granular reactivity is opt-in by implementing LiveSurfaceContext. 4. ui_models.dart: SurfaceDefinition gains a .fromCore(SurfaceModel) factory and keeps its validate(Schema), copyWith, asContextDescription methods. SurfaceUpdate.{SurfaceAdded,ComponentsUpdated} carries both the live `surface` and a lazy `definition` snapshot — lifecycle-only listeners pay nothing. 5. engine/data_model_store.dart is kept as a compat facade. Its lookup callback now redirects to InMemoryDataModel.wrap of the live surface.dataModel for active surfaces, falling back to standalone models when no live surface exists. Carries an explicit dartdoc marking it as compatibility-only. 6. model/parts/ui.dart's UiPart.create accepts either SurfaceDefinition or a raw JsonMap as `definition`. Parsing always re-materializes a SurfaceDefinition snapshot so consumers don't see the wire-shape difference. 7. Legacy SurfaceDefinition.validate(Schema) is preserved. Tests covering surface lifecycle live in commit 4 with their controller/registry counterparts. --- .../lib/src/engine/data_model_store.dart | 48 ++- .../lib/src/engine/surface_registry.dart | 157 ++++---- packages/genui/lib/src/interfaces.dart | 2 +- .../lib/src/interfaces/surface_context.dart | 17 +- packages/genui/lib/src/model/parts/ui.dart | 9 +- packages/genui/lib/src/model/ui_models.dart | 279 ++++---------- packages/genui/lib/src/widgets/surface.dart | 349 ++++++++++++++---- 7 files changed, 516 insertions(+), 345 deletions(-) diff --git a/packages/genui/lib/src/engine/data_model_store.dart b/packages/genui/lib/src/engine/data_model_store.dart index 5a63ff916..3b54fdf4e 100644 --- a/packages/genui/lib/src/engine/data_model_store.dart +++ b/packages/genui/lib/src/engine/data_model_store.dart @@ -4,14 +4,50 @@ import '../model/data_model.dart'; -/// Manages the data models for surfaces. +/// A compatibility facade over the per-surface data models managed by +/// `a2ui_core.SurfaceGroupModel`. +/// +/// Earlier `package:genui` releases kept a separate per-surface +/// `InMemoryDataModel` registry here, distinct from anything the substrate +/// owned. Post-`a2ui_core` migration, the canonical data model lives on +/// `core.SurfaceModel.dataModel`; this class exists to preserve the old +/// public API (`SurfaceController.store`, `store.getDataModel(surfaceId)`) +/// while transparently returning a [DataModel] wrapper over the live +/// substrate model. +/// +/// The [lookup] callback is what does the substrate redirection: GenUI's +/// own `SurfaceController` constructs the store with a lookup that returns +/// `InMemoryDataModel.wrap(surface.dataModel)` for active surfaces. When +/// no live surface exists for a requested id, [getDataModel] falls back to +/// a standalone in-memory model — preserving the prior "data survives +/// before createSurface" leniency that some integration paths relied on. +/// +/// This class will be removed in the same follow-up PR that renames the +/// rest of GenUI's facade types to match `a2ui_core` directly; new code +/// should prefer reading from the surface's own data model via +/// `SurfaceController.registry.getSurface(id)?.dataModel`. class DataModelStore { + /// Creates a [DataModelStore]. + /// + /// When [lookup] returns a model for a surface, that live model is used + /// instead of creating a standalone fallback model. This keeps the legacy + /// store API source-compatible while GenUI's own controller stores data in + /// the a2ui_core-backed surface model. + DataModelStore({DataModel? Function(String surfaceId)? lookup}) + : _lookup = lookup; + + final DataModel? Function(String surfaceId)? _lookup; final Map _dataModels = {}; + final Map _liveDataModels = {}; final Set _attachedSurfaces = {}; /// Retrieves the data model for the given [surfaceId], creating it if it /// does not exist. DataModel getDataModel(String surfaceId) { + final DataModel? liveModel = _lookup?.call(surfaceId); + if (liveModel != null) { + return _liveDataModels.putIfAbsent(surfaceId, () => liveModel); + } return _dataModels.putIfAbsent(surfaceId, InMemoryDataModel.new); } @@ -19,6 +55,8 @@ class DataModelStore { void removeDataModel(String surfaceId) { final DataModel? model = _dataModels.remove(surfaceId); model?.dispose(); + final DataModel? liveModel = _liveDataModels.remove(surfaceId); + liveModel?.dispose(); _attachedSurfaces.remove(surfaceId); } @@ -33,12 +71,18 @@ class DataModelStore { } /// An unmodifiable map of all registered data models. - Map get dataModels => Map.unmodifiable(_dataModels); + Map get dataModels => + Map.unmodifiable({..._dataModels, ..._liveDataModels}); /// Disposes of all data models in this store. void dispose() { for (final DataModel model in _dataModels.values) { model.dispose(); } + for (final DataModel model in _liveDataModels.values) { + model.dispose(); + } + _dataModels.clear(); + _liveDataModels.clear(); } } diff --git a/packages/genui/lib/src/engine/surface_registry.dart b/packages/genui/lib/src/engine/surface_registry.dart index 72285889c..6d51d83b6 100644 --- a/packages/genui/lib/src/engine/surface_registry.dart +++ b/packages/genui/lib/src/engine/surface_registry.dart @@ -4,9 +4,10 @@ import 'dart:async'; +import 'package:a2ui_core/a2ui_core.dart' hide Catalog, DataContext; import 'package:flutter/foundation.dart'; -import '../model/ui_models.dart'; +import '../model/ui_models.dart' as genui_model; import '../primitives/logging.dart'; /// Events emitted by the [SurfaceRegistry]. @@ -14,31 +15,30 @@ sealed class RegistryEvent {} /// An event indicating that a new surface has been added. class SurfaceAdded extends RegistryEvent { - /// Creates a [SurfaceAdded] event. - SurfaceAdded(this.surfaceId, this.definition); + SurfaceAdded(this.surfaceId, this.surface); final String surfaceId; - final SurfaceDefinition definition; + final SurfaceModel surface; } /// An event indicating that a surface has been removed. class SurfaceRemoved extends RegistryEvent { - /// Creates a [SurfaceRemoved] event. SurfaceRemoved(this.surfaceId); final String surfaceId; } -/// An event indicating that a surface has been updated. +/// An event indicating that a surface's components were updated. class SurfaceUpdated extends RegistryEvent { - /// Creates a [SurfaceUpdated] event. - SurfaceUpdated(this.surfaceId, this.definition); + SurfaceUpdated(this.surfaceId, this.surface); final String surfaceId; - final SurfaceDefinition definition; + final SurfaceModel surface; } -/// Manages the lifecycle and storage of [SurfaceDefinition]s. +/// Tracks live [SurfaceModel]s by surface ID and exposes Flutter-friendly +/// [ValueListenable]s for them, plus a registry-event stream. class SurfaceRegistry { - final Map> _surfaces = {}; - // Track creation/update order for cleanup policies + final Map> _surfaces = {}; + final Map> + _definitions = {}; final List _surfaceOrder = []; final StreamController _eventController = StreamController.broadcast(); @@ -47,83 +47,112 @@ class SurfaceRegistry { Stream get events => _eventController.stream; /// The list of surface IDs in the order they were created or updated. - /// - /// This is used by cleanup strategies to determine which surfaces to remove. List get surfaceOrder => List.unmodifiable(_surfaceOrder); - /// Returns a [ValueListenable] that tracks the definition of the surface - /// with the given [surfaceId]. - /// - /// If the surface does not exist, a new notifier is created with a null - /// value. - ValueListenable watchSurface(String surfaceId) { + /// Returns a [ValueListenable] that tracks the surface with the given + /// [surfaceId]. The value is `null` until the surface is registered, and + /// becomes `null` again when it is removed. + ValueListenable watchSurface(String surfaceId) { if (!_surfaces.containsKey(surfaceId)) { - genUiLogger.fine('Adding new surface $surfaceId'); - } else { - genUiLogger.fine('Fetching surface notifier for $surfaceId'); + genUiLogger.fine('Adding new surface watcher for $surfaceId'); } return _surfaces.putIfAbsent( surfaceId, - () => ValueNotifier(null), + () => ValueNotifier(null), ); } - /// Updates the definition of a surface. - /// - /// If [isNew] is true, a [SurfaceAdded] event is emitted. Otherwise, a - /// [SurfaceUpdated] event is emitted. - void updateSurface( + /// Returns a [ValueListenable] that tracks the source-compatible snapshot + /// definition for the given [surfaceId]. + ValueListenable watchDefinition( String surfaceId, - SurfaceDefinition definition, { - bool isNew = false, - }) { - final ValueNotifier notifier = _surfaces.putIfAbsent( + ) { + return _definitions.putIfAbsent( surfaceId, - () => ValueNotifier(null), + () => ValueNotifier(null), ); - notifier.value = definition; + } - _surfaceOrder.remove(surfaceId); - _surfaceOrder.add(surfaceId); - - if (isNew) { - genUiLogger.info('Created new surface $surfaceId'); - _eventController.add(SurfaceAdded(surfaceId, definition)); - } else { - // genUiLogger.info('Updated surface $surfaceId'); // Optional logging - _eventController.add(SurfaceUpdated(surfaceId, definition)); - } + /// Registers a new surface, emitting a [SurfaceAdded] event. + void addSurface(SurfaceModel surface) { + final ValueNotifier?> notifier = _surfaces + .putIfAbsent(surface.id, () => ValueNotifier(null)); + notifier.value = surface; + _definitions + .putIfAbsent( + surface.id, + () => ValueNotifier(null), + ) + .value = genui_model.SurfaceDefinition.fromCore( + surface, + ); + _surfaceOrder + ..remove(surface.id) + ..add(surface.id); + genUiLogger.info('Created new surface ${surface.id}'); + _eventController.add(SurfaceAdded(surface.id, surface)); } - /// Removes a surface from the registry. + /// Signals that the components of a surface have changed. + void notifyUpdated(SurfaceModel surface) { + _surfaceOrder + ..remove(surface.id) + ..add(surface.id); + _definitions + .putIfAbsent( + surface.id, + () => ValueNotifier(null), + ) + .value = genui_model.SurfaceDefinition.fromCore( + surface, + ); + _eventController.add(SurfaceUpdated(surface.id, surface)); + } + + /// Removes a surface from the registry, emitting a [SurfaceRemoved] event. /// - /// Emits a [SurfaceRemoved] event if the surface existed. + /// The per-id [ValueNotifier] is kept (not removed from the map, not + /// disposed) so that any widget already listening to it stays connected. + /// If a surface with the same id is later created, the existing notifier's + /// value is updated and the widget gets notified — without this, the + /// widget would be stranded on a dead notifier. + /// + /// The [SurfaceModel] itself is owned by the substrate's + /// `core.SurfaceGroupModel` and disposed there; this registry only nulls + /// out the notifier value. void removeSurface(String surfaceId) { - if (_surfaces.containsKey(surfaceId)) { - genUiLogger.info('Deleting surface $surfaceId'); - final ValueNotifier? notifier = _surfaces.remove( - surfaceId, - ); - notifier?.dispose(); - _surfaceOrder.remove(surfaceId); - _eventController.add(SurfaceRemoved(surfaceId)); - } + final ValueNotifier?>? notifier = + _surfaces[surfaceId]; + if (notifier == null || notifier.value == null) return; + genUiLogger.info('Deleting surface $surfaceId'); + notifier.value = null; + _definitions[surfaceId]?.value = null; + _surfaceOrder.remove(surfaceId); + _eventController.add(SurfaceRemoved(surfaceId)); } - /// Returns true if the registry contains a surface with the given + /// Returns true if the registry has a watcher (or live surface) for /// [surfaceId]. - bool hasSurface(String surfaceId) => _surfaces.containsKey(surfaceId); + bool hasSurface(String surfaceId) => _surfaces[surfaceId]?.value != null; - /// Returns the current definition of the surface with the given [surfaceId], - /// or null if it doesn't exist. - SurfaceDefinition? getSurface(String surfaceId) => - _surfaces[surfaceId]?.value; + /// Returns the current surface with the given [surfaceId], or `null`. + SurfaceModel? getSurface(String surfaceId) => _surfaces[surfaceId]?.value; - /// Disposes of the registry and all its resources. + /// Disposes of the registry and all per-surface notifiers. The underlying + /// [SurfaceModel]s are owned and disposed by the substrate's + /// `core.SurfaceGroupModel`, not by this registry. void dispose() { _eventController.close(); - for (final ValueNotifier notifier in _surfaces.values) { + for (final ValueNotifier?> notifier + in _surfaces.values) { + notifier.dispose(); + } + for (final ValueNotifier notifier + in _definitions.values) { notifier.dispose(); } + _surfaces.clear(); + _definitions.clear(); + _surfaceOrder.clear(); } } diff --git a/packages/genui/lib/src/interfaces.dart b/packages/genui/lib/src/interfaces.dart index 79e9c4a6f..fccbaeb5d 100644 --- a/packages/genui/lib/src/interfaces.dart +++ b/packages/genui/lib/src/interfaces.dart @@ -7,6 +7,6 @@ library; export 'interfaces/a2ui_message_sink.dart'; -export 'interfaces/surface_context.dart'; +export 'interfaces/surface_context.dart' hide LiveSurfaceContext; export 'interfaces/surface_host.dart'; export 'interfaces/transport.dart'; diff --git a/packages/genui/lib/src/interfaces/surface_context.dart b/packages/genui/lib/src/interfaces/surface_context.dart index dd76909be..0ef549e12 100644 --- a/packages/genui/lib/src/interfaces/surface_context.dart +++ b/packages/genui/lib/src/interfaces/surface_context.dart @@ -2,6 +2,7 @@ // 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/foundation.dart'; import '../model/catalog.dart'; @@ -10,12 +11,13 @@ import '../model/ui_models.dart'; /// An interface for a specific UI surface context. /// -/// This provides access to the state and definition of a single surface. +/// This provides access to the source-compatible surface snapshot and data +/// model facade for a single surface. abstract interface class SurfaceContext { /// The ID of the surface this context is bound to. String get surfaceId; - /// The current definition of the UI for this surface. + /// The current snapshot definition of the UI for this surface. ValueListenable get definition; /// The data model for this surface. @@ -30,3 +32,14 @@ abstract interface class SurfaceContext { /// Reports an error capable of being sent back to the AI. void reportError(Object error, StackTrace? stack); } + +/// Internal live-surface extension used by GenUI's own controller/widget pair. +/// +/// External/custom [SurfaceContext] implementations only need the legacy +/// [SurfaceContext.definition] API; when this live interface is available the +/// renderer can subscribe to per-component core updates for granular rebuilds. +@internal +abstract interface class LiveSurfaceContext implements SurfaceContext { + /// The live core surface model for this surface. + ValueListenable get surface; +} diff --git a/packages/genui/lib/src/model/parts/ui.dart b/packages/genui/lib/src/model/parts/ui.dart index e291dc808..1b107487d 100644 --- a/packages/genui/lib/src/model/parts/ui.dart +++ b/packages/genui/lib/src/model/parts/ui.dart @@ -66,12 +66,11 @@ extension UiPartListExtension on Iterable { @immutable final class UiPart { /// Creates a [DataPart] compatible with GenUI. - static DataPart create({ - required SurfaceDefinition definition, - String? surfaceId, - }) { + static DataPart create({required Object definition, String? surfaceId}) { final Map json = { - _Json.definition: definition.toJson(), + _Json.definition: definition is SurfaceDefinition + ? definition.toJson() + : definition as JsonMap, _Json.surfaceId: surfaceId ?? generateId(), }; return DataPart( diff --git a/packages/genui/lib/src/model/ui_models.dart b/packages/genui/lib/src/model/ui_models.dart index 3915740c3..2eb8a6906 100644 --- a/packages/genui/lib/src/model/ui_models.dart +++ b/packages/genui/lib/src/model/ui_models.dart @@ -4,11 +4,13 @@ import 'dart:convert'; +import 'package:a2ui_core/a2ui_core.dart' as core; import 'package:collection/collection.dart'; import 'package:json_schema_builder/json_schema_builder.dart'; import '../primitives/constants.dart'; import '../primitives/simple_items.dart'; +import 'schema_validation.dart' as schema_validation; /// A callback that is called when events are sent. typedef SendEventsCallback = @@ -19,8 +21,8 @@ typedef DispatchEventCallback = void Function(UiEvent event); /// A data object that represents a user interaction event in the UI. /// -/// Used to send information from the app to the AI about user -/// actions, such as tapping a button or entering text. +/// Used to send information from the app to the AI about user actions, +/// such as tapping a button or entering text. extension type UiEvent.fromMap(JsonMap _json) { /// The ID of the surface that this event originated from. String get surfaceId => _json[surfaceIdKey] as String; @@ -32,8 +34,6 @@ extension type UiEvent.fromMap(JsonMap _json) { String get eventType => _json['eventType'] as String; /// The value associated with the event, if any. - /// - /// For example, the text in a `TextField`, or the value of a `Checkbox`. Object? get value => _json['value']; /// The timestamp of when the event occurred. @@ -43,9 +43,7 @@ extension type UiEvent.fromMap(JsonMap _json) { JsonMap toMap() => _json; } -/// A UI event that represents a user action. -/// -/// Triggers a submission to the AI, such as tapping a button. +/// A UI event that represents a user action — triggers a submission to the AI. extension type UserActionEvent.fromMap(JsonMap _json) implements UiEvent { /// Creates a [UserActionEvent] from a set of properties. UserActionEvent({ @@ -80,21 +78,10 @@ final class _Json { /// A data object that represents the entire UI definition. /// -/// The root object that defines a complete UI to be rendered. +/// This is a legacy GenUI snapshot facade. Live mutation is owned by +/// `a2ui_core.SurfaceModel`; snapshots are materialized for public API +/// compatibility. class SurfaceDefinition { - /// The ID of the surface that this UI belongs to. - final String surfaceId; - - /// The ID of the catalog to use for rendering this surface. - final String catalogId; - - /// A map of all widget definitions in the UI, keyed by their ID. - Map get components => UnmodifiableMapView(_components); - final Map _components; - - /// The theme for this surface. - final JsonMap? theme; - /// Creates a [SurfaceDefinition]. SurfaceDefinition({ required this.surfaceId, @@ -117,6 +104,32 @@ class SurfaceDefinition { ); } + /// Creates a snapshot from a live core surface model. + factory SurfaceDefinition.fromCore(core.SurfaceModel surface) { + return SurfaceDefinition( + surfaceId: surface.id, + catalogId: surface.catalog.id, + components: { + for (final core.ComponentModel component in surface.componentsModel.all) + component.id: Component.fromCore(component), + }, + theme: surface.theme.isEmpty ? null : JsonMap.from(surface.theme), + ); + } + + /// The ID of the surface that this UI belongs to. + final String surfaceId; + + /// The ID of the catalog to use for rendering this surface. + final String catalogId; + + /// A map of all widget definitions in the UI, keyed by their ID. + Map get components => UnmodifiableMapView(_components); + final Map _components; + + /// The theme for this surface. + final JsonMap? theme; + /// Creates a copy of this [SurfaceDefinition] with the given fields replaced. SurfaceDefinition copyWith({ String? catalogId, @@ -150,165 +163,14 @@ class SurfaceDefinition { } /// Validates the UI definition against a schema. - /// - /// Throws [A2uiValidationException] if validation fails. void validate(Schema schema) { - final String jsonOutput = schema.toJson(); - final schemaMap = jsonDecode(jsonOutput) as Map; - - List> allowedSchemas = []; - if (schemaMap.containsKey('oneOf')) { - allowedSchemas = (schemaMap['oneOf'] as List) - .cast>(); - } else if (schemaMap.containsKey('properties') && - (schemaMap['properties'] as Map).containsKey('components')) { - final componentsProp = - (schemaMap['properties'] as Map)['components'] - as Map; - if (componentsProp.containsKey('items')) { - final items = componentsProp['items'] as Map; - if (items.containsKey('oneOf')) { - allowedSchemas = (items['oneOf'] as List) - .cast>(); - } else { - allowedSchemas = [items]; - } - } else if (componentsProp.containsKey('properties')) { - allowedSchemas = (componentsProp['properties'] as Map).values - .cast>() - .toList(); - } - } - - if (allowedSchemas.isEmpty) { - return; - } - - for (final Component component in components.values) { - var matched = false; - List errors = []; - final JsonMap instanceJson = component.toJson(); - - for (final s in allowedSchemas) { - if (_schemaMatchesType(s, component.type)) { - try { - _validateInstance(instanceJson, s, '/components/${component.id}'); - matched = true; - break; - } catch (e) { - errors.add(e.toString()); - } - } - } - - if (!matched) { - if (errors.isNotEmpty) { - throw A2uiValidationException( - 'Validation failed for component ${component.id} ' - '(${component.type}): ${errors.join("; ")}', - surfaceId: surfaceId, - path: '/components/${component.id}', - ); - } - throw A2uiValidationException( - 'Unknown component type: ${component.type}', - surfaceId: surfaceId, - path: '/components/${component.id}', - ); - } - } - } - - bool _schemaMatchesType(Map schema, String type) { - if (schema case { - 'properties': {'component': Map compProp}, - }) { - return switch (compProp) { - {'const': String constType} when constType == type => true, - {'enum': List enums} when enums.contains(type) => true, - _ => false, - }; - } - return false; - } - - void _validateInstance( - Object? instance, - Map schema, - String path, - ) { - if (instance == null) { - return; - } - - if (schema case {'const': Object? constVal} when instance != constVal) { - throw A2uiValidationException( - 'Value mismatch. Expected $constVal, got $instance', - surfaceId: surfaceId, - path: path, - ); - } - - if (schema case { - 'enum': List enums, - } when !enums.contains(instance)) { - throw A2uiValidationException( - 'Value not in enum: $instance', - surfaceId: surfaceId, - path: path, - ); - } - - if (schema case {'required': List required} when instance is Map) { - for (final String key in required.cast()) { - if (!instance.containsKey(key)) { - throw A2uiValidationException( - 'Missing required property: $key', - surfaceId: surfaceId, - path: path, - ); - } - } - } - - if (schema case { - 'properties': Map props, - } when instance is Map) { - for (final MapEntry entry in props.entries) { - final String key = entry.key; - final propSchema = entry.value as Map; - if (instance.containsKey(key)) { - _validateInstance(instance[key], propSchema, '$path/$key'); - } - } - } - - if (schema case { - 'items': Map itemsSchema, - } when instance is List) { - for (var i = 0; i < instance.length; i++) { - _validateInstance(instance[i], itemsSchema, '$path/$i'); - } - } - - if (schema case {'oneOf': List oneOfs}) { - var oneMatched = false; - for (final Map s - in oneOfs.cast>()) { - try { - _validateInstance(instance, s, path); - oneMatched = true; - break; - } catch (_) {} - } - if (!oneMatched) { - throw A2uiValidationException( - 'Value did not match any oneOf schema', - surfaceId: surfaceId, - path: path, - ); - } - } + schema_validation.validateComponents( + surfaceId: surfaceId, + components: components.values.map( + (c) => (id: c.id, type: c.type, json: c.toJson()), + ), + schema: schema, + ); } } @@ -336,6 +198,15 @@ final class Component { return Component(id: id, type: rawType, properties: properties); } + /// Creates a snapshot from a live core component model. + factory Component.fromCore(core.ComponentModel component) { + return Component( + id: component.id, + type: component.type, + properties: JsonMap.from(component.properties), + ); + } + /// The unique ID of the component. final String id; @@ -350,6 +221,9 @@ final class Component { return {'id': id, 'component': type, ...properties}; } + /// Converts this snapshot to the core component wire JSON shape. + Map toCoreJson() => Map.from(toJson()); + @override bool operator ==(Object other) => other is Component && @@ -362,9 +236,9 @@ final class Component { Object.hash(id, type, const DeepCollectionEquality().hash(properties)); } -/// Exception thrown when validation fails. +/// Exception thrown when an A2UI message fails parsing or validation. class A2uiValidationException implements Exception { - /// Creates a [A2uiValidationException]. + /// Creates an [A2uiValidationException]. A2uiValidationException( this.message, { this.surfaceId, @@ -373,19 +247,10 @@ class A2uiValidationException implements Exception { this.cause, }); - /// The error message. final String message; - - /// The ID of the surface where the validation error occurred. final String? surfaceId; - - /// The path in the data/component model where the error occurred. final String? path; - - /// The JSON that caused the error. final Object? json; - - /// The underlying cause of the error. final Object? cause; @override @@ -399,39 +264,37 @@ class A2uiValidationException implements Exception { } } -/// A sealed class representing an update to the UI managed by the system. -/// -/// Subclasses: [SurfaceAdded], [ComponentsUpdated], and [SurfaceRemoved]. +/// Surface lifecycle events emitted by `SurfaceController.surfaceUpdates`. sealed class SurfaceUpdate { - /// Creates a [SurfaceUpdate] for the given [surfaceId]. const SurfaceUpdate(this.surfaceId); - - /// The ID of the surface that was updated. final String surfaceId; } /// Fired when a new surface is created. final class SurfaceAdded extends SurfaceUpdate { - /// Creates a [SurfaceAdded] event for the given [surfaceId] and - /// [definition]. - const SurfaceAdded(super.surfaceId, this.definition); + SurfaceAdded(super.surfaceId, this.surface); - /// The definition of the new surface. - final SurfaceDefinition definition; + /// The live core surface model. + final core.SurfaceModel surface; + + /// Snapshot facade for source-compatible public API. Materialized lazily + /// on first read; lifecycle-only listeners don't pay the snapshot cost. + late final SurfaceDefinition definition = SurfaceDefinition.fromCore(surface); } -/// Fired when an existing surface is modified. +/// Fired when an existing surface's component set is modified. final class ComponentsUpdated extends SurfaceUpdate { - /// Creates a [ComponentsUpdated] event for the given [surfaceId] and - /// [definition]. - const ComponentsUpdated(super.surfaceId, this.definition); + ComponentsUpdated(super.surfaceId, this.surface); + + /// The live core surface model. + final core.SurfaceModel surface; - /// The new definition of the surface. - final SurfaceDefinition definition; + /// Snapshot facade for source-compatible public API. Materialized lazily + /// on first read; lifecycle-only listeners don't pay the snapshot cost. + late final SurfaceDefinition definition = SurfaceDefinition.fromCore(surface); } /// Fired when a surface is deleted. final class SurfaceRemoved extends SurfaceUpdate { - /// Creates a [SurfaceRemoved] event for the given [surfaceId]. const SurfaceRemoved(super.surfaceId); } diff --git a/packages/genui/lib/src/widgets/surface.dart b/packages/genui/lib/src/widgets/surface.dart index c1faff9d8..f7206c785 100644 --- a/packages/genui/lib/src/widgets/surface.dart +++ b/packages/genui/lib/src/widgets/surface.dart @@ -2,6 +2,7 @@ // 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/material.dart'; @@ -10,7 +11,6 @@ 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'; @@ -20,8 +20,10 @@ typedef UiEventCallback = void Function(UiEvent event); /// A widget that renders a dynamic UI surface generated by the AI. /// -/// This widget connects to a [SurfaceContext] and renders the UI defined by the -/// [SurfaceDefinition] for the bound surface. +/// Publicly this still consumes the legacy [SurfaceContext.definition] API. +/// When the context is GenUI's own internal [LiveSurfaceContext], the renderer +/// additionally uses the live core surface model for per-component granular +/// rebuilds. class Surface extends StatefulWidget { /// Creates a [Surface]. const Surface({ @@ -45,60 +47,247 @@ class Surface extends StatefulWidget { } class _SurfaceState extends State { + core.SurfaceModel? _trackedSurface; + void Function(core.ComponentModel)? _onCreated; + void Function(String)? _onDeleted; + + LiveSurfaceContext? get _liveContext => + widget.surfaceContext is LiveSurfaceContext + ? widget.surfaceContext as LiveSurfaceContext + : null; + + @override + void initState() { + super.initState(); + final LiveSurfaceContext? liveContext = _liveContext; + if (liveContext != null) { + liveContext.surface.addListener(_onSurfaceChanged); + _attachComponentsListeners(liveContext.surface.value); + } else { + widget.surfaceContext.definition.addListener(_onDefinitionChanged); + } + } + + @override + void didUpdateWidget(Surface oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.surfaceContext == widget.surfaceContext) return; + + if (oldWidget.surfaceContext is LiveSurfaceContext) { + (oldWidget.surfaceContext as LiveSurfaceContext).surface.removeListener( + _onSurfaceChanged, + ); + _detachComponentsListeners(); + } else { + oldWidget.surfaceContext.definition.removeListener(_onDefinitionChanged); + } + + final LiveSurfaceContext? liveContext = _liveContext; + if (liveContext != null) { + liveContext.surface.addListener(_onSurfaceChanged); + _attachComponentsListeners(liveContext.surface.value); + } else { + widget.surfaceContext.definition.addListener(_onDefinitionChanged); + } + } + + @override + void dispose() { + final LiveSurfaceContext? liveContext = _liveContext; + if (liveContext != null) { + liveContext.surface.removeListener(_onSurfaceChanged); + _detachComponentsListeners(); + } else { + widget.surfaceContext.definition.removeListener(_onDefinitionChanged); + } + super.dispose(); + } + + void _onDefinitionChanged() { + if (mounted) setState(() {}); + } + + void _onSurfaceChanged() { + final core.SurfaceModel? next = _liveContext?.surface.value; + if (!identical(next, _trackedSurface)) { + _detachComponentsListeners(); + _attachComponentsListeners(next); + } + if (mounted) setState(() {}); + } + + void _attachComponentsListeners(core.SurfaceModel? surface) { + _trackedSurface = surface; + if (surface == null) return; + _onCreated = (component) { + if (mounted) setState(() {}); + }; + _onDeleted = (id) { + if (mounted) setState(() {}); + }; + surface.componentsModel.onCreated.addListener(_onCreated!); + surface.componentsModel.onDeleted.addListener(_onDeleted!); + } + + void _detachComponentsListeners() { + final core.SurfaceModel? surface = _trackedSurface; + if (surface != null && _onCreated != null && _onDeleted != null) { + surface.componentsModel.onCreated.removeListener(_onCreated!); + surface.componentsModel.onDeleted.removeListener(_onDeleted!); + } + _onCreated = null; + _onDeleted = null; + _trackedSurface = null; + } + @override Widget build(BuildContext context) { - genUiLogger.fine( - 'Outer Building surface ${widget.surfaceContext.surfaceId}', + final LiveSurfaceContext? liveContext = _liveContext; + if (liveContext != null) { + return _buildLiveSurface(liveContext); + } + return _buildDefinitionSurface(widget.surfaceContext.definition.value); + } + + Widget _buildLiveSurface(LiveSurfaceContext surfaceContext) { + final core.SurfaceModel? surface = surfaceContext.surface.value; + genUiLogger.fine('Building surface ${widget.surfaceContext.surfaceId}'); + + if (surface == null) { + genUiLogger.info( + 'Surface ${widget.surfaceContext.surfaceId} has no model yet.', + ); + return widget.defaultBuilder?.call(context) ?? const SizedBox.shrink(); + } + + const rootId = 'root'; + if (surface.componentsModel.get(rootId) == null) { + genUiLogger.warning( + 'Surface ${widget.surfaceContext.surfaceId} has no root component.', + ); + return const SizedBox.shrink(); + } + + final Catalog? catalog = widget.surfaceContext.catalog; + if (catalog == null) { + final error = Exception( + 'Catalog with id "${surface.catalog.id}" not found.', + ); + genUiLogger.severe(error.toString()); + widget.surfaceContext.reportError(error, StackTrace.current); + return FallbackWidget(error: error); + } + + return _buildLiveWidget( + surface, + catalog, + rootId, + DataContext( + widget.surfaceContext.dataModel, + DataPath.root, + functions: catalog.functions, + ), + ); + } + + Widget _buildDefinitionSurface(SurfaceDefinition? definition) { + genUiLogger.fine('Building surface ${widget.surfaceContext.surfaceId}'); + if (definition == null) { + genUiLogger.info( + 'Surface ${widget.surfaceContext.surfaceId} has no definition.', + ); + return widget.defaultBuilder?.call(context) ?? const SizedBox.shrink(); + } + + const rootId = 'root'; + if (definition.components.isEmpty || + !definition.components.containsKey(rootId)) { + genUiLogger.warning( + 'Surface ${widget.surfaceContext.surfaceId} has no root component.', + ); + return const SizedBox.shrink(); + } + + final Catalog? catalog = _findCatalogForDefinition(definition); + if (catalog == null) { + final error = Exception( + 'Catalog with id "${definition.catalogId}" not found.', + ); + widget.surfaceContext.reportError(error, StackTrace.current); + return FallbackWidget(error: error); + } + + return _buildWidgetFromDefinition( + definition, + catalog, + rootId, + DataContext( + widget.surfaceContext.dataModel, + DataPath.root, + functions: catalog.functions, + ), ); - return ValueListenableBuilder( - valueListenable: widget.surfaceContext.definition, - builder: (context, definition, child) { - genUiLogger.fine('Building surface ${widget.surfaceContext.surfaceId}'); - if (definition == null) { - genUiLogger.info( - 'Surface ${widget.surfaceContext.surfaceId} has no definition.', + } + + /// Recursively builds a widget from the live core model. + Widget _buildLiveWidget( + core.SurfaceModel surface, + Catalog catalog, + String widgetId, + DataContext dataContext, + ) { + final core.ComponentModel? data = surface.componentsModel.get(widgetId); + if (data == null) { + final error = Exception('Widget with id: $widgetId not found.'); + genUiLogger.severe(error.toString()); + widget.surfaceContext.reportError(error, StackTrace.current); + return FallbackWidget(error: error); + } + + return _ComponentBuilder( + key: ValueKey(widgetId), + component: data, + builder: (BuildContext ctx) { + try { + genUiLogger.finest('Building widget $widgetId'); + final coreCtx = core.ComponentContext( + surface, + data, + basePath: dataContext.path.toString(), ); - return widget.defaultBuilder?.call(context) ?? - const SizedBox.shrink(); - } - // Implicit root is "root". - const rootId = 'root'; - if (definition.components.isEmpty || - !definition.components.containsKey(rootId)) { - genUiLogger.warning( - 'Surface ${widget.surfaceContext.surfaceId} has no root component.', + return catalog.buildWidget( + CatalogItemContext.fromCore( + componentContext: coreCtx, + buildChild: (String childId, [DataContext? childDataContext]) => + _buildLiveWidget( + surface, + catalog, + childId, + childDataContext ?? dataContext, + ), + dispatchEvent: _dispatchEvent, + buildContext: ctx, + dataContext: dataContext, + getCatalogItem: (String type) => + catalog.items.firstWhereOrNull((item) => item.name == type), + reportError: widget.surfaceContext.reportError, + ), ); - return const SizedBox.shrink(); - } - - final Catalog? catalog = _findCatalogForDefinition(definition); - if (catalog == null) { - final error = Exception( - 'Catalog with id "${definition.catalogId}" not found.', + } catch (exception, stackTrace) { + genUiLogger.severe( + 'Error building widget $widgetId', + exception, + stackTrace, ); - widget.surfaceContext.reportError(error, StackTrace.current); - return FallbackWidget(error: error); + widget.surfaceContext.reportError(exception, stackTrace); + return FallbackWidget(error: exception, stackTrace: stackTrace); } - - return _buildWidget( - definition, - catalog, - rootId, - DataContext( - widget.surfaceContext.dataModel, - DataPath.root, - functions: catalog.functions, - ), - ); }, ); } - /// The main recursive build function. - /// It reads a widget definition and its current state from - /// `widget.definition` - /// and constructs the corresponding Flutter widget. - Widget _buildWidget( + /// Recursively builds a widget from the legacy snapshot model. + Widget _buildWidgetFromDefinition( SurfaceDefinition definition, Catalog catalog, String widgetId, @@ -121,7 +310,7 @@ class _SurfaceState extends State { data: widgetData, type: data.type, buildChild: (String childId, [DataContext? childDataContext]) => - _buildWidget( + _buildWidgetFromDefinition( definition, catalog, childId, @@ -154,12 +343,11 @@ class _SurfaceState extends State { context, event, widget.surfaceContext, - _buildWidget, + _buildWidgetFromDefinition, )) { return; } - // The event comes in without a surfaceId, which we add here. final Map eventMap = { ...event.toMap(), surfaceIdKey: widget.surfaceContext.surfaceId, @@ -171,15 +359,12 @@ class _SurfaceState extends State { } Catalog? _findCatalogForDefinition(SurfaceDefinition definition) { - // The surfaceContext is responsible for resolving the catalog based on - // the current definition in the registry. final Catalog? catalog = widget.surfaceContext.catalog; - if (catalog == null) { genUiLogger.severe( 'Catalog with id "${definition.catalogId}" not found for surface ' - '"${widget.surfaceContext.surfaceId}". Ensure the catalog is provided ' - 'to A2uiMessageProcessor.', + '"${widget.surfaceContext.surfaceId}". Ensure the catalog is ' + 'provided to SurfaceController.', ); } return catalog; @@ -187,18 +372,10 @@ class _SurfaceState extends State { } /// A delegate for handling UI actions in [Surface]. -/// -/// Implement this interface to provide custom handling for specific actions, -/// such as showing modals or navigating. abstract interface class ActionDelegate { /// Handles a [UiEvent]. /// /// Returns `true` if the event was handled, `false` otherwise. - /// - /// The [context] is the build context of the [Surface]. - /// The [genUiContext] provides access to the surface state. - /// The [buildWidget] function allows building widgets from the definition, - /// useful for rendering content inside modals or dialogs. bool handleEvent( BuildContext context, UiEvent event, @@ -208,7 +385,7 @@ abstract interface class ActionDelegate { ); } -/// The default action delegate that handles standard actions like 'showModal'. +/// The default action delegate that handles no actions itself. class DefaultActionDelegate implements ActionDelegate { /// Creates a [DefaultActionDelegate]. const DefaultActionDelegate(); @@ -224,3 +401,49 @@ class DefaultActionDelegate implements ActionDelegate { return false; } } + +/// Subscribes to a single [core.ComponentModel.onUpdated] and rebuilds its +/// child subtree when that component's properties change. +class _ComponentBuilder extends StatefulWidget { + const _ComponentBuilder({ + super.key, + required this.component, + required this.builder, + }); + + final core.ComponentModel component; + final WidgetBuilder builder; + + @override + State<_ComponentBuilder> createState() => _ComponentBuilderState(); +} + +class _ComponentBuilderState extends State<_ComponentBuilder> { + @override + void initState() { + super.initState(); + widget.component.onUpdated.addListener(_onComponentUpdated); + } + + @override + void didUpdateWidget(_ComponentBuilder oldWidget) { + super.didUpdateWidget(oldWidget); + if (!identical(oldWidget.component, widget.component)) { + oldWidget.component.onUpdated.removeListener(_onComponentUpdated); + widget.component.onUpdated.addListener(_onComponentUpdated); + } + } + + @override + void dispose() { + widget.component.onUpdated.removeListener(_onComponentUpdated); + super.dispose(); + } + + void _onComponentUpdated(core.ComponentModel _) { + if (mounted) setState(() {}); + } + + @override + Widget build(BuildContext context) => widget.builder(context); +} From 62bd41858218c3ab1a32e1c87d9bc81a0de45c4d Mon Sep 17 00:00:00 2001 From: Andrew Kolos Date: Tue, 26 May 2026 14:16:44 -0700 Subject: [PATCH 04/49] refactor(genui): delegate SurfaceController to a2ui_core.MessageProcessor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SurfaceController becomes a thin Flutter-side wrapper around core.MessageProcessor. The substrate owns canonical state mutation (create/update/delete surfaces and their components/data models); this class adds the Flutter-specific concerns: pre-create message buffering, schema validation, a SurfaceUpdate stream the facade subscribes to, and the GenUI-named A2uiMessage facade. Substantive pieces: 1. engine/surface_controller.dart: rewrite. - Public `handleMessage(A2uiMessage)` accepts the genui-facade message type. It converts to core via `message.toCoreMessage()` and delegates to a private `_handleCoreMessage(core.A2uiMessage)`. The buffered/flushed path uses the private method directly so no double-conversion happens. - Holds a core.MessageProcessor built from each catalog's `coreCatalog` adapter; subscribes to its groupModel.onSurfaceCreated/onSurfaceDeleted and forwards them to the SurfaceRegistry. - Pre-create buffering: UpdateComponents/UpdateDataModel that arrive before their createSurface are held in a per-surfaceId queue with `pendingUpdateTimeout` cleanup, then flushed when the surface is created. - Lenient unknown-catalogId behavior is preserved (registers an empty stub core.Catalog so the substrate's "not found" check passes; the genui Surface widget surfaces FallbackWidget at render time as before). - Schema validation runs after each UpdateComponents via the new shared schema_validation helper (item 5 below). Mutation is not rolled back on failure — the error is reported and the caller decides. 2. model/a2ui_message.dart: GenUI message facade classes. `A2uiMessage`, `CreateSurface`, `UpdateComponents`, `UpdateDataModel`, `DeleteSurface` each wrap the corresponding core message. `.fromJson(JsonMap)` parses via core then converts; `.fromCore(core.A2uiMessage)` adapts an existing core message; `.toCoreMessage()` converts back. `UpdateDataModel` preserves the value-omitted vs value-null wire distinction via a `hasValue` flag + `.removeKey` named constructor matching a2ui_core's new shape. 3. model/catalog.dart: `coreCatalog` extension exposes a core.Catalog view of a genui Catalog so the substrate has real component metadata for the processor. Functions and theme are not adapted yet — Node Layer (#1282) will redo this boundary; until then catalog widgets resolve functions through the genui DataContext, not the core one. 4. model/catalog_item.dart: CatalogItemContext keeps both the legacy public unnamed constructor (which builds a stand-alone substrate context internally) AND an @internal fromCore constructor used by the live render path. `withOverrides({...})` is the rebind-callbacks-while-preserving-substrate-context pattern Catalog.buildWidget uses. 5. model/schema_validation.dart (new): shared validator extracted from both SurfaceDefinition.validate and the controller's post-mutation validation. Takes an iterable of `({id, type, json})` records plus the catalog Schema; both call sites adapt their representation into the iterable. 6. transport/a2ui_parser_transformer.dart: routes through core.A2uiMessage.fromJson then wraps the result in the facade A2uiMessage. Envelope detection (the `_looksLikeA2uiEnvelope` helper) treats `version` as an envelope marker, so a payload like {"version":"v0.9","unknownAction":{}} is surfaced as a validation error rather than falling through to TextEvent. 7. New regression tests: - data_model_edge_cases_test.dart: covers the mutable-copy-of-const-Map case the substrate fix in commit 1 introduced. - surface_controller_test.dart: store-facade test, duplicate createSurface, pre-create dataModel access. - a2ui_message_test.dart: facade round-trip cases. - core_widgets_test.dart: minor accommodation for the new contextFor(...)/dataModel path. --- .../lib/src/engine/surface_controller.dart | 389 +++++++++++------- .../genui/lib/src/model/a2ui_message.dart | 362 +++++++++------- packages/genui/lib/src/model/catalog.dart | 61 ++- .../genui/lib/src/model/catalog_item.dart | 182 ++++++-- .../lib/src/model/schema_validation.dart | 180 ++++++++ .../transport/a2ui_parser_transformer.dart | 62 ++- .../src/transport/a2ui_transport_adapter.dart | 17 +- packages/genui/lib/test/validation.dart | 17 +- .../genui/test/catalog/core_widgets_test.dart | 15 +- .../test/engine/surface_controller_test.dart | 39 +- .../genui/test/model/a2ui_message_test.dart | 43 ++ .../model/data_model_edge_cases_test.dart | 14 +- 12 files changed, 977 insertions(+), 404 deletions(-) create mode 100644 packages/genui/lib/src/model/schema_validation.dart diff --git a/packages/genui/lib/src/engine/surface_controller.dart b/packages/genui/lib/src/engine/surface_controller.dart index 69731d290..0231a5473 100644 --- a/packages/genui/lib/src/engine/surface_controller.dart +++ b/packages/genui/lib/src/engine/surface_controller.dart @@ -5,6 +5,7 @@ import 'dart:async'; import 'dart:convert'; +import 'package:a2ui_core/a2ui_core.dart' as core; import 'package:collection/collection.dart'; import 'package:flutter/foundation.dart'; @@ -16,8 +17,8 @@ import '../model/a2ui_message.dart'; import '../model/catalog.dart'; import '../model/chat_message.dart'; import '../model/data_model.dart'; +import '../model/schema_validation.dart' as schema_validation; import '../model/ui_models.dart'; -import '../primitives/constants.dart'; import '../primitives/logging.dart'; import 'data_model_store.dart'; @@ -25,50 +26,59 @@ import 'surface_registry.dart' as surface_reg; /// The runtime controller for the GenUI system. /// -/// Orchestrates the lifecycle of UI surfaces, manages communication with the -/// AI service, and handles data model updates. +/// Thin Flutter-side wrapper around [core.MessageProcessor]: the substrate +/// owns the canonical A2UI state-mutation rules (create/update/delete +/// surfaces and their components/data models) and this class adds the +/// Flutter-specific concerns on top: pre-create message buffering, schema +/// validation against the genui catalog, and a [SurfaceUpdate] stream the +/// Flutter facade subscribes to. interface class SurfaceController implements SurfaceHost, A2uiMessageSink { - /// Creates a [SurfaceController]. - /// - /// The [catalogs] parameter defines the set of component catalogs available - /// for use by surfaces managed by this controller. - /// - /// The [pendingUpdateTimeout] specifies how long to wait for a surface - /// creation message before discarding buffered updates for that surface. SurfaceController({ required this.catalogs, this.pendingUpdateTimeout = const Duration(minutes: 1), - }); + }) { + _processor = core.MessageProcessor( + // Growable: handleMessage may inject stub catalogs for unknown + // catalogIds (see comment near _processor.catalogs.add below). + catalogs: catalogs.map((c) => c.coreCatalog).toList(), + ); + _processor.groupModel.onSurfaceCreated.addListener(_onCoreSurfaceCreated); + _processor.groupModel.onSurfaceDeleted.addListener(_onCoreSurfaceDeleted); + } /// The catalogs available to surfaces in this engine. final Iterable catalogs; - /// The timeout for pending updates waiting for a surface creation. + /// The timeout for buffered updates waiting for a surface creation. final Duration pendingUpdateTimeout; + late final core.MessageProcessor _processor; late final surface_reg.SurfaceRegistry _registry = surface_reg.SurfaceRegistry(); - late final DataModelStore _store = DataModelStore(); + late final DataModelStore _store = DataModelStore( + lookup: (String surfaceId) { + final core.SurfaceModel? surface = _registry.getSurface(surfaceId); + if (surface == null) return null; + return InMemoryDataModel.wrap(surface.dataModel); + }, + ); final _onSubmit = StreamController.broadcast(); - final _pendingUpdates = >{}; + final _pendingUpdates = >{}; final _pendingUpdateTimers = {}; - // Expose registry events as surface updates @override Stream get surfaceUpdates => _registry.events.map( (e) => switch (e) { - surface_reg.SurfaceAdded(:final surfaceId, :final definition) => - SurfaceAdded(surfaceId, definition), - surface_reg.SurfaceUpdated(:final surfaceId, :final definition) => - ComponentsUpdated(surfaceId, definition), + surface_reg.SurfaceAdded(:final surfaceId, :final surface) => + SurfaceAdded(surfaceId, surface), + surface_reg.SurfaceUpdated(:final surfaceId, :final surface) => + ComponentsUpdated(surfaceId, surface), surface_reg.SurfaceRemoved(:final surfaceId) => SurfaceRemoved(surfaceId), }, ); /// A stream of messages to be submitted to the AI service. - /// - /// This includes user actions and validation errors. Stream get onSubmit => _onSubmit.stream; /// The IDs of the currently active surfaces. @@ -87,37 +97,178 @@ interface class SurfaceController implements SurfaceHost, A2uiMessageSink { /// The registry of surfaces managed by this controller. surface_reg.SurfaceRegistry get registry => _registry; - /// The store of data models managed by this controller. + /// The source-compatible store of data models managed by this controller. DataModelStore get store => _store; - /// Process an [message] from the AI service. - /// - /// Decodes the message and updates the state of the relevant surface, - /// provided the message passes validation. + /// Processes a message from the AI service. /// - /// If validation fails, a [A2uiValidationException] is caught and logged, - /// and an error message is sent back via [onSubmit]. + /// Delegates the canonical state mutation to [core.MessageProcessor] and + /// adds Flutter-specific concerns around it (pre-create buffering of + /// updates, schema validation of the resulting component set, and + /// surface-level `ComponentsUpdated` emission for the [surfaceUpdates] + /// stream). @override void handleMessage(A2uiMessage message) { genUiLogger.info( 'SurfaceController.handleMessage received: ${message.runtimeType}', ); + _handleCoreMessage(message.toCoreMessage()); + } + + /// Internal entry point used by buffered/flushed messages where we already + /// hold the substrate representation. Public callers go through + /// [handleMessage] with the GenUI facade type. + void _handleCoreMessage(core.A2uiMessage coreMessage) { + // Empty surfaceId — reject before delegating so the substrate doesn't + // create a surface with id "". + if (coreMessage is core.CreateSurfaceMessage && + coreMessage.surfaceId.isEmpty) { + reportError( + A2uiValidationException( + 'Surface ID cannot be empty', + surfaceId: '', + path: 'surfaceId', + ), + StackTrace.current, + ); + return; + } + + // Buffer updates that arrive before their surface is created. + final String? bufferSurfaceId = _bufferSurfaceIdIfNoSurface(coreMessage); + if (bufferSurfaceId != null) { + _bufferMessage(bufferSurfaceId, coreMessage); + return; + } + + // If a createSurface refers to a catalogId we do not have, register an + // empty stub so the substrate's "catalog not found" check passes. This + // mirrors the previous lenient behavior where unknown catalogIds were + // accepted with an empty component set — useful for tests/demos that + // do not pre-register every catalog the server may send. + if (coreMessage is core.CreateSurfaceMessage) { + final core.CreateSurfaceMessage createMessage = coreMessage; + if (!_processor.catalogs.any((c) => c.id == createMessage.catalogId)) { + _processor.catalogs.add( + core.Catalog( + id: createMessage.catalogId, + components: const [], + ), + ); + } + } try { - _handleMessageInternal(message); + _processor.processMessages([coreMessage]); + } on core.A2uiStateError catch (e) { + genUiLogger.warning('State error from MessageProcessor: ${e.message}'); + reportError( + A2uiValidationException( + e.message, + surfaceId: _surfaceIdOf(coreMessage), + ), + StackTrace.current, + ); + return; + } on core.A2uiValidationError catch (e) { + genUiLogger.warning( + 'Validation error from MessageProcessor: ${e.message}', + ); + reportError( + A2uiValidationException( + e.message, + surfaceId: _surfaceIdOf(coreMessage), + ), + StackTrace.current, + ); + return; } on A2uiValidationException catch (e) { genUiLogger.warning('Validation failed for surface ${e.surfaceId}: $e'); reportError(e, StackTrace.current); + return; } catch (exception, stackTrace) { genUiLogger.severe( - 'Error handling message: $message', + 'Error handling message: $coreMessage', exception, stackTrace, ); reportError(exception, stackTrace); + return; + } + + // Genui-side post-mutation handling. The substrate has already applied + // the mutation by this point — these are additive Flutter/genui + // concerns the substrate does not own. + if (coreMessage is core.UpdateComponentsMessage) { + final core.SurfaceModel? surface = _processor + .groupModel + .getSurface(coreMessage.surfaceId); + if (surface != null) { + // Emit a surface-level "components updated" so subscribers to + // `surfaceUpdates` can react without listening to every + // ComponentModel individually. + _registry.notifyUpdated(surface); + // Validate the resulting component set against the genui catalog + // schema. Mutation is not rolled back on validation failure — we + // surface the error and let the caller decide. + try { + final Catalog? genuiCatalog = catalogs.firstWhereOrNull( + (c) => c.catalogId == surface.catalog.id, + ); + if (genuiCatalog != null) { + _validateComponents(coreMessage.surfaceId, surface, genuiCatalog); + } + } on A2uiValidationException catch (e) { + genUiLogger.warning( + 'Schema validation failed for surface ${e.surfaceId}: $e', + ); + reportError(e, StackTrace.current); + } + } + } + } + + /// Returns the surfaceId of [message] if it is an update for a surface + /// that does not yet exist (and therefore needs to be buffered), or null + /// if no buffering is needed. + String? _bufferSurfaceIdIfNoSurface(core.A2uiMessage message) { + final String? targetId = switch (message) { + core.UpdateComponentsMessage(:final surfaceId) => surfaceId, + core.UpdateDataModelMessage(:final surfaceId) => surfaceId, + _ => null, + }; + if (targetId == null) return null; + if (_processor.groupModel.getSurface(targetId) != null) return null; + return targetId; + } + + String? _surfaceIdOf(core.A2uiMessage message) => switch (message) { + core.CreateSurfaceMessage(:final surfaceId) => surfaceId, + core.UpdateComponentsMessage(:final surfaceId) => surfaceId, + core.UpdateDataModelMessage(:final surfaceId) => surfaceId, + core.DeleteSurfaceMessage(:final surfaceId) => surfaceId, + _ => null, + }; + + void _onCoreSurfaceCreated(core.SurfaceModel surface) { + _registry.addSurface(surface); + // Flush any updates that arrived for this surfaceId before createSurface. + final List? pending = _pendingUpdates.remove(surface.id); + _pendingUpdateTimers.remove(surface.id)?.cancel(); + if (pending != null) { + for (final core.A2uiMessage msg in pending) { + _handleCoreMessage(msg); + } } } + void _onCoreSurfaceDeleted(String surfaceId) { + _pendingUpdates.remove(surfaceId); + _pendingUpdateTimers.remove(surfaceId)?.cancel(); + _store.removeDataModel(surfaceId); + _registry.removeSurface(surfaceId); + } + /// Reports an error to the AI service. void reportError(Object error, StackTrace? stack) { var errorCode = 'RUNTIME_ERROR'; @@ -149,103 +300,7 @@ interface class SurfaceController implements SurfaceHost, A2uiMessageSink { ); } - void _handleMessageInternal(A2uiMessage message) { - switch (message) { - case CreateSurface( - :final surfaceId, - :final catalogId, - :final theme, - :final sendDataModel, - ): - if (surfaceId.isEmpty) { - throw A2uiValidationException( - 'Surface ID cannot be empty', - surfaceId: surfaceId, - path: 'surfaceId', - ); - } - - final List? pending = _pendingUpdates.remove(surfaceId); - _pendingUpdateTimers.remove(surfaceId)?.cancel(); - - _store.getDataModel(surfaceId); // Ensure model exists - - final SurfaceDefinition? existing = _registry.getSurface(surfaceId); - final SurfaceDefinition newDefinition = - (existing ?? SurfaceDefinition(surfaceId: surfaceId)).copyWith( - catalogId: catalogId, - theme: theme, - ); - - if (sendDataModel) { - _store.attachSurface(surfaceId); - } else { - _store.detachSurface(surfaceId); - } - - _registry.updateSurface( - surfaceId, - newDefinition, - isNew: existing == null, - ); - - final Catalog? catalog = _findCatalogForDefinition(newDefinition); - if (catalog != null) { - newDefinition.validate(catalog.definition); - } - - if (pending != null) { - for (final A2uiMessage msg in pending) { - _handleMessageInternal(msg); - } - } - - case UpdateComponents(:final surfaceId, :final components): - if (!_registry.hasSurface(surfaceId)) { - _bufferMessage(surfaceId, message); - return; - } - - final SurfaceDefinition current = _registry.getSurface(surfaceId)!; - final Map newComponents = Map.of(current.components); - for (final component in components) { - newComponents[component.id] = component; - } - - _registry.updateSurface( - surfaceId, - current.copyWith(components: newComponents), - ); - - final SurfaceDefinition updatedDefinition = _registry.getSurface( - surfaceId, - )!; - final Catalog? catalog = _findCatalogForDefinition(updatedDefinition); - if (catalog != null) { - updatedDefinition.validate(catalog.definition); - } - - case UpdateDataModel(:final surfaceId, :final path, :final value): - if (!_registry.hasSurface(surfaceId)) { - _bufferMessage(surfaceId, message); - return; - } - - final DataModel model = _store.getDataModel(surfaceId); - model.update(path, value); - - // Note: We don't trigger a surface update here to avoid full UI refreshes - // on data changes. Components should listen to the DataModel directly. - - case DeleteSurface(:final surfaceId): - _pendingUpdates.remove(surfaceId); - _pendingUpdateTimers.remove(surfaceId)?.cancel(); - _registry.removeSurface(surfaceId); - _store.removeDataModel(surfaceId); - } - } - - void _bufferMessage(String surfaceId, A2uiMessage message) { + void _bufferMessage(String surfaceId, core.A2uiMessage message) { _pendingUpdates.putIfAbsent(surfaceId, () => []).add(message); if (!_pendingUpdateTimers.containsKey(surfaceId)) { _pendingUpdateTimers[surfaceId] = Timer(pendingUpdateTimeout, () { @@ -255,10 +310,8 @@ interface class SurfaceController implements SurfaceHost, A2uiMessageSink { } } - /// Handles a UI event from a surface. - /// - /// Converts the event into a [ChatMessage] and adds it to the [onSubmit] - /// stream. + /// Handles a UI event from a surface — converts a [UserActionEvent] into + /// a [ChatMessage] sent on [onSubmit]. void handleUiEvent(UiEvent event) { if (event is! UserActionEvent) return; _onSubmit.add( @@ -273,22 +326,41 @@ interface class SurfaceController implements SurfaceHost, A2uiMessageSink { ); } - Catalog? _findCatalogForDefinition(SurfaceDefinition definition) { - genUiLogger.fine( - 'Finding catalog for ${definition.catalogId} in ' - '${catalogs.map((c) => c.catalogId).toList()}', + Catalog? _findCatalogForSurface(String surfaceId) { + final core.SurfaceModel? surface = _registry.getSurface( + surfaceId, ); - return catalogs.firstWhereOrNull( - (catalog) => catalog.catalogId == definition.catalogId, + if (surface == null) return null; + return catalogs.firstWhereOrNull((c) => c.catalogId == surface.catalog.id); + } + + /// Validates the components currently in [surface] against [catalog]'s + /// schema. Throws [A2uiValidationException] on the first failing component. + void _validateComponents( + String surfaceId, + core.SurfaceModel surface, + Catalog catalog, + ) { + schema_validation.validateComponents( + surfaceId: surfaceId, + components: surface.componentsModel.all.map( + (c) => (id: c.id, type: c.type, json: c.toJson()), + ), + schema: catalog.definition, ); } /// Disposes of the controller and releases all resources. - /// - /// Closes the [onSubmit] stream and cancels any pending timers. void dispose() { - _registry.dispose(); + _processor.groupModel.onSurfaceCreated.removeListener( + _onCoreSurfaceCreated, + ); + _processor.groupModel.onSurfaceDeleted.removeListener( + _onCoreSurfaceDeleted, + ); + _processor.groupModel.dispose(); _store.dispose(); + _registry.dispose(); _onSubmit.close(); for (final Timer timer in _pendingUpdateTimers.values) { timer.cancel(); @@ -296,7 +368,7 @@ interface class SurfaceController implements SurfaceHost, A2uiMessageSink { } } -class _ControllerContext implements SurfaceContext { +class _ControllerContext implements LiveSurfaceContext { _ControllerContext(this._controller, this.surfaceId); final SurfaceController _controller; @@ -304,30 +376,33 @@ class _ControllerContext implements SurfaceContext { final String surfaceId; @override - ValueListenable get definition => + ValueListenable get surface => _controller.registry.watchSurface(surfaceId); @override - DataModel get dataModel => _controller.store.getDataModel(surfaceId); + ValueListenable get definition => + _controller.registry.watchDefinition(surfaceId); @override - Catalog? get catalog { - final ValueListenable definitions = _controller.registry - .watchSurface(surfaceId); - final SurfaceDefinition? definition = definitions.value; - final String catalogId = definition?.catalogId ?? basicCatalogId; - return _controller.catalogs.firstWhereOrNull( - (catalog) => catalog.catalogId == catalogId, - ); + DataModel get dataModel { + final core.SurfaceModel? s = _controller.registry.getSurface(surfaceId); + if (s == null) { + throw StateError( + 'SurfaceContext.dataModel accessed for surface "$surfaceId" ' + 'before the surface was created. Guard on `definition.value != null` ' + 'or wait for a SurfaceAdded event before reading the data model.', + ); + } + return InMemoryDataModel.wrap(s.dataModel); } @override - void handleUiEvent(UiEvent event) { - _controller.handleUiEvent(event); - } + Catalog? get catalog => _controller._findCatalogForSurface(surfaceId); @override - void reportError(Object error, StackTrace? stack) { - _controller.reportError(error, stack); - } + void handleUiEvent(UiEvent event) => _controller.handleUiEvent(event); + + @override + void reportError(Object error, StackTrace? stack) => + _controller.reportError(error, stack); } diff --git a/packages/genui/lib/src/model/a2ui_message.dart b/packages/genui/lib/src/model/a2ui_message.dart index 57ef8a169..224f12d3b 100644 --- a/packages/genui/lib/src/model/a2ui_message.dart +++ b/packages/genui/lib/src/model/a2ui_message.dart @@ -2,146 +2,129 @@ // 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:json_schema_builder/json_schema_builder.dart'; -import '../primitives/logging.dart'; import '../primitives/simple_items.dart'; import 'a2ui_schemas.dart'; import 'catalog.dart'; import 'data_model.dart'; import 'ui_models.dart'; -/// A sealed class representing a message in the A2UI stream. -sealed class A2uiMessage { - /// Creates an [A2uiMessage]. - const A2uiMessage(); +/// A source-compatible GenUI facade for A2UI protocol messages. +/// +/// The canonical parser and processor live in `a2ui_core`; these classes keep +/// the legacy GenUI names while converting to/from the core message types at +/// the renderer boundary. +abstract class A2uiMessage { + const A2uiMessage({this.version = 'v0.9'}); - /// Creates an [A2uiMessage] from a JSON map. + /// Creates an [A2uiMessage] from a JSON envelope. factory A2uiMessage.fromJson(JsonMap json) { try { - final Object? version = json['version']; - if (version != 'v0.9') { - throw A2uiValidationException( - 'A2UI message must have version "v0.9"', - json: json, - ); - } - if (json case {'createSurface': JsonMap data}) { - try { - return CreateSurface.fromJson(data); - } catch (e) { - throw A2uiValidationException( - 'Failed to parse CreateSurface message', - json: json, - cause: e, - ); - } - } - if (json case {'updateComponents': JsonMap data}) { - try { - return UpdateComponents.fromJson(data); - } catch (e) { - throw A2uiValidationException( - 'Failed to parse UpdateComponents message', - json: json, - cause: e, - ); - } - } - if (json case {'updateDataModel': JsonMap data}) { - try { - return UpdateDataModel.fromJson(data); - } catch (e) { - throw A2uiValidationException( - 'Failed to parse UpdateDataModel message', - json: json, - cause: e, - ); - } - } - if (json case {'deleteSurface': JsonMap data}) { - try { - return DeleteSurface.fromJson(data); - } catch (e) { - throw A2uiValidationException( - 'Failed to parse DeleteSurface message', - json: json, - cause: e, - ); - } + return A2uiMessage.fromCore( + core.A2uiMessage.fromJson(Map.from(json)), + ); + } on core.A2uiValidationError catch (e) { + String message = e.message; + if (message.contains("'version'")) { + message = 'A2UI message must have version "v0.9"'; } - } on A2uiValidationException { - rethrow; - } catch (exception, stackTrace) { - genUiLogger.severe( - 'Failed to parse A2UI message from JSON: $json', - exception, - stackTrace, + throw A2uiValidationException(message, json: json, cause: e); + } catch (e) { + throw A2uiValidationException( + 'Failed to parse A2UI message', + json: json, + cause: e, ); - rethrow; } - throw A2uiValidationException( - 'Unknown A2UI message type: ${json.keys}', - json: json, - ); } - /// Returns the JSON schema for an A2UI message. - static Schema a2uiMessageSchema(Catalog catalog) { - return S.combined( - title: 'A2UI Message Schema', - description: - 'Describes a JSON payload for an A2UI (Agent to UI) message, ' - 'which is used to dynamically construct and update user interfaces.', - oneOf: [ - S.object( - properties: { - 'version': S.string(constValue: 'v0.9'), - 'createSurface': A2uiSchemas.createSurfaceSchema(), - }, - required: ['version', 'createSurface'], - additionalProperties: false, - ), - S.object( - properties: { - 'version': S.string(constValue: 'v0.9'), - 'updateComponents': A2uiSchemas.updateComponentsSchema(catalog), - }, - required: ['version', 'updateComponents'], - additionalProperties: false, - ), - S.object( - properties: { - 'version': S.string(constValue: 'v0.9'), - 'updateDataModel': A2uiSchemas.updateDataModelSchema(), - }, - required: ['version', 'updateDataModel'], - additionalProperties: false, - ), - S.object( - properties: { - 'version': S.string(constValue: 'v0.9'), - 'deleteSurface': A2uiSchemas.deleteSurfaceSchema(), - }, - required: ['version', 'deleteSurface'], - additionalProperties: false, - ), - ], - ); + /// Creates a facade message from a core message. + factory A2uiMessage.fromCore(core.A2uiMessage message) { + return switch (message) { + core.CreateSurfaceMessage() => CreateSurface.fromCore(message), + core.UpdateComponentsMessage() => UpdateComponents.fromCore(message), + core.UpdateDataModelMessage() => UpdateDataModel.fromCore(message), + core.DeleteSurfaceMessage() => DeleteSurface.fromCore(message), + _ => throw A2uiValidationException( + 'Unknown A2UI message type: ${message.runtimeType}', + ), + }; } + + /// Returns the JSON schema for an A2UI message. + static Schema a2uiMessageSchema(Catalog catalog) => + _buildA2uiMessageSchema(catalog); + + /// The protocol version. + final String version; + + /// Converts this facade message to the core substrate message. + core.A2uiMessage toCoreMessage(); + + /// Converts this message to a JSON envelope. + Map toJson() => toCoreMessage().toJson(); +} + +/// Returns the JSON schema for an A2UI message, parameterized by [catalog]. +Schema a2uiMessageSchema(Catalog catalog) => _buildA2uiMessageSchema(catalog); + +Schema _buildA2uiMessageSchema(Catalog catalog) { + return S.combined( + title: 'A2UI Message Schema', + description: + 'Describes a JSON payload for an A2UI (Agent to UI) message, ' + 'which is used to dynamically construct and update user interfaces.', + oneOf: [ + S.object( + properties: { + 'version': S.string(constValue: 'v0.9'), + 'createSurface': A2uiSchemas.createSurfaceSchema(), + }, + required: ['version', 'createSurface'], + additionalProperties: false, + ), + S.object( + properties: { + 'version': S.string(constValue: 'v0.9'), + 'updateComponents': A2uiSchemas.updateComponentsSchema(catalog), + }, + required: ['version', 'updateComponents'], + additionalProperties: false, + ), + S.object( + properties: { + 'version': S.string(constValue: 'v0.9'), + 'updateDataModel': A2uiSchemas.updateDataModelSchema(), + }, + required: ['version', 'updateDataModel'], + additionalProperties: false, + ), + S.object( + properties: { + 'version': S.string(constValue: 'v0.9'), + 'deleteSurface': A2uiSchemas.deleteSurfaceSchema(), + }, + required: ['version', 'deleteSurface'], + additionalProperties: false, + ), + ], + ); } /// An A2UI message that signals the client to create and show a new surface. final class CreateSurface extends A2uiMessage { /// Creates a [CreateSurface] message. const CreateSurface({ + super.version, required this.surfaceId, required this.catalogId, this.theme, this.sendDataModel = false, }); - /// Creates a [CreateSurface] message from a JSON map. + /// Creates a [CreateSurface] message from a JSON map body. factory CreateSurface.fromJson(JsonMap json) { return CreateSurface( surfaceId: json[surfaceIdKey] as String, @@ -151,6 +134,17 @@ final class CreateSurface extends A2uiMessage { ); } + /// Creates a facade from a core message. + factory CreateSurface.fromCore(core.CreateSurfaceMessage message) { + return CreateSurface( + version: message.version, + surfaceId: message.surfaceId, + catalogId: message.catalogId, + theme: message.theme == null ? null : JsonMap.from(message.theme!), + sendDataModel: message.sendDataModel, + ); + } + /// The ID of the surface that this message applies to. final String surfaceId; @@ -163,22 +157,28 @@ final class CreateSurface extends A2uiMessage { /// If true, the client sends the full data model in A2A metadata. final bool sendDataModel; - /// Converts this message to a JSON map. - Map toJson() => { - 'version': 'v0.9', - surfaceIdKey: surfaceId, - 'catalogId': catalogId, - 'theme': ?theme, - 'sendDataModel': sendDataModel, - }; + @override + core.CreateSurfaceMessage toCoreMessage() { + return core.CreateSurfaceMessage( + version: version, + surfaceId: surfaceId, + catalogId: catalogId, + theme: theme == null ? null : Map.from(theme!), + sendDataModel: sendDataModel, + ); + } } /// An A2UI message that updates a surface with new components. final class UpdateComponents extends A2uiMessage { - /// Creates a [UpdateComponents] message. - const UpdateComponents({required this.surfaceId, required this.components}); + /// Creates an [UpdateComponents] message. + const UpdateComponents({ + super.version, + required this.surfaceId, + required this.components, + }); - /// Creates a [UpdateComponents] message from a JSON map. + /// Creates an [UpdateComponents] message from a JSON map body. factory UpdateComponents.fromJson(JsonMap json) { return UpdateComponents( surfaceId: json[surfaceIdKey] as String, @@ -188,35 +188,82 @@ final class UpdateComponents extends A2uiMessage { ); } + /// Creates a facade from a core message. + factory UpdateComponents.fromCore(core.UpdateComponentsMessage message) { + return UpdateComponents( + version: message.version, + surfaceId: message.surfaceId, + components: message.components + .map((json) => Component.fromJson(JsonMap.from(json))) + .toList(), + ); + } + /// The ID of the surface that this message applies to. final String surfaceId; /// The list of components to add or update. final List components; - /// Converts this message to a JSON map. - Map toJson() => { - 'version': 'v0.9', - surfaceIdKey: surfaceId, - 'components': components.map((c) => c.toJson()).toList(), - }; + @override + core.UpdateComponentsMessage toCoreMessage() { + return core.UpdateComponentsMessage( + version: version, + surfaceId: surfaceId, + components: components.map((c) => c.toCoreJson()).toList(), + ); + } } /// An A2UI message that updates the data model. final class UpdateDataModel extends A2uiMessage { - /// Creates a [UpdateDataModel] message. + /// Creates an [UpdateDataModel] message that sets [path] to [value]. const UpdateDataModel({ + super.version, required this.surfaceId, this.path = DataPath.root, this.value, - }); + }) : hasValue = true; - /// Creates a [UpdateDataModel] message from a JSON map. + /// Creates an [UpdateDataModel] message that removes the key at [path]. + const UpdateDataModel.removeKey({ + super.version, + required this.surfaceId, + this.path = DataPath.root, + }) : value = null, + hasValue = false; + + /// Creates an [UpdateDataModel] message from a JSON map body. factory UpdateDataModel.fromJson(JsonMap json) { - return UpdateDataModel( + final path = DataPath(json['path'] as String? ?? '/'); + if (json.containsKey('value')) { + return UpdateDataModel( + surfaceId: json[surfaceIdKey] as String, + path: path, + value: json['value'], + ); + } + return UpdateDataModel.removeKey( surfaceId: json[surfaceIdKey] as String, - path: DataPath(json['path'] as String? ?? '/'), - value: json['value'], + path: path, + ); + } + + /// Creates a facade from a core message. + factory UpdateDataModel.fromCore(core.UpdateDataModelMessage message) { + final path = DataPath(message.path ?? '/'); + if (message.hasValue) { + return UpdateDataModel( + version: message.version, + surfaceId: message.surfaceId, + path: path, + value: message.value, + ); + } + return UpdateDataModel.removeKey( + version: message.version, + surfaceId: message.surfaceId, + path: path, ); } @@ -227,33 +274,52 @@ final class UpdateDataModel extends A2uiMessage { final DataPath path; /// The new value to write to the data model. - /// - /// If null (and the key is present in the JSON), it implies deletion of the - /// key at the path. final Object? value; - /// Converts this message to a JSON map. - Map toJson() => { - 'version': 'v0.9', - surfaceIdKey: surfaceId, - 'path': path.toString(), - 'value': ?value, - }; + /// Whether the wire envelope carries an explicit `value` key. + final bool hasValue; + + @override + core.UpdateDataModelMessage toCoreMessage() { + if (!hasValue) { + return core.UpdateDataModelMessage.removeKey( + version: version, + surfaceId: surfaceId, + path: path.toString(), + ); + } + return core.UpdateDataModelMessage( + version: version, + surfaceId: surfaceId, + path: path.toString(), + value: value, + ); + } } /// An A2UI message that deletes a surface. final class DeleteSurface extends A2uiMessage { /// Creates a [DeleteSurface] message. - const DeleteSurface({required this.surfaceId}); + const DeleteSurface({super.version, required this.surfaceId}); - /// Creates a [DeleteSurface] message from a JSON map. + /// Creates a [DeleteSurface] message from a JSON map body. factory DeleteSurface.fromJson(JsonMap json) { return DeleteSurface(surfaceId: json[surfaceIdKey] as String); } + /// Creates a facade from a core message. + factory DeleteSurface.fromCore(core.DeleteSurfaceMessage message) { + return DeleteSurface( + version: message.version, + surfaceId: message.surfaceId, + ); + } + /// The ID of the surface that this message applies to. final String surfaceId; - /// Converts this message to a JSON map. - Map toJson() => {'version': 'v0.9', surfaceIdKey: surfaceId}; + @override + core.DeleteSurfaceMessage toCoreMessage() { + return core.DeleteSurfaceMessage(version: version, surfaceId: surfaceId); + } } diff --git a/packages/genui/lib/src/model/catalog.dart b/packages/genui/lib/src/model/catalog.dart index 6aab4e2a6..1c9fcf3c5 100644 --- a/packages/genui/lib/src/model/catalog.dart +++ b/packages/genui/lib/src/model/catalog.dart @@ -2,6 +2,7 @@ // 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/material.dart'; import 'package:json_schema_builder/json_schema_builder.dart'; @@ -135,27 +136,17 @@ interface class Catalog { } genUiLogger.info('Building widget ${item.name} with id ${itemContext.id}'); - return KeyedSubtree( - key: ValueKey(itemContext.id), - child: item.widgetBuilder( - CatalogItemContext( - data: itemContext.data, - id: itemContext.id, - type: widgetType, - buildChild: (String childId, [DataContext? childDataContext]) => - itemContext.buildChild( - childId, - childDataContext ?? itemContext.dataContext, - ), - dispatchEvent: itemContext.dispatchEvent, - buildContext: itemContext.buildContext, - dataContext: itemContext.dataContext, - getComponent: itemContext.getComponent, - getCatalogItem: (String type) => - items.firstWhereOrNull((item) => item.name == type), - surfaceId: itemContext.surfaceId, - reportError: itemContext.reportError, - ), + // Per-id identity is provided by the Surface's _ComponentBuilder wrapper, + // so no KeyedSubtree is needed here. + return item.widgetBuilder( + itemContext.withOverrides( + buildChild: (String childId, [DataContext? childDataContext]) => + itemContext.buildChild( + childId, + childDataContext ?? itemContext.dataContext, + ), + getCatalogItem: (String type) => + items.firstWhereOrNull((item) => item.name == type), ), ); } @@ -262,3 +253,31 @@ class CatalogItemNotFoundException implements Exception { return buffer.toString(); } } + +/// Adapter exposing a [CatalogItem] as a substrate [core.ComponentApi]. +class _CatalogItemComponentApi implements core.ComponentApi { + _CatalogItemComponentApi(this._item); + final CatalogItem _item; + + @override + String get name => _item.name; + + @override + Schema get schema => _item.dataSchema; +} + +/// Extension on [Catalog] that builds the substrate-facing +/// [core.Catalog] view of the genui catalog. +extension CatalogCoreView on Catalog { + /// Returns a [core.Catalog] populated from this catalog's items, with each + /// [CatalogItem] adapted into a [core.ComponentApi]. Used when constructing + /// a [core.SurfaceModel] so the surface has real component metadata + /// (instead of an empty stub) for substrate-side lookups. + core.Catalog get coreCatalog => + core.Catalog( + id: catalogId ?? 'genui_inline_$hashCode', + components: items + .map(_CatalogItemComponentApi.new) + .toList(growable: false), + ); +} diff --git a/packages/genui/lib/src/model/catalog_item.dart b/packages/genui/lib/src/model/catalog_item.dart index 8bcd0e62c..c0ae4efa6 100644 --- a/packages/genui/lib/src/model/catalog_item.dart +++ b/packages/genui/lib/src/model/catalog_item.dart @@ -2,8 +2,10 @@ // 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:json_schema_builder/json_schema_builder.dart'; +import 'package:meta/meta.dart' show internal; import 'data_model.dart'; import 'ui_models.dart'; @@ -18,7 +20,7 @@ typedef ChildBuilderCallback = /// A callback that builds an example of a catalog item. /// /// The returned string must be a valid JSON representation of a list of -/// [Component] objects. One of the components in the list must have the `id` +/// component objects. One of the components in the list must have the `id` /// 'root'. typedef ExampleBuilderCallback = String Function(); @@ -27,37 +29,90 @@ typedef CatalogWidgetBuilder = Widget Function(CatalogItemContext itemContext); /// Context provided to a [CatalogItem]'s widget builder. /// -/// This class encapsulates all the information and callbacks needed to build -/// a catalog widget, including access to the widget's data, its position in -/// the component tree, and mechanisms for building children and dispatching -/// events. +/// Internally backed by the substrate's [core.ComponentContext], plus +/// Flutter-specific extras (build context, dispatch callbacks, error +/// reporting). The public GenUI authoring API remains the shim getters and +/// callbacks on this class: [id], [type], [data], [surfaceId], +/// [getComponent], [dataContext], [buildChild], and related callbacks. final class CatalogItemContext { - /// Creates a [CatalogItemContext] with the required parameters. + /// Creates a [CatalogItemContext] from the legacy public GenUI fields. /// - /// All parameters are required to ensure the widget builder has complete - /// context for rendering and interaction. + /// Internally this builds a small core [core.ComponentContext] so widget + /// authoring stays source-compatible while the renderer substrate is core. CatalogItemContext({ - required this.data, - required this.id, - required this.type, + required String id, + required String type, + required Map data, required this.buildChild, required this.dispatchEvent, required this.buildContext, required this.dataContext, - required this.getComponent, + required GetComponentCallback getComponent, required this.getCatalogItem, - required this.surfaceId, + required String surfaceId, required this.reportError, - }); + }) : _componentContext = _standaloneContext( + id: id, + type: type, + data: data, + surfaceId: surfaceId, + ), + _getComponentOverride = getComponent; - /// The parsed data for this component from the AI-generated definition. - final Object data; + /// Creates a [CatalogItemContext] from a substrate [core.ComponentContext]. + /// + /// This constructor is for GenUI renderer internals only. Catalog authors + /// should receive instances from the renderer; tests that need to construct + /// one manually can use the public constructor or + /// [CatalogItemContext.forTesting]. + @internal + CatalogItemContext.fromCore({ + required core.ComponentContext componentContext, + required this.buildChild, + required this.dispatchEvent, + required this.buildContext, + required this.dataContext, + required this.getCatalogItem, + required this.reportError, + }) : _componentContext = componentContext, + _getComponentOverride = null; - /// The unique identifier for this component instance. - final String id; + /// Test-only convenience constructor that builds a stand-alone + /// [core.SurfaceModel] + [core.ComponentContext] from raw fields, avoiding + /// the need for tests to wire up a full surface. + @visibleForTesting + factory CatalogItemContext.forTesting({ + required String id, + required String type, + required Map data, + required ChildBuilderCallback buildChild, + required DispatchEventCallback dispatchEvent, + required BuildContext buildContext, + required DataContext dataContext, + required CatalogItem? Function(String type) getCatalogItem, + required String surfaceId, + required void Function(Object error, StackTrace? stack) reportError, + }) { + return CatalogItemContext( + id: id, + type: type, + data: data, + buildChild: buildChild, + dispatchEvent: dispatchEvent, + buildContext: buildContext, + dataContext: dataContext, + getComponent: (_) => null, + getCatalogItem: getCatalogItem, + surfaceId: surfaceId, + reportError: reportError, + ); + } - /// The type of this component. - final String type; + /// The wrapped substrate context. Source of truth for component identity, + /// raw properties, and access to other components on the surface. + final core.ComponentContext _componentContext; + + final GetComponentCallback? _getComponentOverride; /// Callback to build a child widget by its component ID. final ChildBuilderCallback buildChild; @@ -68,20 +123,91 @@ final class CatalogItemContext { /// The Flutter [BuildContext] for this widget. final BuildContext buildContext; - /// The [DataContext] for accessing and modifying the data model. + /// The Flutter-side [DataContext] for accessing the data model, dispatching + /// catalog functions, and subscribing to dynamic-value streams. final DataContext dataContext; - /// Callback to retrieve a component definition by its ID. - final GetComponentCallback getComponent; - /// Callback to retrieve a catalog item definition by its type name. final CatalogItem? Function(String type) getCatalogItem; - /// The ID of the surface this component belongs to. - final String surfaceId; - /// Callback to report an error that occurred within this component. final void Function(Object error, StackTrace? stack) reportError; + + CatalogItemContext._copy({ + required core.ComponentContext componentContext, + required GetComponentCallback? getComponentOverride, + required this.buildChild, + required this.dispatchEvent, + required this.buildContext, + required this.dataContext, + required this.getCatalogItem, + required this.reportError, + }) : _componentContext = componentContext, + _getComponentOverride = getComponentOverride; + + /// Returns a copy of this context with selected Flutter-side callbacks + /// replaced while preserving the same private substrate context. + @internal + CatalogItemContext withOverrides({ + ChildBuilderCallback? buildChild, + DispatchEventCallback? dispatchEvent, + BuildContext? buildContext, + DataContext? dataContext, + CatalogItem? Function(String type)? getCatalogItem, + void Function(Object error, StackTrace? stack)? reportError, + }) { + return CatalogItemContext._copy( + componentContext: _componentContext, + getComponentOverride: _getComponentOverride, + buildChild: buildChild ?? this.buildChild, + dispatchEvent: dispatchEvent ?? this.dispatchEvent, + buildContext: buildContext ?? this.buildContext, + dataContext: dataContext ?? this.dataContext, + getCatalogItem: getCatalogItem ?? this.getCatalogItem, + reportError: reportError ?? this.reportError, + ); + } + + /// The parsed data for this component from the AI-generated definition. + Object get data => _componentContext.componentModel.properties; + + /// The unique identifier for this component instance. + String get id => _componentContext.componentModel.id; + + /// The type of this component. + String get type => _componentContext.componentModel.type; + + /// The ID of the surface this component belongs to. + String get surfaceId => _componentContext.surface.id; + + /// Retrieves a component on the surface by its ID, or `null` if absent. + Component? getComponent(String componentId) { + final Component? override = _getComponentOverride?.call(componentId); + if (override != null) return override; + final core.ComponentModel? component = _componentContext + .surface + .componentsModel + .get(componentId); + return component == null ? null : Component.fromCore(component); + } + + static core.ComponentContext _standaloneContext({ + required String id, + required String type, + required Map data, + required String surfaceId, + }) { + final surface = core.SurfaceModel( + surfaceId, + catalog: core.Catalog( + id: 'catalog', + components: const [], + ), + ); + final component = core.ComponentModel(id, type, data); + surface.componentsModel.addComponent(component); + return core.ComponentContext(surface, component); + } } /// Defines a UI layout type, its schema, and how to build its widget. @@ -145,7 +271,7 @@ final class CatalogItem { /// example usage of this widget. /// /// Each returned string must be a valid JSON representation of a list of - /// [Component] objects. For the example to be renderable, one of the + /// component objects. For the example to be renderable, one of the /// components in the list must have the `id` 'root', which will be used as /// the entry point for rendering. /// diff --git a/packages/genui/lib/src/model/schema_validation.dart b/packages/genui/lib/src/model/schema_validation.dart new file mode 100644 index 000000000..eb022dd67 --- /dev/null +++ b/packages/genui/lib/src/model/schema_validation.dart @@ -0,0 +1,180 @@ +// 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 'dart:convert'; + +import 'package:json_schema_builder/json_schema_builder.dart'; + +import '../primitives/simple_items.dart'; +import 'ui_models.dart' show A2uiValidationException; + +/// Validates a set of A2UI components against a catalog [schema]. +/// +/// Shared between [SurfaceDefinition.validate] (legacy snapshot path) and +/// `SurfaceController`'s post-mutation validation (live model path). Each +/// caller adapts its component representation into the [components] iterable +/// and passes the same catalog schema; the schema-walking and matching logic +/// lives here in one place. +/// +/// Throws [A2uiValidationException] on the first component that fails. Does +/// not roll back any state — that's the caller's concern. +void validateComponents({ + required String surfaceId, + required Iterable<({String id, String type, JsonMap json})> components, + required Schema schema, +}) { + final List> allowedSchemas = _extractAllowedSchemas( + jsonDecode(schema.toJson()) as Map, + ); + if (allowedSchemas.isEmpty) return; + + for (final component in components) { + var matched = false; + final errors = []; + + for (final s in allowedSchemas) { + if (_schemaMatchesType(s, component.type)) { + try { + _validateInstance( + component.json, + s, + '/components/${component.id}', + surfaceId, + ); + matched = true; + break; + } catch (e) { + errors.add(e.toString()); + } + } + } + + if (!matched) { + if (errors.isNotEmpty) { + throw A2uiValidationException( + 'Validation failed for component ${component.id} ' + '(${component.type}): ${errors.join("; ")}', + surfaceId: surfaceId, + path: '/components/${component.id}', + ); + } + throw A2uiValidationException( + 'Unknown component type: ${component.type}', + surfaceId: surfaceId, + path: '/components/${component.id}', + ); + } + } +} + +List> _extractAllowedSchemas( + Map schemaMap, +) { + if (schemaMap.containsKey('oneOf')) { + return (schemaMap['oneOf'] as List).cast>(); + } + if (schemaMap.containsKey('properties') && + (schemaMap['properties'] as Map).containsKey('components')) { + final componentsProp = + (schemaMap['properties'] as Map)['components'] as Map; + if (componentsProp.containsKey('items')) { + final items = componentsProp['items'] as Map; + if (items.containsKey('oneOf')) { + return (items['oneOf'] as List).cast>(); + } + return [items]; + } + if (componentsProp.containsKey('properties')) { + return (componentsProp['properties'] as Map).values + .cast>() + .toList(); + } + } + return const []; +} + +bool _schemaMatchesType(Map schema, String type) { + if (schema case { + 'properties': {'component': Map compProp}, + }) { + return switch (compProp) { + {'const': String constType} when constType == type => true, + {'enum': List enums} when enums.contains(type) => true, + _ => false, + }; + } + return false; +} + +void _validateInstance( + Object? instance, + Map schema, + String path, + String surfaceId, +) { + if (instance == null) return; + + if (schema case {'const': Object? constVal} when instance != constVal) { + throw A2uiValidationException( + 'Value mismatch. Expected $constVal, got $instance', + surfaceId: surfaceId, + path: path, + ); + } + if (schema case { + 'enum': List enums, + } when !enums.contains(instance)) { + throw A2uiValidationException( + 'Value not in enum: $instance', + surfaceId: surfaceId, + path: path, + ); + } + if (schema case {'required': List required} when instance is Map) { + for (final String key in required.cast()) { + if (!instance.containsKey(key)) { + throw A2uiValidationException( + 'Missing required property: $key', + surfaceId: surfaceId, + path: path, + ); + } + } + } + if (schema case { + 'properties': Map props, + } when instance is Map) { + for (final MapEntry entry in props.entries) { + final String key = entry.key; + final propSchema = entry.value as Map; + if (instance.containsKey(key)) { + _validateInstance(instance[key], propSchema, '$path/$key', surfaceId); + } + } + } + if (schema case { + 'items': Map itemsSchema, + } when instance is List) { + for (var i = 0; i < instance.length; i++) { + _validateInstance(instance[i], itemsSchema, '$path/$i', surfaceId); + } + } + if (schema case {'oneOf': List oneOfs}) { + var oneMatched = false; + for (final Map s in oneOfs.cast>()) { + try { + _validateInstance(instance, s, path, surfaceId); + oneMatched = true; + break; + } catch (_) {} + } + if (!oneMatched) { + throw A2uiValidationException( + 'Value did not match any oneOf schema', + surfaceId: surfaceId, + path: path, + ); + } + } +} diff --git a/packages/genui/lib/src/transport/a2ui_parser_transformer.dart b/packages/genui/lib/src/transport/a2ui_parser_transformer.dart index e5d6ac3b3..cfde166af 100644 --- a/packages/genui/lib/src/transport/a2ui_parser_transformer.dart +++ b/packages/genui/lib/src/transport/a2ui_parser_transformer.dart @@ -181,38 +181,56 @@ class _A2uiParserStream { } } + /// JSON keys that mark a payload as intended to be an A2UI message envelope. + /// If parsing fails on a payload that looks like an envelope, treat the + /// failure as a validation error rather than fall back to emitting plain + /// text. `version` is included because a payload that carries `version` + /// is clearly an attempted envelope even if its action key is unknown or + /// malformed. + static const _a2uiEnvelopeKeys = { + 'version', + 'createSurface', + 'updateComponents', + 'updateDataModel', + 'deleteSurface', + }; + + bool _looksLikeA2uiEnvelope(Map json) => + json.keys.any(_a2uiEnvelopeKeys.contains); + void _emitMessage(Object json) { if (json is Map) { - try { - _controller.add(A2uiMessageEvent(A2uiMessage.fromJson(json))); - _wasLastEventA2ui = true; - } on A2uiValidationException catch (e) { - _controller.addError(e); - _wasLastEventA2ui = false; - } catch (_) { - // Failed to parse A2UI message structure (e.g. invalid type - // discriminator) - _controller.add(TextEvent(jsonEncode(json))); - _wasLastEventA2ui = false; - } + _tryEmitOne(json); } else if (json is List) { for (final Object? item in json) { if (item is Map) { - try { - _controller.add(A2uiMessageEvent(A2uiMessage.fromJson(item))); - _wasLastEventA2ui = true; - } on A2uiValidationException catch (e) { - _controller.addError(e); - _wasLastEventA2ui = false; - } catch (_) { - _controller.add(TextEvent(jsonEncode(item))); - _wasLastEventA2ui = false; - } + _tryEmitOne(item); } } } } + void _tryEmitOne(Map json) { + try { + _controller.add(A2uiMessageEvent(A2uiMessage.fromJson(json))); + _wasLastEventA2ui = true; + } catch (e) { + if (_looksLikeA2uiEnvelope(json)) { + _controller.addError( + A2uiValidationException( + 'Failed to parse A2UI message', + json: json, + cause: e, + ), + ); + } else { + // Not an A2UI envelope — emit as plain text. + _controller.add(TextEvent(jsonEncode(json))); + } + _wasLastEventA2ui = false; + } + } + _Match? _findMarkdownJson(String text) { final regex = RegExp(r'```(?:json)?\s*([\s\S]*?)\s*```'); final RegExpMatch? match = regex.firstMatch(text); diff --git a/packages/genui/lib/src/transport/a2ui_transport_adapter.dart b/packages/genui/lib/src/transport/a2ui_transport_adapter.dart index a96bb777c..5eac93b30 100644 --- a/packages/genui/lib/src/transport/a2ui_transport_adapter.dart +++ b/packages/genui/lib/src/transport/a2ui_transport_adapter.dart @@ -4,11 +4,12 @@ import 'dart:async'; +import 'package:a2ui_core/a2ui_core.dart' as core; + import '../interfaces/transport.dart'; import '../model/a2ui_message.dart'; import '../model/chat_message.dart'; import '../model/generation_events.dart'; - import 'a2ui_parser_transformer.dart'; export '../model/generation_events.dart' @@ -56,8 +57,18 @@ class A2uiTransportAdapter implements Transport { } /// Feeds a raw A2UI message (e.g. from a tool output or separate channel). - void addMessage(A2uiMessage message) { - _messageStream.add(message); + void addMessage(Object message) { + if (message is A2uiMessage) { + _messageStream.add(message); + return; + } + if (message is core.A2uiMessage) { + _messageStream.add(A2uiMessage.fromCore(message)); + return; + } + throw ArgumentError( + 'Unsupported A2UI message type: ${message.runtimeType}', + ); } /// A stream of sanitizer text for the chat UI. diff --git a/packages/genui/lib/test/validation.dart b/packages/genui/lib/test/validation.dart index 1b405b539..c2392456a 100644 --- a/packages/genui/lib/test/validation.dart +++ b/packages/genui/lib/test/validation.dart @@ -4,13 +4,12 @@ import 'dart:convert'; +import 'package:a2ui_core/a2ui_core.dart' as core; import 'package:json_schema_builder/json_schema_builder.dart'; -import '../src/model/a2ui_message.dart'; import '../src/model/a2ui_schemas.dart'; import '../src/model/catalog.dart'; import '../src/model/catalog_item.dart' show CatalogItem; -import '../src/model/ui_models.dart'; import '../src/primitives/simple_items.dart'; /// A class to represent a validation error in a catalog item example. @@ -63,11 +62,12 @@ Future> validateCatalogItemExamples( continue; } - final List components = exampleData - .map((e) => Component.fromJson(e as JsonMap)) + final List> components = exampleData + .cast() + .map(Map.from) .toList(); - if (components.every((c) => c.id != 'root')) { + if (components.every((c) => c['id'] != 'root')) { errors.add( ExampleValidationError( i, @@ -76,13 +76,16 @@ Future> validateCatalogItemExamples( ); } - final surfaceUpdate = UpdateComponents( + final surfaceUpdate = core.UpdateComponentsMessage( surfaceId: 'test-surface', components: components, ); + // `a2ui_core.UpdateComponentsMessage.toJson()` wraps the body in an + // outer envelope (`{'version': ..., 'updateComponents': {...}}`); the + // schema here validates the body shape only. final List validationErrors = await schema.validate( - surfaceUpdate.toJson(), + surfaceUpdate.toJson()['updateComponents'], ); if (validationErrors.isNotEmpty) { errors.add( diff --git a/packages/genui/test/catalog/core_widgets_test.dart b/packages/genui/test/catalog/core_widgets_test.dart index 24c6a7fdb..d2c140a46 100644 --- a/packages/genui/test/catalog/core_widgets_test.dart +++ b/packages/genui/test/catalog/core_widgets_test.dart @@ -78,8 +78,9 @@ void main() { ]; await pumpWidgetWithDefinition(tester, 'root', components); - controller!.store - .getDataModel('testSurface') + controller! + .contextFor('testSurface') + .dataModel .update(DataPath('/myText'), 'Hello from data model'); await tester.pumpAndSettle(); @@ -131,8 +132,9 @@ void main() { ]; await pumpWidgetWithDefinition(tester, 'field', components); - controller!.store - .getDataModel('testSurface') + controller! + .contextFor('testSurface') + .dataModel .update(DataPath('/myValue'), 'initial'); await tester.pumpAndSettle(); @@ -144,8 +146,9 @@ void main() { // Test onChanged await tester.enterText(textFieldFinder, 'new value'); expect( - controller!.store - .getDataModel('testSurface') + controller! + .contextFor('testSurface') + .dataModel .getValue(DataPath('/myValue')), 'new value', ); diff --git a/packages/genui/test/engine/surface_controller_test.dart b/packages/genui/test/engine/surface_controller_test.dart index 88e0bc588..f7c7b3f01 100644 --- a/packages/genui/test/engine/surface_controller_test.dart +++ b/packages/genui/test/engine/surface_controller_test.dart @@ -71,7 +71,7 @@ void main() { expect(definition.catalogId, 'test_catalog'); expect(controller.registry.getSurface(surfaceId), isNotNull); expect( - controller.registry.getSurface(surfaceId)!.catalogId, + controller.registry.getSurface(surfaceId)!.catalog.id, 'test_catalog', ); }); @@ -147,13 +147,40 @@ void main() { test('surface() creates a new ValueNotifier if one does not exist', () { final ValueListenable notifier1 = controller.registry - .watchSurface('s1'); + .watchDefinition('s1'); final ValueListenable notifier2 = controller.registry - .watchSurface('s1'); + .watchDefinition('s1'); expect(notifier1, same(notifier2)); expect(notifier1.value, isNull); }); + test('store exposes the live surface data model facade', () { + const surfaceId = 's1'; + controller.handleMessage( + const CreateSurface(surfaceId: surfaceId, catalogId: 'test_catalog'), + ); + controller.handleMessage( + const UpdateDataModel( + surfaceId: surfaceId, + path: DataPath.root, + value: {'name': 'Alice'}, + ), + ); + + final DataModel model = controller.store.getDataModel(surfaceId); + expect(model.getValue(DataPath('/name')), 'Alice'); + expect(controller.store.dataModels[surfaceId], same(model)); + + controller.handleMessage( + UpdateDataModel( + surfaceId: surfaceId, + path: DataPath('/name'), + value: 'Bob', + ), + ); + expect(model.getValue(DataPath('/name')), 'Bob'); + }); + test('dispose() closes the updates stream', () async { var isClosed = false; controller.surfaceUpdates.listen( @@ -170,9 +197,6 @@ void main() { }); test('can handle UI event', () async { - controller.store - .getDataModel('testSurface') - .update(DataPath('/myValue'), 'testValue'); final Future future = controller.onSubmit.first; final now = DateTime.now(); final event = UserActionEvent( @@ -277,7 +301,8 @@ void main() { await Future.delayed(Duration.zero); final SurfaceDefinition? surface = shortTimeoutController.registry - .getSurface(surfaceId); + .watchDefinition(surfaceId) + .value; expect(surface, isNotNull); // Updates NOT applied, so components should be empty (or default) expect(surface!.components, isEmpty); diff --git a/packages/genui/test/model/a2ui_message_test.dart b/packages/genui/test/model/a2ui_message_test.dart index 20022d989..ad4c7dff6 100644 --- a/packages/genui/test/model/a2ui_message_test.dart +++ b/packages/genui/test/model/a2ui_message_test.dart @@ -100,6 +100,49 @@ void main() { expect(message.toJson(), containsPair('version', 'v0.9')); }); + test('UpdateDataModel preserves explicit null value', () { + final message = A2uiMessage.fromJson({ + 'version': 'v0.9', + 'updateDataModel': { + surfaceIdKey: 's1', + 'path': '/x', + 'value': null, + }, + }); + + final update = message as UpdateDataModel; + expect(update.path, DataPath('/x')); + expect(update.value, isNull); + expect(update.hasValue, isTrue); + + final body = update.toJson()['updateDataModel'] as Map; + expect(body.containsKey('value'), isTrue); + expect(body['value'], isNull); + }); + + test('UpdateDataModel preserves omitted value as remove-key', () { + final message = UpdateDataModel.removeKey( + surfaceId: 's1', + path: DataPath('/x'), + ); + + final body = message.toJson()['updateDataModel'] as Map; + expect(message.path, DataPath('/x')); + expect(message.hasValue, isFalse); + expect(body.containsKey('value'), isFalse); + + final reparsed = + A2uiMessage.fromJson({ + 'version': 'v0.9', + 'updateDataModel': { + surfaceIdKey: 's1', + 'path': '/x', + }, + }) + as UpdateDataModel; + expect(reparsed.hasValue, isFalse); + }); + test('DeleteSurface.toJson includes version', () { const message = DeleteSurface(surfaceId: 's1'); expect(message.toJson(), containsPair('version', 'v0.9')); diff --git a/packages/genui/test/model/data_model_edge_cases_test.dart b/packages/genui/test/model/data_model_edge_cases_test.dart index 8e6ca2acb..779476907 100644 --- a/packages/genui/test/model/data_model_edge_cases_test.dart +++ b/packages/genui/test/model/data_model_edge_cases_test.dart @@ -84,18 +84,22 @@ void main() { expect(dataModel.getValue>(DataPath('/list')), ['a', 'b']); }); - test('List Boundaries: Out of bounds (index > length) is ignored', () { + test('List Boundaries: sparse writes fill skipped entries with null', () { dataModel.update(DataPath('/list/0'), 'a'); - // Try to write to index 2 (skipping 1) + // Write to index 2 (skipping 1). a2ui_core auto-vivifies sparse + // list entries with null rather than silently ignoring the write. dataModel.update(DataPath('/list/2'), 'c'); - expect(dataModel.getValue>(DataPath('/list')), ['a']); - // Verify length is still 1 + expect(dataModel.getValue>(DataPath('/list')), [ + 'a', + null, + 'c', + ]); final List? list = dataModel.getValue>( DataPath('/list'), ); - expect(list?.length, 1); + expect(list?.length, 3); }); test('Null Handling: Setting map key to null removes it', () { From 967e14633ad85ce40a28fcf94f1757f6e3830e57 Mon Sep 17 00:00:00 2001 From: Andrew Kolos Date: Tue, 26 May 2026 14:17:01 -0700 Subject: [PATCH 05/49] docs(migration): add migration guide for genui -> a2ui_core swap Short guide for consumers. The substrate moved into a2ui_core but the existing GenUI public API names are preserved as compat facades, so most catalog widget authors and example apps need no source changes. The guide: 1. Lists what stays source-compatible: CreateSurface, DataPath, DataModel, InMemoryDataModel, SurfaceDefinition, Component, SurfaceContext.definition, SurfaceController.store / DataModelStore, ActionDelegate.handleEvent's existing signature, CatalogItemContext public fields, UiPart.create(definition: SurfaceDefinition(...)). 2. Documents what changed internally: SurfaceController delegates to MessageProcessor; InMemoryDataModel wraps core.DataModel; the Surface widget uses live core models for granular rebuilds when its context is GenUI's own controller context, and falls back to the legacy snapshot path for custom SurfaceContexts. 3. Lists residual behavior changes to watch for: stricter DataModel writes, defensively-copied stored containers, signal-backed reactivity inside Bound*, stricter envelope validation, duplicate createSurface as an error, RFC 6901 escapes no longer interpreted. 4. Notes that the GenUI-named compat types are the current public API, not short-lived shims. A future PR may add a2ui_core-shaped aliases for cross-language parity, but the existing names will not be removed without a separate deprecation cycle. --- .../migration_genui_onto_a2ui_core.md | 89 +++++++++++++++++++ 1 file changed, 89 insertions(+) create mode 100644 docs/usage/migration/migration_genui_onto_a2ui_core.md diff --git a/docs/usage/migration/migration_genui_onto_a2ui_core.md b/docs/usage/migration/migration_genui_onto_a2ui_core.md new file mode 100644 index 000000000..50abc9751 --- /dev/null +++ b/docs/usage/migration/migration_genui_onto_a2ui_core.md @@ -0,0 +1,89 @@ +# Migration Guide: GenUI on `a2ui_core` + +This PR migrates `package:genui`'s runtime substrate onto the shared +`package:a2ui_core` implementation, but it intentionally keeps the existing +GenUI-facing API shape as a compatibility facade. + +The goal is to make this PR about the substrate swap (#811): shared protocol +parsing, shared surface/data-model state, and granular Flutter rebuilds. Public +API renames for closer web/core parity are deferred to a follow-up PR (#801). + +## What stays source-compatible in this PR + +GenUI applications and catalog authors should continue to use the existing +`package:genui` API names: + +- `CreateSurface`, `UpdateComponents`, `UpdateDataModel`, `DeleteSurface`, and + `A2uiMessage.fromJson`. +- `DataPath`, `DataModel`, `InMemoryDataModel`, `DataContext.update`, + `getValue`, `subscribe`, and `bindExternalState`. +- `Component` and `SurfaceDefinition` snapshot/value objects. +- `SurfaceContext.definition`, `SurfaceUpdate.definition`, and `SurfaceController.store` / `DataModelStore`. +- `ActionDelegate.handleEvent`'s existing `SurfaceDefinition` callback shape. +- Catalog widget authoring through `CatalogItemContext.id`, `type`, `data`, + `surfaceId`, `getComponent`, `dataContext`, `buildChild`, `dispatchEvent`, + and `reportError`. +- `UiPart.create(definition: SurfaceDefinition(...))`. + +You should **not** need to add `a2ui_core` to application/example packages just +to consume GenUI. `package:genui` does not re-export raw `a2ui_core` symbols. + +## What changed internally + +The compatibility types above now delegate to, wrap, or snapshot from +`a2ui_core`: + +- `SurfaceController.handleMessage(...)` accepts GenUI facade messages, converts + them to core messages privately, and delegates state mutation to + `a2ui_core.MessageProcessor`. +- `InMemoryDataModel` wraps `a2ui_core.DataModel` while preserving the old + `DataPath`/`ValueListenable` API. +- GenUI's own `SurfaceController` + `Surface` path renders from the live core + `SurfaceModel`, so component updates can rebuild only the affected component + subtree. +- `SurfaceController.store` remains available as a compatibility facade; for + live surfaces it returns wrappers around the surface's core-backed data model. +- Custom/external `SurfaceContext` implementations can still provide only the + legacy `definition` snapshot path. +- `CatalogItemContext` is internally backed by `a2ui_core.ComponentContext`, but + the public authoring surface remains the GenUI shim getters and callbacks. + `getComponent()` returns a GenUI `Component?` snapshot. + +## Remaining behavior changes to watch for + +These are substrate behavior changes, not rename requirements: + +1. **`DataModel` is stricter.** Some writes that previously no-op'd now throw + core data errors, especially type-mismatched intermediate paths and very + large list indices. Sparse list writes are also core-style: skipped entries + are filled with `null`. +2. **Stored containers are mutable copies.** Incoming map/list values are copied + before storage so nested updates work even when callers pass const literals. +3. **Surfaces are live internally.** Public `SurfaceDefinition` snapshots remain + available, but GenUI's built-in renderer uses live component models for + granular rebuilds. +4. **Data reactivity is signal-backed internally.** Public `subscribe(...)` + still returns a `ValueListenable`, and `Bound*` widgets keep their existing + API. Internally, those listenables bridge to `preact_signals` from + `a2ui_core`. +5. **Protocol validation is stricter.** The core parser rejects malformed + envelopes more consistently, including missing/incorrect versions and + envelopes with more than one action key. +6. **Duplicate `createSurface` for an active surface id is an error** instead + of silently reusing the existing surface. +7. **JSON Pointer `~0`/`~1` escapes are not interpreted.** Paths split on `/`, + matching the web core behavior. + +## On the future of the compatibility facades + +The GenUI-named types in this release (`CreateSurface`, `InMemoryDataModel`, +`DataPath`, `SurfaceDefinition`, `Component`, `SurfaceContext.definition`, +`SurfaceController.store`, etc.) are kept as a compatibility API on top of +`a2ui_core`. They're stable; you can write new code against them today. + +A future PR may add `a2ui_core`-shaped aliases or replacements for closer +cross-language parity (`CreateSurfaceMessage`, string paths, `SurfaceModel`, +raw map component payloads, etc.). That PR is not scheduled, and the existing +GenUI names will not be removed without a separate deprecation cycle. Treat +the facade types in this release as the current public API, not as +short-lived shims. From 5e6c5fd81bd27bdc1b94962a3577814680908f34 Mon Sep 17 00:00:00 2001 From: Andrew Kolos Date: Thu, 28 May 2026 10:38:08 -0700 Subject: [PATCH 06/49] chore: trim comments --- .../migration_genui_onto_a2ui_core.md | 4 +- packages/a2ui_core/lib/src/core/messages.dart | 30 ++++------- .../lib/src/engine/data_model_store.dart | 34 ++++-------- .../lib/src/engine/surface_controller.dart | 54 +++++-------------- .../lib/src/engine/surface_registry.dart | 17 +++--- .../lib/src/interfaces/surface_context.dart | 13 ++--- .../genui/lib/src/model/a2ui_message.dart | 11 +--- packages/genui/lib/src/model/catalog.dart | 15 +++--- .../genui/lib/src/model/catalog_item.dart | 34 +++++------- packages/genui/lib/src/model/data_model.dart | 31 ++--------- .../lib/src/model/schema_validation.dart | 10 +--- packages/genui/lib/src/model/ui_models.dart | 15 +++--- .../transport/a2ui_parser_transformer.dart | 20 ++++--- packages/genui/lib/src/widgets/surface.dart | 10 ++-- packages/genui/lib/test/validation.dart | 6 +-- 15 files changed, 94 insertions(+), 210 deletions(-) diff --git a/docs/usage/migration/migration_genui_onto_a2ui_core.md b/docs/usage/migration/migration_genui_onto_a2ui_core.md index 50abc9751..d250f2c55 100644 --- a/docs/usage/migration/migration_genui_onto_a2ui_core.md +++ b/docs/usage/migration/migration_genui_onto_a2ui_core.md @@ -67,8 +67,8 @@ These are substrate behavior changes, not rename requirements: API. Internally, those listenables bridge to `preact_signals` from `a2ui_core`. 5. **Protocol validation is stricter.** The core parser rejects malformed - envelopes more consistently, including missing/incorrect versions and - envelopes with more than one action key. + messages more consistently, including missing/incorrect versions and + messages with more than one top-level action key. 6. **Duplicate `createSurface` for an active surface id is an error** instead of silently reusing the existing surface. 7. **JSON Pointer `~0`/`~1` escapes are not interpreted.** Paths split on `/`, diff --git a/packages/a2ui_core/lib/src/core/messages.dart b/packages/a2ui_core/lib/src/core/messages.dart index d7ac608bd..cdb8079f5 100644 --- a/packages/a2ui_core/lib/src/core/messages.dart +++ b/packages/a2ui_core/lib/src/core/messages.dart @@ -9,12 +9,12 @@ abstract class A2uiMessage { final String version; A2uiMessage({this.version = 'v0.9'}); - /// Deserializes a JSON envelope into a typed [A2uiMessage]. + /// Deserializes a JSON message into a typed [A2uiMessage]. /// /// Throws [A2uiValidationError] if the `version` field is missing or is - /// not exactly `'v0.9'`, or if the envelope does not contain exactly one - /// of the known action keys (`createSurface`, `updateComponents`, - /// `updateDataModel`, `deleteSurface`). + /// not exactly `'v0.9'`, or if the message does not contain exactly one + /// of `createSurface`, `updateComponents`, `updateDataModel`, + /// `deleteSurface`. factory A2uiMessage.fromJson(Map json) { final Object? rawVersion = json['version']; if (rawVersion is! String) { @@ -70,8 +70,6 @@ abstract class A2uiMessage { if (json.containsKey('updateDataModel')) { final body = json['updateDataModel'] as Map; - // Preserve the wire-level distinction between "value omitted" (remove - // the key) and "value: null" (set to null) — see UpdateDataModelMessage. if (body.containsKey('value')) { return UpdateDataModelMessage( version: version, @@ -152,28 +150,22 @@ class UpdateComponentsMessage extends A2uiMessage { /// Updates the data model for an existing surface. /// -/// The wire protocol distinguishes between two intents the renderer guide -/// treats differently: +/// The wire protocol distinguishes two intents: /// -/// - `"value": ` (present, possibly `null`): set the value at [path]. +/// - `"value": ` (present, possibly `null`): set [path] to that value. /// - omitted `value` key: remove the key at [path] (sparse-clear for lists). /// -/// To keep that distinction lossless through parse/serialize, the default -/// constructor marks `hasValue = true` (the message carries an explicit -/// value, which may be `null`), and [UpdateDataModelMessage.removeKey] -/// constructs the "value omitted" form. +/// The default constructor builds the first; [UpdateDataModelMessage.removeKey] +/// builds the second. [hasValue] preserves the distinction across parse and +/// serialize. class UpdateDataModelMessage extends A2uiMessage { final String surfaceId; final String? path; final Object? value; - /// True iff the message carries an explicit `value` on the wire. When - /// false, the `value` key is absent from the JSON envelope and the - /// receiver should treat the message as a "remove key at [path]". + /// True if the message carries an explicit `value` on the wire. final bool hasValue; - /// Constructs a message that sets [path] to [value]. `value` is part of - /// the envelope even when `null`. UpdateDataModelMessage({ super.version, required this.surfaceId, @@ -181,8 +173,6 @@ class UpdateDataModelMessage extends A2uiMessage { this.value, }) : hasValue = true; - /// Constructs a message that removes the key at [path] (no `value` field - /// on the wire). UpdateDataModelMessage.removeKey({ super.version, required this.surfaceId, diff --git a/packages/genui/lib/src/engine/data_model_store.dart b/packages/genui/lib/src/engine/data_model_store.dart index 3b54fdf4e..eaf34632a 100644 --- a/packages/genui/lib/src/engine/data_model_store.dart +++ b/packages/genui/lib/src/engine/data_model_store.dart @@ -4,35 +4,21 @@ import '../model/data_model.dart'; -/// A compatibility facade over the per-surface data models managed by +/// A facade over per-surface data models managed by /// `a2ui_core.SurfaceGroupModel`. /// -/// Earlier `package:genui` releases kept a separate per-surface -/// `InMemoryDataModel` registry here, distinct from anything the substrate -/// owned. Post-`a2ui_core` migration, the canonical data model lives on -/// `core.SurfaceModel.dataModel`; this class exists to preserve the old -/// public API (`SurfaceController.store`, `store.getDataModel(surfaceId)`) -/// while transparently returning a [DataModel] wrapper over the live -/// substrate model. +/// Kept to preserve the legacy `SurfaceController.store` / +/// `store.getDataModel(surfaceId)` API. The lookup callback (provided by +/// `SurfaceController`) redirects active surfaces to the substrate's live +/// `surface.dataModel`; ids without a live surface fall back to a standalone +/// in-memory model, preserving the pre-migration "data survives before +/// createSurface" behavior. /// -/// The [lookup] callback is what does the substrate redirection: GenUI's -/// own `SurfaceController` constructs the store with a lookup that returns -/// `InMemoryDataModel.wrap(surface.dataModel)` for active surfaces. When -/// no live surface exists for a requested id, [getDataModel] falls back to -/// a standalone in-memory model — preserving the prior "data survives -/// before createSurface" leniency that some integration paths relied on. -/// -/// This class will be removed in the same follow-up PR that renames the -/// rest of GenUI's facade types to match `a2ui_core` directly; new code -/// should prefer reading from the surface's own data model via -/// `SurfaceController.registry.getSurface(id)?.dataModel`. +/// Slated for removal alongside the rest of the GenUI->a2ui_core facade +/// renames. New code should read from `SurfaceController.registry +/// .getSurface(id)?.dataModel` directly. class DataModelStore { /// Creates a [DataModelStore]. - /// - /// When [lookup] returns a model for a surface, that live model is used - /// instead of creating a standalone fallback model. This keeps the legacy - /// store API source-compatible while GenUI's own controller stores data in - /// the a2ui_core-backed surface model. DataModelStore({DataModel? Function(String surfaceId)? lookup}) : _lookup = lookup; diff --git a/packages/genui/lib/src/engine/surface_controller.dart b/packages/genui/lib/src/engine/surface_controller.dart index 0231a5473..3743a0bc6 100644 --- a/packages/genui/lib/src/engine/surface_controller.dart +++ b/packages/genui/lib/src/engine/surface_controller.dart @@ -26,20 +26,16 @@ import 'surface_registry.dart' as surface_reg; /// The runtime controller for the GenUI system. /// -/// Thin Flutter-side wrapper around [core.MessageProcessor]: the substrate -/// owns the canonical A2UI state-mutation rules (create/update/delete -/// surfaces and their components/data models) and this class adds the -/// Flutter-specific concerns on top: pre-create message buffering, schema -/// validation against the genui catalog, and a [SurfaceUpdate] stream the -/// Flutter facade subscribes to. +/// Wraps [core.MessageProcessor] and adds Flutter-side concerns: pre-create +/// message buffering, catalog-schema validation, and a [SurfaceUpdate] +/// stream the Flutter facade subscribes to. interface class SurfaceController implements SurfaceHost, A2uiMessageSink { SurfaceController({ required this.catalogs, this.pendingUpdateTimeout = const Duration(minutes: 1), }) { _processor = core.MessageProcessor( - // Growable: handleMessage may inject stub catalogs for unknown - // catalogIds (see comment near _processor.catalogs.add below). + // Growable: handleMessage injects stub catalogs for unknown catalogIds. catalogs: catalogs.map((c) => c.coreCatalog).toList(), ); _processor.groupModel.onSurfaceCreated.addListener(_onCoreSurfaceCreated); @@ -97,16 +93,10 @@ interface class SurfaceController implements SurfaceHost, A2uiMessageSink { /// The registry of surfaces managed by this controller. surface_reg.SurfaceRegistry get registry => _registry; - /// The source-compatible store of data models managed by this controller. + /// The store of data models managed by this controller. DataModelStore get store => _store; /// Processes a message from the AI service. - /// - /// Delegates the canonical state mutation to [core.MessageProcessor] and - /// adds Flutter-specific concerns around it (pre-create buffering of - /// updates, schema validation of the resulting component set, and - /// surface-level `ComponentsUpdated` emission for the [surfaceUpdates] - /// stream). @override void handleMessage(A2uiMessage message) { genUiLogger.info( @@ -115,11 +105,8 @@ interface class SurfaceController implements SurfaceHost, A2uiMessageSink { _handleCoreMessage(message.toCoreMessage()); } - /// Internal entry point used by buffered/flushed messages where we already - /// hold the substrate representation. Public callers go through - /// [handleMessage] with the GenUI facade type. void _handleCoreMessage(core.A2uiMessage coreMessage) { - // Empty surfaceId — reject before delegating so the substrate doesn't + // Reject empty surfaceId before delegating; the substrate would otherwise // create a surface with id "". if (coreMessage is core.CreateSurfaceMessage && coreMessage.surfaceId.isEmpty) { @@ -134,18 +121,14 @@ interface class SurfaceController implements SurfaceHost, A2uiMessageSink { return; } - // Buffer updates that arrive before their surface is created. final String? bufferSurfaceId = _bufferSurfaceIdIfNoSurface(coreMessage); if (bufferSurfaceId != null) { _bufferMessage(bufferSurfaceId, coreMessage); return; } - // If a createSurface refers to a catalogId we do not have, register an - // empty stub so the substrate's "catalog not found" check passes. This - // mirrors the previous lenient behavior where unknown catalogIds were - // accepted with an empty component set — useful for tests/demos that - // do not pre-register every catalog the server may send. + // Register an empty stub for unknown catalogIds. Mirrors the lenient + // pre-migration behavior tests and demos relied on. if (coreMessage is core.CreateSurfaceMessage) { final core.CreateSurfaceMessage createMessage = coreMessage; if (!_processor.catalogs.any((c) => c.id == createMessage.catalogId)) { @@ -196,21 +179,14 @@ interface class SurfaceController implements SurfaceHost, A2uiMessageSink { return; } - // Genui-side post-mutation handling. The substrate has already applied - // the mutation by this point — these are additive Flutter/genui - // concerns the substrate does not own. if (coreMessage is core.UpdateComponentsMessage) { final core.SurfaceModel? surface = _processor .groupModel .getSurface(coreMessage.surfaceId); if (surface != null) { - // Emit a surface-level "components updated" so subscribers to - // `surfaceUpdates` can react without listening to every - // ComponentModel individually. _registry.notifyUpdated(surface); - // Validate the resulting component set against the genui catalog - // schema. Mutation is not rolled back on validation failure — we - // surface the error and let the caller decide. + // Validation does not roll back the mutation; we surface the error + // and let the caller decide. try { final Catalog? genuiCatalog = catalogs.firstWhereOrNull( (c) => c.catalogId == surface.catalog.id, @@ -228,9 +204,8 @@ interface class SurfaceController implements SurfaceHost, A2uiMessageSink { } } - /// Returns the surfaceId of [message] if it is an update for a surface - /// that does not yet exist (and therefore needs to be buffered), or null - /// if no buffering is needed. + /// If [message] targets a surface that does not yet exist, returns that + /// surfaceId so the caller can buffer the message. Otherwise returns null. String? _bufferSurfaceIdIfNoSurface(core.A2uiMessage message) { final String? targetId = switch (message) { core.UpdateComponentsMessage(:final surfaceId) => surfaceId, @@ -252,7 +227,6 @@ interface class SurfaceController implements SurfaceHost, A2uiMessageSink { void _onCoreSurfaceCreated(core.SurfaceModel surface) { _registry.addSurface(surface); - // Flush any updates that arrived for this surfaceId before createSurface. final List? pending = _pendingUpdates.remove(surface.id); _pendingUpdateTimers.remove(surface.id)?.cancel(); if (pending != null) { @@ -310,8 +284,8 @@ interface class SurfaceController implements SurfaceHost, A2uiMessageSink { } } - /// Handles a UI event from a surface — converts a [UserActionEvent] into - /// a [ChatMessage] sent on [onSubmit]. + /// Sends a [UserActionEvent] to [onSubmit] as a [ChatMessage]. No-op for + /// non-action [UiEvent]s. void handleUiEvent(UiEvent event) { if (event is! UserActionEvent) return; _onSubmit.add( diff --git a/packages/genui/lib/src/engine/surface_registry.dart b/packages/genui/lib/src/engine/surface_registry.dart index 6d51d83b6..7c62b84b1 100644 --- a/packages/genui/lib/src/engine/surface_registry.dart +++ b/packages/genui/lib/src/engine/surface_registry.dart @@ -62,8 +62,8 @@ class SurfaceRegistry { ); } - /// Returns a [ValueListenable] that tracks the source-compatible snapshot - /// definition for the given [surfaceId]. + /// Returns a [ValueListenable] tracking the + /// [genui_model.SurfaceDefinition] snapshot for [surfaceId]. ValueListenable watchDefinition( String surfaceId, ) { @@ -111,15 +111,10 @@ class SurfaceRegistry { /// Removes a surface from the registry, emitting a [SurfaceRemoved] event. /// - /// The per-id [ValueNotifier] is kept (not removed from the map, not - /// disposed) so that any widget already listening to it stays connected. - /// If a surface with the same id is later created, the existing notifier's - /// value is updated and the widget gets notified — without this, the - /// widget would be stranded on a dead notifier. - /// - /// The [SurfaceModel] itself is owned by the substrate's - /// `core.SurfaceGroupModel` and disposed there; this registry only nulls - /// out the notifier value. + /// The per-id [ValueNotifier] is intentionally retained so widgets already + /// listening stay connected; a later re-create of the same id updates the + /// existing notifier. The [SurfaceModel] is owned and disposed by the + /// substrate's `core.SurfaceGroupModel`. void removeSurface(String surfaceId) { final ValueNotifier?>? notifier = _surfaces[surfaceId]; diff --git a/packages/genui/lib/src/interfaces/surface_context.dart b/packages/genui/lib/src/interfaces/surface_context.dart index 0ef549e12..19ca09411 100644 --- a/packages/genui/lib/src/interfaces/surface_context.dart +++ b/packages/genui/lib/src/interfaces/surface_context.dart @@ -10,9 +10,6 @@ import '../model/data_model.dart'; import '../model/ui_models.dart'; /// An interface for a specific UI surface context. -/// -/// This provides access to the source-compatible surface snapshot and data -/// model facade for a single surface. abstract interface class SurfaceContext { /// The ID of the surface this context is bound to. String get surfaceId; @@ -33,13 +30,11 @@ abstract interface class SurfaceContext { void reportError(Object error, StackTrace? stack); } -/// Internal live-surface extension used by GenUI's own controller/widget pair. -/// -/// External/custom [SurfaceContext] implementations only need the legacy -/// [SurfaceContext.definition] API; when this live interface is available the -/// renderer can subscribe to per-component core updates for granular rebuilds. +/// GenUI-internal extension of [SurfaceContext] that exposes the live core +/// surface model so the renderer can subscribe to per-component updates for +/// granular rebuilds. External implementations only need to satisfy +/// [SurfaceContext]. @internal abstract interface class LiveSurfaceContext implements SurfaceContext { - /// The live core surface model for this surface. ValueListenable get surface; } diff --git a/packages/genui/lib/src/model/a2ui_message.dart b/packages/genui/lib/src/model/a2ui_message.dart index 224f12d3b..96b715bfc 100644 --- a/packages/genui/lib/src/model/a2ui_message.dart +++ b/packages/genui/lib/src/model/a2ui_message.dart @@ -19,7 +19,6 @@ import 'ui_models.dart'; abstract class A2uiMessage { const A2uiMessage({this.version = 'v0.9'}); - /// Creates an [A2uiMessage] from a JSON envelope. factory A2uiMessage.fromJson(JsonMap json) { try { return A2uiMessage.fromCore( @@ -63,7 +62,6 @@ abstract class A2uiMessage { /// Converts this facade message to the core substrate message. core.A2uiMessage toCoreMessage(); - /// Converts this message to a JSON envelope. Map toJson() => toCoreMessage().toJson(); } @@ -115,7 +113,6 @@ Schema _buildA2uiMessageSchema(Catalog catalog) { /// An A2UI message that signals the client to create and show a new surface. final class CreateSurface extends A2uiMessage { - /// Creates a [CreateSurface] message. const CreateSurface({ super.version, required this.surfaceId, @@ -134,7 +131,6 @@ final class CreateSurface extends A2uiMessage { ); } - /// Creates a facade from a core message. factory CreateSurface.fromCore(core.CreateSurfaceMessage message) { return CreateSurface( version: message.version, @@ -171,7 +167,6 @@ final class CreateSurface extends A2uiMessage { /// An A2UI message that updates a surface with new components. final class UpdateComponents extends A2uiMessage { - /// Creates an [UpdateComponents] message. const UpdateComponents({ super.version, required this.surfaceId, @@ -188,7 +183,6 @@ final class UpdateComponents extends A2uiMessage { ); } - /// Creates a facade from a core message. factory UpdateComponents.fromCore(core.UpdateComponentsMessage message) { return UpdateComponents( version: message.version, @@ -249,7 +243,6 @@ final class UpdateDataModel extends A2uiMessage { ); } - /// Creates a facade from a core message. factory UpdateDataModel.fromCore(core.UpdateDataModelMessage message) { final path = DataPath(message.path ?? '/'); if (message.hasValue) { @@ -276,7 +269,7 @@ final class UpdateDataModel extends A2uiMessage { /// The new value to write to the data model. final Object? value; - /// Whether the wire envelope carries an explicit `value` key. + /// Whether the wire JSON carries an explicit `value` key. final bool hasValue; @override @@ -299,7 +292,6 @@ final class UpdateDataModel extends A2uiMessage { /// An A2UI message that deletes a surface. final class DeleteSurface extends A2uiMessage { - /// Creates a [DeleteSurface] message. const DeleteSurface({super.version, required this.surfaceId}); /// Creates a [DeleteSurface] message from a JSON map body. @@ -307,7 +299,6 @@ final class DeleteSurface extends A2uiMessage { return DeleteSurface(surfaceId: json[surfaceIdKey] as String); } - /// Creates a facade from a core message. factory DeleteSurface.fromCore(core.DeleteSurfaceMessage message) { return DeleteSurface( version: message.version, diff --git a/packages/genui/lib/src/model/catalog.dart b/packages/genui/lib/src/model/catalog.dart index 1c9fcf3c5..afee37062 100644 --- a/packages/genui/lib/src/model/catalog.dart +++ b/packages/genui/lib/src/model/catalog.dart @@ -136,8 +136,8 @@ interface class Catalog { } genUiLogger.info('Building widget ${item.name} with id ${itemContext.id}'); - // Per-id identity is provided by the Surface's _ComponentBuilder wrapper, - // so no KeyedSubtree is needed here. + // No KeyedSubtree: per-id identity comes from the Surface widget's + // _ComponentBuilder wrapper. return item.widgetBuilder( itemContext.withOverrides( buildChild: (String childId, [DataContext? childDataContext]) => @@ -254,7 +254,6 @@ class CatalogItemNotFoundException implements Exception { } } -/// Adapter exposing a [CatalogItem] as a substrate [core.ComponentApi]. class _CatalogItemComponentApi implements core.ComponentApi { _CatalogItemComponentApi(this._item); final CatalogItem _item; @@ -266,13 +265,11 @@ class _CatalogItemComponentApi implements core.ComponentApi { Schema get schema => _item.dataSchema; } -/// Extension on [Catalog] that builds the substrate-facing -/// [core.Catalog] view of the genui catalog. +/// Substrate-facing [core.Catalog] view of a genui [Catalog]. extension CatalogCoreView on Catalog { - /// Returns a [core.Catalog] populated from this catalog's items, with each - /// [CatalogItem] adapted into a [core.ComponentApi]. Used when constructing - /// a [core.SurfaceModel] so the surface has real component metadata - /// (instead of an empty stub) for substrate-side lookups. + /// Returns a [core.Catalog] populated from this catalog's items, used when + /// constructing a [core.SurfaceModel] so substrate-side lookups see real + /// component metadata instead of an empty stub. core.Catalog get coreCatalog => core.Catalog( id: catalogId ?? 'genui_inline_$hashCode', diff --git a/packages/genui/lib/src/model/catalog_item.dart b/packages/genui/lib/src/model/catalog_item.dart index c0ae4efa6..07a993043 100644 --- a/packages/genui/lib/src/model/catalog_item.dart +++ b/packages/genui/lib/src/model/catalog_item.dart @@ -29,16 +29,12 @@ typedef CatalogWidgetBuilder = Widget Function(CatalogItemContext itemContext); /// Context provided to a [CatalogItem]'s widget builder. /// -/// Internally backed by the substrate's [core.ComponentContext], plus -/// Flutter-specific extras (build context, dispatch callbacks, error -/// reporting). The public GenUI authoring API remains the shim getters and -/// callbacks on this class: [id], [type], [data], [surfaceId], -/// [getComponent], [dataContext], [buildChild], and related callbacks. +/// Backed by a substrate [core.ComponentContext] plus Flutter-specific +/// extras: [buildContext], [buildChild], [dispatchEvent], [dataContext], +/// [reportError]. final class CatalogItemContext { - /// Creates a [CatalogItemContext] from the legacy public GenUI fields. - /// - /// Internally this builds a small core [core.ComponentContext] so widget - /// authoring stays source-compatible while the renderer substrate is core. + /// Creates a [CatalogItemContext] from raw fields. Synthesizes a + /// stand-alone substrate context internally. CatalogItemContext({ required String id, required String type, @@ -60,10 +56,7 @@ final class CatalogItemContext { _getComponentOverride = getComponent; /// Creates a [CatalogItemContext] from a substrate [core.ComponentContext]. - /// - /// This constructor is for GenUI renderer internals only. Catalog authors - /// should receive instances from the renderer; tests that need to construct - /// one manually can use the public constructor or + /// Renderer-internal; tests should use the public constructor or /// [CatalogItemContext.forTesting]. @internal CatalogItemContext.fromCore({ @@ -77,9 +70,8 @@ final class CatalogItemContext { }) : _componentContext = componentContext, _getComponentOverride = null; - /// Test-only convenience constructor that builds a stand-alone - /// [core.SurfaceModel] + [core.ComponentContext] from raw fields, avoiding - /// the need for tests to wire up a full surface. + /// Test-only convenience: builds a stand-alone substrate context so tests + /// don't need to wire up a full surface. @visibleForTesting factory CatalogItemContext.forTesting({ required String id, @@ -108,8 +100,6 @@ final class CatalogItemContext { ); } - /// The wrapped substrate context. Source of truth for component identity, - /// raw properties, and access to other components on the surface. final core.ComponentContext _componentContext; final GetComponentCallback? _getComponentOverride; @@ -123,8 +113,8 @@ final class CatalogItemContext { /// The Flutter [BuildContext] for this widget. final BuildContext buildContext; - /// The Flutter-side [DataContext] for accessing the data model, dispatching - /// catalog functions, and subscribing to dynamic-value streams. + /// The [DataContext] for accessing the data model, dispatching catalog + /// functions, and subscribing to dynamic-value streams. final DataContext dataContext; /// Callback to retrieve a catalog item definition by its type name. @@ -145,8 +135,8 @@ final class CatalogItemContext { }) : _componentContext = componentContext, _getComponentOverride = getComponentOverride; - /// Returns a copy of this context with selected Flutter-side callbacks - /// replaced while preserving the same private substrate context. + /// Returns a copy of this context with selected callbacks replaced. The + /// substrate context is preserved. @internal CatalogItemContext withOverrides({ ChildBuilderCallback? buildChild, diff --git a/packages/genui/lib/src/model/data_model.dart b/packages/genui/lib/src/model/data_model.dart index 9d1a548ea..70f622bfb 100644 --- a/packages/genui/lib/src/model/data_model.dart +++ b/packages/genui/lib/src/model/data_model.dart @@ -71,11 +71,8 @@ abstract interface class DataModel { T? getValue(DataPath absolutePath); } -/// Standard in-memory implementation of [DataModel]. -/// -/// This is a source-compatible facade over `a2ui_core.DataModel`; it keeps the -/// legacy GenUI API shape while delegating storage and reactivity to the core -/// substrate. +/// Standard in-memory implementation of [DataModel]. Facade over +/// `a2ui_core.DataModel`. class InMemoryDataModel implements DataModel { /// Creates an empty in-memory data model. InMemoryDataModel() : _core = core.DataModel(), _ownsCore = true; @@ -110,7 +107,7 @@ class InMemoryDataModel implements DataModel { required ValueListenable source, bool twoWay = false, }) { - final VoidCallback cleanup = bindExternalStateForDataModel( + final VoidCallback cleanup = _bindExternalState( dataModel: this, path: path, source: source, @@ -327,11 +324,7 @@ Future resolveContext( return resolved; } -/// Binds an external state [source] to a [path] in a GenUI [DataModel]. -/// -/// Kept as a top-level helper for branch-internal code; the legacy public API -/// is [DataModel.bindExternalState]. -VoidCallback bindExternalStateForDataModel({ +VoidCallback _bindExternalState({ required DataModel dataModel, required DataPath path, required ValueListenable source, @@ -383,22 +376,6 @@ VoidCallback bindExternalStateForDataModel({ }; } -/// Compatibility alias for branch-local callers that used the temporary -/// top-level helper name. -VoidCallback bindExternalState({ - required DataModel dataModel, - required Object path, - required ValueListenable source, - bool twoWay = false, -}) { - return bindExternalStateForDataModel( - dataModel: dataModel, - path: _toDataPath(path), - source: source, - twoWay: twoWay, - ); -} - /// Bridges a preact_signals [core.ReadonlySignal] to a Flutter /// [ValueNotifier]. class _SignalNotifier extends ValueNotifier { diff --git a/packages/genui/lib/src/model/schema_validation.dart b/packages/genui/lib/src/model/schema_validation.dart index eb022dd67..1ce84b05a 100644 --- a/packages/genui/lib/src/model/schema_validation.dart +++ b/packages/genui/lib/src/model/schema_validation.dart @@ -11,14 +11,8 @@ import 'ui_models.dart' show A2uiValidationException; /// Validates a set of A2UI components against a catalog [schema]. /// -/// Shared between [SurfaceDefinition.validate] (legacy snapshot path) and -/// `SurfaceController`'s post-mutation validation (live model path). Each -/// caller adapts its component representation into the [components] iterable -/// and passes the same catalog schema; the schema-walking and matching logic -/// lives here in one place. -/// -/// Throws [A2uiValidationException] on the first component that fails. Does -/// not roll back any state — that's the caller's concern. +/// Throws [A2uiValidationException] on the first component that fails. State +/// is not rolled back. void validateComponents({ required String surfaceId, required Iterable<({String id, String type, JsonMap json})> components, diff --git a/packages/genui/lib/src/model/ui_models.dart b/packages/genui/lib/src/model/ui_models.dart index 2eb8a6906..b871cf94b 100644 --- a/packages/genui/lib/src/model/ui_models.dart +++ b/packages/genui/lib/src/model/ui_models.dart @@ -43,7 +43,7 @@ extension type UiEvent.fromMap(JsonMap _json) { JsonMap toMap() => _json; } -/// A UI event that represents a user action — triggers a submission to the AI. +/// A UI event that represents a user action; triggers a submission to the AI. extension type UserActionEvent.fromMap(JsonMap _json) implements UiEvent { /// Creates a [UserActionEvent] from a set of properties. UserActionEvent({ @@ -78,9 +78,8 @@ final class _Json { /// A data object that represents the entire UI definition. /// -/// This is a legacy GenUI snapshot facade. Live mutation is owned by -/// `a2ui_core.SurfaceModel`; snapshots are materialized for public API -/// compatibility. +/// Snapshot facade kept for public-API compatibility; mutation is owned by +/// `a2ui_core.SurfaceModel`. class SurfaceDefinition { /// Creates a [SurfaceDefinition]. SurfaceDefinition({ @@ -277,8 +276,8 @@ final class SurfaceAdded extends SurfaceUpdate { /// The live core surface model. final core.SurfaceModel surface; - /// Snapshot facade for source-compatible public API. Materialized lazily - /// on first read; lifecycle-only listeners don't pay the snapshot cost. + /// Snapshot facade. Materialized lazily so lifecycle-only listeners don't + /// pay the snapshot cost. late final SurfaceDefinition definition = SurfaceDefinition.fromCore(surface); } @@ -289,8 +288,8 @@ final class ComponentsUpdated extends SurfaceUpdate { /// The live core surface model. final core.SurfaceModel surface; - /// Snapshot facade for source-compatible public API. Materialized lazily - /// on first read; lifecycle-only listeners don't pay the snapshot cost. + /// Snapshot facade. Materialized lazily so lifecycle-only listeners don't + /// pay the snapshot cost. late final SurfaceDefinition definition = SurfaceDefinition.fromCore(surface); } diff --git a/packages/genui/lib/src/transport/a2ui_parser_transformer.dart b/packages/genui/lib/src/transport/a2ui_parser_transformer.dart index cfde166af..7a96792f2 100644 --- a/packages/genui/lib/src/transport/a2ui_parser_transformer.dart +++ b/packages/genui/lib/src/transport/a2ui_parser_transformer.dart @@ -181,13 +181,11 @@ class _A2uiParserStream { } } - /// JSON keys that mark a payload as intended to be an A2UI message envelope. - /// If parsing fails on a payload that looks like an envelope, treat the - /// failure as a validation error rather than fall back to emitting plain - /// text. `version` is included because a payload that carries `version` - /// is clearly an attempted envelope even if its action key is unknown or - /// malformed. - static const _a2uiEnvelopeKeys = { + /// Top-level keys that mark a JSON payload as an attempted A2UI message. + /// If parsing fails on one of these, surface a validation error rather + /// than fall back to plain text. `version` is included so a + /// malformed-but-versioned payload still counts as an attempted message. + static const _a2uiMessageKeys = { 'version', 'createSurface', 'updateComponents', @@ -195,8 +193,8 @@ class _A2uiParserStream { 'deleteSurface', }; - bool _looksLikeA2uiEnvelope(Map json) => - json.keys.any(_a2uiEnvelopeKeys.contains); + bool _looksLikeA2uiMessage(Map json) => + json.keys.any(_a2uiMessageKeys.contains); void _emitMessage(Object json) { if (json is Map) { @@ -215,7 +213,7 @@ class _A2uiParserStream { _controller.add(A2uiMessageEvent(A2uiMessage.fromJson(json))); _wasLastEventA2ui = true; } catch (e) { - if (_looksLikeA2uiEnvelope(json)) { + if (_looksLikeA2uiMessage(json)) { _controller.addError( A2uiValidationException( 'Failed to parse A2UI message', @@ -224,7 +222,7 @@ class _A2uiParserStream { ), ); } else { - // Not an A2UI envelope — emit as plain text. + // Not an A2UI message; emit as plain text. _controller.add(TextEvent(jsonEncode(json))); } _wasLastEventA2ui = false; diff --git a/packages/genui/lib/src/widgets/surface.dart b/packages/genui/lib/src/widgets/surface.dart index f7206c785..5c971c977 100644 --- a/packages/genui/lib/src/widgets/surface.dart +++ b/packages/genui/lib/src/widgets/surface.dart @@ -20,10 +20,10 @@ typedef UiEventCallback = void Function(UiEvent event); /// A widget that renders a dynamic UI surface generated by the AI. /// -/// Publicly this still consumes the legacy [SurfaceContext.definition] API. -/// When the context is GenUI's own internal [LiveSurfaceContext], the renderer -/// additionally uses the live core surface model for per-component granular -/// rebuilds. +/// Drives off [SurfaceContext.definition] by default; when the context also +/// implements [LiveSurfaceContext] (GenUI's own controller does), the renderer +/// subscribes to per-component updates from the live core surface model for +/// granular rebuilds. class Surface extends StatefulWidget { /// Creates a [Surface]. const Surface({ @@ -229,7 +229,6 @@ class _SurfaceState extends State { ); } - /// Recursively builds a widget from the live core model. Widget _buildLiveWidget( core.SurfaceModel surface, Catalog catalog, @@ -286,7 +285,6 @@ class _SurfaceState extends State { ); } - /// Recursively builds a widget from the legacy snapshot model. Widget _buildWidgetFromDefinition( SurfaceDefinition definition, Catalog catalog, diff --git a/packages/genui/lib/test/validation.dart b/packages/genui/lib/test/validation.dart index c2392456a..4769c8402 100644 --- a/packages/genui/lib/test/validation.dart +++ b/packages/genui/lib/test/validation.dart @@ -81,9 +81,9 @@ Future> validateCatalogItemExamples( components: components, ); - // `a2ui_core.UpdateComponentsMessage.toJson()` wraps the body in an - // outer envelope (`{'version': ..., 'updateComponents': {...}}`); the - // schema here validates the body shape only. + // `a2ui_core.UpdateComponentsMessage.toJson()` produces + // `{'version': ..., 'updateComponents': {...}}`; the schema here + // validates the body shape only. final List validationErrors = await schema.validate( surfaceUpdate.toJson()['updateComponents'], ); From af0bb836ef3c62be60f93c41ae32fdac1d327344 Mon Sep 17 00:00:00 2001 From: Andrew Kolos Date: Thu, 28 May 2026 11:34:43 -0700 Subject: [PATCH 07/49] refactor(genui): preserve pre-create dataModel access; reduce core leakage Pre-create writes to `SurfaceContext.dataModel` were lost when the live surface arrived (the fallback `InMemoryDataModel` stayed shadowed by the new live wrapper). `DataModelStore` now exposes `attachLive(id, model)`, called from `_onCoreSurfaceCreated`, which snapshots any fallback data into the live core model and disposes the fallback. `_ControllerContext.dataModel` is routed through the store so post-create re-fetches see the migrated data. Marks `SurfaceRegistry`, `SurfaceAdded.surface`, and `ComponentsUpdated.surface` `@internal` so the public consumer surface stays GenUI-typed. Updates the migration guide to call out the small set of integrator-facing APIs that still expose `a2ui_core.SurfaceModel`. Adds tests covering pre-create dataModel access, fallback->live data migration on createSurface, and facade-level UpdateDataModel toCoreMessage/fromCore round-trip preservation of the explicit-null vs omitted distinction. Adds CHANGELOG entries to both packages. --- .../migration_genui_onto_a2ui_core.md | 11 ++++++-- packages/a2ui_core/CHANGELOG.md | 13 ++++++++++ packages/genui/CHANGELOG.md | 20 ++++++++++++++ packages/genui/lib/genui.dart | 2 +- .../lib/src/engine/data_model_store.dart | 25 ++++++++++++++---- .../lib/src/engine/surface_controller.dart | 13 ++-------- .../lib/src/engine/surface_registry.dart | 1 + packages/genui/lib/src/model/ui_models.dart | 9 +++++-- .../test/engine/surface_controller_test.dart | 26 +++++++++++++++++++ .../genui/test/model/a2ui_message_test.dart | 26 +++++++++++++++++++ 10 files changed, 125 insertions(+), 21 deletions(-) diff --git a/docs/usage/migration/migration_genui_onto_a2ui_core.md b/docs/usage/migration/migration_genui_onto_a2ui_core.md index d250f2c55..6f7d9f14e 100644 --- a/docs/usage/migration/migration_genui_onto_a2ui_core.md +++ b/docs/usage/migration/migration_genui_onto_a2ui_core.md @@ -25,8 +25,15 @@ GenUI applications and catalog authors should continue to use the existing and `reportError`. - `UiPart.create(definition: SurfaceDefinition(...))`. -You should **not** need to add `a2ui_core` to application/example packages just -to consume GenUI. `package:genui` does not re-export raw `a2ui_core` symbols. +Most consumers should **not** need to add `a2ui_core` to application or example +packages. Catalog widget authors and apps that use the existing facade API stay +on GenUI types. + +The exception is integrators reaching for the live surface model. A few APIs +that exist for GenUI-internal use cross the boundary and expose +`a2ui_core.SurfaceModel`: `SurfaceAdded.surface`, `ComponentsUpdated.surface`, +and `SurfaceRegistry`. These are marked `@internal`; use the `definition` +snapshot fields instead unless you specifically need live core access. ## What changed internally diff --git a/packages/a2ui_core/CHANGELOG.md b/packages/a2ui_core/CHANGELOG.md index 4b51ff99e..6efaa5a07 100644 --- a/packages/a2ui_core/CHANGELOG.md +++ b/packages/a2ui_core/CHANGELOG.md @@ -2,4 +2,17 @@ ## 0.0.1-wip002 +- **Feature**: `UpdateDataModelMessage` carries a `hasValue` field so + parsers can distinguish `value: null` (set to null) from an absent + `value` key (remove the key) per the v0.9 protocol. Runtime mutation + still collapses both to "remove" pending flutter/genui#938. +- **Feature**: Re-export preact_signals `effect` and `Effect`. +- **Fix**: `DataModel.set` deep-copies map/list payloads so later nested + writes work even when callers pass const literals. +- **Behavior**: `DataPath` no longer interprets RFC 6901 `~0`/`~1` + escapes; paths split on `/` only, matching the TypeScript reference + implementation (see A2UI#1499 tracking spec clarification). + +## 0.0.1-dev002 + - Initial version. diff --git a/packages/genui/CHANGELOG.md b/packages/genui/CHANGELOG.md index 0ce448fd6..b7e0e633f 100644 --- a/packages/genui/CHANGELOG.md +++ b/packages/genui/CHANGELOG.md @@ -1,7 +1,27 @@ # `genui` Changelog +## 0.10.0 (in progress) + +- **Refactor**: Migrate the runtime substrate onto `package:a2ui_core`. + Public GenUI types are preserved as compatibility facades; see + [docs/usage/migration/migration_genui_onto_a2ui_core.md](../../docs/usage/migration/migration_genui_onto_a2ui_core.md). +- **Behavior**: `DataModel` writes are stricter (core data errors on + type-mismatched intermediate paths and excessively large list indices) + and sparse list writes now fill skipped entries with `null` instead of + silently dropping them. +- **Behavior**: A duplicate `createSurface` for an already-active surface + id is now an error. +- **Behavior**: JSON Pointer `~0`/`~1` escapes are not interpreted on + `DataPath`; paths split on `/`, matching the web reference implementation + (see A2UI#1499 tracking spec clarification). +- **Internal**: `SurfaceRegistry` and the live `core.SurfaceModel` fields + on `SurfaceAdded` / `ComponentsUpdated` are marked `@internal`. Most + consumers should read `SurfaceUpdate.definition` instead. + ## 0.9.2 +## 0.9.1 + - **Feature**: Updated example/README.md. ## 0.9.0 diff --git a/packages/genui/lib/genui.dart b/packages/genui/lib/genui.dart index 390e65253..a5edd03d4 100644 --- a/packages/genui/lib/genui.dart +++ b/packages/genui/lib/genui.dart @@ -13,7 +13,7 @@ library; export 'src/catalog.dart'; export 'src/development_utilities.dart'; -export 'src/engine.dart' hide SurfaceAdded, SurfaceRemoved; +export 'src/engine.dart' hide SurfaceAdded, SurfaceRegistry, SurfaceRemoved; export 'src/facade.dart'; export 'src/functions.dart'; export 'src/interfaces.dart'; diff --git a/packages/genui/lib/src/engine/data_model_store.dart b/packages/genui/lib/src/engine/data_model_store.dart index eaf34632a..8525030be 100644 --- a/packages/genui/lib/src/engine/data_model_store.dart +++ b/packages/genui/lib/src/engine/data_model_store.dart @@ -8,11 +8,11 @@ import '../model/data_model.dart'; /// `a2ui_core.SurfaceGroupModel`. /// /// Kept to preserve the legacy `SurfaceController.store` / -/// `store.getDataModel(surfaceId)` API. The lookup callback (provided by -/// `SurfaceController`) redirects active surfaces to the substrate's live -/// `surface.dataModel`; ids without a live surface fall back to a standalone -/// in-memory model, preserving the pre-migration "data survives before -/// createSurface" behavior. +/// `store.getDataModel(surfaceId)` API. Pre-`createSurface`, returns a +/// standalone in-memory model; when `SurfaceController` later attaches a +/// live surface via [attachLive], any data written to the standalone model +/// is migrated into the live one and future [getDataModel] calls return the +/// live wrapper. /// /// Slated for removal alongside the rest of the GenUI->a2ui_core facade /// renames. New code should read from `SurfaceController.registry @@ -37,6 +37,21 @@ class DataModelStore { return _dataModels.putIfAbsent(surfaceId, InMemoryDataModel.new); } + /// Caches [liveModel] for [surfaceId] and migrates any pre-create + /// fallback data into it. Callers that had a reference to the fallback + /// model must refetch via [getDataModel]. + void attachLive(String surfaceId, DataModel liveModel) { + final DataModel? fallback = _dataModels.remove(surfaceId); + if (fallback != null) { + final Object? snapshot = fallback.getValue(DataPath.root); + if (snapshot != null) { + liveModel.update(DataPath.root, snapshot); + } + fallback.dispose(); + } + _liveDataModels[surfaceId] = liveModel; + } + /// Removes the data model for the given [surfaceId] and detaches the surface. void removeDataModel(String surfaceId) { final DataModel? model = _dataModels.remove(surfaceId); diff --git a/packages/genui/lib/src/engine/surface_controller.dart b/packages/genui/lib/src/engine/surface_controller.dart index 3743a0bc6..d7b81c719 100644 --- a/packages/genui/lib/src/engine/surface_controller.dart +++ b/packages/genui/lib/src/engine/surface_controller.dart @@ -227,6 +227,7 @@ interface class SurfaceController implements SurfaceHost, A2uiMessageSink { void _onCoreSurfaceCreated(core.SurfaceModel surface) { _registry.addSurface(surface); + _store.attachLive(surface.id, InMemoryDataModel.wrap(surface.dataModel)); final List? pending = _pendingUpdates.remove(surface.id); _pendingUpdateTimers.remove(surface.id)?.cancel(); if (pending != null) { @@ -358,17 +359,7 @@ class _ControllerContext implements LiveSurfaceContext { _controller.registry.watchDefinition(surfaceId); @override - DataModel get dataModel { - final core.SurfaceModel? s = _controller.registry.getSurface(surfaceId); - if (s == null) { - throw StateError( - 'SurfaceContext.dataModel accessed for surface "$surfaceId" ' - 'before the surface was created. Guard on `definition.value != null` ' - 'or wait for a SurfaceAdded event before reading the data model.', - ); - } - return InMemoryDataModel.wrap(s.dataModel); - } + DataModel get dataModel => _controller.store.getDataModel(surfaceId); @override Catalog? get catalog => _controller._findCatalogForSurface(surfaceId); diff --git a/packages/genui/lib/src/engine/surface_registry.dart b/packages/genui/lib/src/engine/surface_registry.dart index 7c62b84b1..394804efe 100644 --- a/packages/genui/lib/src/engine/surface_registry.dart +++ b/packages/genui/lib/src/engine/surface_registry.dart @@ -35,6 +35,7 @@ class SurfaceUpdated extends RegistryEvent { /// Tracks live [SurfaceModel]s by surface ID and exposes Flutter-friendly /// [ValueListenable]s for them, plus a registry-event stream. +@internal class SurfaceRegistry { final Map> _surfaces = {}; final Map> diff --git a/packages/genui/lib/src/model/ui_models.dart b/packages/genui/lib/src/model/ui_models.dart index b871cf94b..6459a1d30 100644 --- a/packages/genui/lib/src/model/ui_models.dart +++ b/packages/genui/lib/src/model/ui_models.dart @@ -7,6 +7,7 @@ import 'dart:convert'; import 'package:a2ui_core/a2ui_core.dart' as core; import 'package:collection/collection.dart'; import 'package:json_schema_builder/json_schema_builder.dart'; +import 'package:meta/meta.dart' show internal; import '../primitives/constants.dart'; import '../primitives/simple_items.dart'; @@ -273,7 +274,9 @@ sealed class SurfaceUpdate { final class SurfaceAdded extends SurfaceUpdate { SurfaceAdded(super.surfaceId, this.surface); - /// The live core surface model. + /// Live `a2ui_core` surface model. Intended for GenUI internals; most + /// consumers should read [definition] instead. + @internal final core.SurfaceModel surface; /// Snapshot facade. Materialized lazily so lifecycle-only listeners don't @@ -285,7 +288,9 @@ final class SurfaceAdded extends SurfaceUpdate { final class ComponentsUpdated extends SurfaceUpdate { ComponentsUpdated(super.surfaceId, this.surface); - /// The live core surface model. + /// Live `a2ui_core` surface model. Intended for GenUI internals; most + /// consumers should read [definition] instead. + @internal final core.SurfaceModel surface; /// Snapshot facade. Materialized lazily so lifecycle-only listeners don't diff --git a/packages/genui/test/engine/surface_controller_test.dart b/packages/genui/test/engine/surface_controller_test.dart index f7c7b3f01..1e1f08bd1 100644 --- a/packages/genui/test/engine/surface_controller_test.dart +++ b/packages/genui/test/engine/surface_controller_test.dart @@ -181,6 +181,32 @@ void main() { expect(model.getValue(DataPath('/name')), 'Bob'); }); + test('contextFor(surfaceId).dataModel returns a writable model ' + 'before createSurface', () { + const surfaceId = 'pre_create'; + final DataModel model = controller.contextFor(surfaceId).dataModel; + expect(() => model.update(DataPath('/foo'), 'bar'), returnsNormally); + expect(model.getValue(DataPath('/foo')), 'bar'); + }); + + test('pre-create data written via contextFor is migrated into the live ' + 'surface model when createSurface arrives', () async { + const surfaceId = 'migrate_me'; + controller + .contextFor(surfaceId) + .dataModel + .update(DataPath('/name'), 'Alice'); + + controller.handleMessage( + const CreateSurface(surfaceId: surfaceId, catalogId: 'test_catalog'), + ); + + // Post-create, contextFor.dataModel routes through the live core + // surface model, so reading back here proves the migration landed. + final DataModel model = controller.contextFor(surfaceId).dataModel; + expect(model.getValue(DataPath('/name')), 'Alice'); + }); + test('dispose() closes the updates stream', () async { var isClosed = false; controller.surfaceUpdates.listen( diff --git a/packages/genui/test/model/a2ui_message_test.dart b/packages/genui/test/model/a2ui_message_test.dart index ad4c7dff6..b59ffbcdb 100644 --- a/packages/genui/test/model/a2ui_message_test.dart +++ b/packages/genui/test/model/a2ui_message_test.dart @@ -143,6 +143,32 @@ void main() { expect(reparsed.hasValue, isFalse); }); + test( + 'UpdateDataModel.toCoreMessage/fromCore preserves explicit null value', + () { + final original = UpdateDataModel(surfaceId: 's1', path: DataPath('/x')); + expect(original.hasValue, isTrue); + expect(original.value, isNull); + + final roundtripped = UpdateDataModel.fromCore(original.toCoreMessage()); + expect(roundtripped.hasValue, isTrue); + expect(roundtripped.value, isNull); + expect(roundtripped.path, DataPath('/x')); + }, + ); + + test('UpdateDataModel.toCoreMessage/fromCore preserves omitted value', () { + final original = UpdateDataModel.removeKey( + surfaceId: 's1', + path: DataPath('/x'), + ); + expect(original.hasValue, isFalse); + + final roundtripped = UpdateDataModel.fromCore(original.toCoreMessage()); + expect(roundtripped.hasValue, isFalse); + expect(roundtripped.path, DataPath('/x')); + }); + test('DeleteSurface.toJson includes version', () { const message = DeleteSurface(surfaceId: 's1'); expect(message.toJson(), containsPair('version', 'v0.9')); From 3758fe3e8be2f8ec7573cc30bb78f515970dbc97 Mon Sep 17 00:00:00 2001 From: Andrew Kolos Date: Thu, 28 May 2026 11:54:26 -0700 Subject: [PATCH 08/49] refactor(genui): address reviewer pass on substrate migration - _onCoreSurfaceCreated: migrate fallback data via store.attachLive BEFORE registry.addSurface notifies listeners, so a synchronous ValueListener callback that calls contextFor.dataModel sees the populated live model rather than racing the migration. - DataModelStore.getDataModel: check the _liveDataModels cache before invoking the lookup callback. Avoids redundant wrap calls and avoids the race where a getDataModel call cached one wrapper just before attachLive cached another. - DataModelStore.attachLive: drop the null-snapshot guard so a pre-create `update(root, null)` is preserved across createSurface. Adds a test. - Revert the genui.dart hide of SurfaceRegistry. SurfaceRegistry existed as a public type pre-migration; hiding it would be an unrelated source break. The new core-typed `surface` fields on SurfaceAdded / SurfaceUpdated remain `@internal`. - Migration guide: add an explicit Scope section listing the deferred follow-ups (catalog widget bodies, action dispatch, sendDataModel, GenericBinder, typed-props), the #938 dependency for the hasValue/remove runtime split, and the A2UI#1499 RFC 6901 tracking. --- .../migration_genui_onto_a2ui_core.md | 20 +++++++++++++++++++ packages/genui/lib/genui.dart | 2 +- .../lib/src/engine/data_model_store.dart | 12 ++++++----- .../lib/src/engine/surface_controller.dart | 6 +++++- .../lib/src/engine/surface_registry.dart | 11 +++++++++- .../test/engine/surface_controller_test.dart | 12 +++++++++++ 6 files changed, 55 insertions(+), 8 deletions(-) diff --git a/docs/usage/migration/migration_genui_onto_a2ui_core.md b/docs/usage/migration/migration_genui_onto_a2ui_core.md index 6f7d9f14e..4c41d8d89 100644 --- a/docs/usage/migration/migration_genui_onto_a2ui_core.md +++ b/docs/usage/migration/migration_genui_onto_a2ui_core.md @@ -8,6 +8,26 @@ The goal is to make this PR about the substrate swap (#811): shared protocol parsing, shared surface/data-model state, and granular Flutter rebuilds. Public API renames for closer web/core parity are deferred to a follow-up PR (#801). +## Scope + +Substrate / state migration only. Deferred to follow-ups: + +- Catalog widget bodies stay on `CatalogItemContext`. A typed-props authoring + API (flutter/genui#801) is on hold until the upstream Node Layer + (A2UI#1282) settles. +- Action dispatch and `sendDataModel` synchronization still flow through the + existing GenUI path, not `core.SurfaceGroupModel.onAction` or + `MessageProcessor.getClientDataModel()`. +- `GenericBinder` is not exposed as a Flutter-side public API. +- `UpdateDataModelMessage.hasValue` is parsed and serialized losslessly + through the genui facade (with round-trip test coverage), but runtime + mutation still treats both `value: null` and an omitted `value` as "remove + the key" pending flutter/genui#938 (adds `DataModel.remove` and the + processor-level branch). +- `DataPath` no longer interprets RFC 6901 `~0` / `~1` escapes, matching the + TypeScript reference implementation. A2UI#1499 tracks the spec + clarification. + ## What stays source-compatible in this PR GenUI applications and catalog authors should continue to use the existing diff --git a/packages/genui/lib/genui.dart b/packages/genui/lib/genui.dart index a5edd03d4..390e65253 100644 --- a/packages/genui/lib/genui.dart +++ b/packages/genui/lib/genui.dart @@ -13,7 +13,7 @@ library; export 'src/catalog.dart'; export 'src/development_utilities.dart'; -export 'src/engine.dart' hide SurfaceAdded, SurfaceRegistry, SurfaceRemoved; +export 'src/engine.dart' hide SurfaceAdded, SurfaceRemoved; export 'src/facade.dart'; export 'src/functions.dart'; export 'src/interfaces.dart'; diff --git a/packages/genui/lib/src/engine/data_model_store.dart b/packages/genui/lib/src/engine/data_model_store.dart index 8525030be..121d2c9e2 100644 --- a/packages/genui/lib/src/engine/data_model_store.dart +++ b/packages/genui/lib/src/engine/data_model_store.dart @@ -28,11 +28,15 @@ class DataModelStore { final Set _attachedSurfaces = {}; /// Retrieves the data model for the given [surfaceId], creating it if it - /// does not exist. + /// does not exist. Already-attached live models are returned directly + /// without re-invoking [_lookup]. DataModel getDataModel(String surfaceId) { + final DataModel? cached = _liveDataModels[surfaceId]; + if (cached != null) return cached; final DataModel? liveModel = _lookup?.call(surfaceId); if (liveModel != null) { - return _liveDataModels.putIfAbsent(surfaceId, () => liveModel); + _liveDataModels[surfaceId] = liveModel; + return liveModel; } return _dataModels.putIfAbsent(surfaceId, InMemoryDataModel.new); } @@ -44,9 +48,7 @@ class DataModelStore { final DataModel? fallback = _dataModels.remove(surfaceId); if (fallback != null) { final Object? snapshot = fallback.getValue(DataPath.root); - if (snapshot != null) { - liveModel.update(DataPath.root, snapshot); - } + liveModel.update(DataPath.root, snapshot); fallback.dispose(); } _liveDataModels[surfaceId] = liveModel; diff --git a/packages/genui/lib/src/engine/surface_controller.dart b/packages/genui/lib/src/engine/surface_controller.dart index d7b81c719..3caf16c4e 100644 --- a/packages/genui/lib/src/engine/surface_controller.dart +++ b/packages/genui/lib/src/engine/surface_controller.dart @@ -226,8 +226,12 @@ interface class SurfaceController implements SurfaceHost, A2uiMessageSink { }; void _onCoreSurfaceCreated(core.SurfaceModel surface) { - _registry.addSurface(surface); + // Migrate pre-create fallback data into the live model BEFORE notifying + // registry listeners; otherwise a synchronous listener could call + // contextFor(...).dataModel and cache an empty live wrapper before the + // fallback's data is copied in. _store.attachLive(surface.id, InMemoryDataModel.wrap(surface.dataModel)); + _registry.addSurface(surface); final List? pending = _pendingUpdates.remove(surface.id); _pendingUpdateTimers.remove(surface.id)?.cancel(); if (pending != null) { diff --git a/packages/genui/lib/src/engine/surface_registry.dart b/packages/genui/lib/src/engine/surface_registry.dart index 394804efe..0b0b596ea 100644 --- a/packages/genui/lib/src/engine/surface_registry.dart +++ b/packages/genui/lib/src/engine/surface_registry.dart @@ -17,6 +17,11 @@ sealed class RegistryEvent {} class SurfaceAdded extends RegistryEvent { SurfaceAdded(this.surfaceId, this.surface); final String surfaceId; + + /// Live `a2ui_core` surface model. Intended for GenUI internals; most + /// consumers should read `SurfaceUpdate.definition` from the public + /// `SurfaceController.surfaceUpdates` stream instead. + @internal final SurfaceModel surface; } @@ -30,12 +35,16 @@ class SurfaceRemoved extends RegistryEvent { class SurfaceUpdated extends RegistryEvent { SurfaceUpdated(this.surfaceId, this.surface); final String surfaceId; + + /// Live `a2ui_core` surface model. Intended for GenUI internals; most + /// consumers should read `SurfaceUpdate.definition` from the public + /// `SurfaceController.surfaceUpdates` stream instead. + @internal final SurfaceModel surface; } /// Tracks live [SurfaceModel]s by surface ID and exposes Flutter-friendly /// [ValueListenable]s for them, plus a registry-event stream. -@internal class SurfaceRegistry { final Map> _surfaces = {}; final Map> diff --git a/packages/genui/test/engine/surface_controller_test.dart b/packages/genui/test/engine/surface_controller_test.dart index 1e1f08bd1..27f1f88de 100644 --- a/packages/genui/test/engine/surface_controller_test.dart +++ b/packages/genui/test/engine/surface_controller_test.dart @@ -207,6 +207,18 @@ void main() { expect(model.getValue(DataPath('/name')), 'Alice'); }); + test('pre-create root-null write is preserved across createSurface', () { + const surfaceId = 'null_root'; + controller.contextFor(surfaceId).dataModel.update(DataPath.root, null); + + controller.handleMessage( + const CreateSurface(surfaceId: surfaceId, catalogId: 'test_catalog'), + ); + + final DataModel model = controller.contextFor(surfaceId).dataModel; + expect(model.getValue(DataPath.root), isNull); + }); + test('dispose() closes the updates stream', () async { var isClosed = false; controller.surfaceUpdates.listen( From df4695aa4daf6d80884a3aca579d82e53740a52d Mon Sep 17 00:00:00 2001 From: Andrew Kolos Date: Thu, 28 May 2026 12:20:15 -0700 Subject: [PATCH 09/49] refactor(genui): preserve pre-migration SurfaceRegistry public API `SurfaceRegistry.watchSurface` and `getSurface` are reverted to their pre-migration return types (`SurfaceDefinition`-typed), and `SurfaceAdded` / `SurfaceUpdated` regain their `definition: SurfaceDefinition` field. Existing consumers of those names see no signature change. Live core access moves to new `@internal` siblings: `watchLiveSurface`, `getLiveSurface`, and the existing `.surface` field on the registry events. The migration guide is updated to reflect the actual set of internal-marked APIs. Internal callers (the lookup-callback in `DataModelStore`, `_findCatalogForSurface`, and `LiveSurfaceContext.surface`) move to the new live methods. --- .../migration_genui_onto_a2ui_core.md | 6 +- .../lib/src/engine/surface_controller.dart | 9 ++- .../lib/src/engine/surface_registry.dart | 61 +++++++++++++------ .../test/engine/surface_controller_test.dart | 2 +- 4 files changed, 50 insertions(+), 28 deletions(-) diff --git a/docs/usage/migration/migration_genui_onto_a2ui_core.md b/docs/usage/migration/migration_genui_onto_a2ui_core.md index 4c41d8d89..a5d33bb83 100644 --- a/docs/usage/migration/migration_genui_onto_a2ui_core.md +++ b/docs/usage/migration/migration_genui_onto_a2ui_core.md @@ -52,8 +52,10 @@ on GenUI types. The exception is integrators reaching for the live surface model. A few APIs that exist for GenUI-internal use cross the boundary and expose `a2ui_core.SurfaceModel`: `SurfaceAdded.surface`, `ComponentsUpdated.surface`, -and `SurfaceRegistry`. These are marked `@internal`; use the `definition` -snapshot fields instead unless you specifically need live core access. +`SurfaceRegistry.watchLiveSurface`, and `SurfaceRegistry.getLiveSurface`. +These are marked `@internal`; use `SurfaceUpdate.definition` or +`SurfaceRegistry.getSurface` / `watchSurface` (which still return +`SurfaceDefinition`) unless you specifically need live core access. ## What changed internally diff --git a/packages/genui/lib/src/engine/surface_controller.dart b/packages/genui/lib/src/engine/surface_controller.dart index 3caf16c4e..b8a293ca1 100644 --- a/packages/genui/lib/src/engine/surface_controller.dart +++ b/packages/genui/lib/src/engine/surface_controller.dart @@ -53,7 +53,7 @@ interface class SurfaceController implements SurfaceHost, A2uiMessageSink { surface_reg.SurfaceRegistry(); late final DataModelStore _store = DataModelStore( lookup: (String surfaceId) { - final core.SurfaceModel? surface = _registry.getSurface(surfaceId); + final core.SurfaceModel? surface = _registry.getLiveSurface(surfaceId); if (surface == null) return null; return InMemoryDataModel.wrap(surface.dataModel); }, @@ -306,9 +306,8 @@ interface class SurfaceController implements SurfaceHost, A2uiMessageSink { } Catalog? _findCatalogForSurface(String surfaceId) { - final core.SurfaceModel? surface = _registry.getSurface( - surfaceId, - ); + final core.SurfaceModel? surface = _registry + .getLiveSurface(surfaceId); if (surface == null) return null; return catalogs.firstWhereOrNull((c) => c.catalogId == surface.catalog.id); } @@ -356,7 +355,7 @@ class _ControllerContext implements LiveSurfaceContext { @override ValueListenable get surface => - _controller.registry.watchSurface(surfaceId); + _controller.registry.watchLiveSurface(surfaceId); @override ValueListenable get definition => diff --git a/packages/genui/lib/src/engine/surface_registry.dart b/packages/genui/lib/src/engine/surface_registry.dart index 0b0b596ea..edae3ee9b 100644 --- a/packages/genui/lib/src/engine/surface_registry.dart +++ b/packages/genui/lib/src/engine/surface_registry.dart @@ -15,12 +15,15 @@ sealed class RegistryEvent {} /// An event indicating that a new surface has been added. class SurfaceAdded extends RegistryEvent { - SurfaceAdded(this.surfaceId, this.surface); + SurfaceAdded(this.surfaceId, this.surface) + : definition = genui_model.SurfaceDefinition.fromCore(surface); final String surfaceId; + /// Snapshot definition for this surface. + final genui_model.SurfaceDefinition definition; + /// Live `a2ui_core` surface model. Intended for GenUI internals; most - /// consumers should read `SurfaceUpdate.definition` from the public - /// `SurfaceController.surfaceUpdates` stream instead. + /// consumers should read [definition] instead. @internal final SurfaceModel surface; } @@ -33,12 +36,15 @@ class SurfaceRemoved extends RegistryEvent { /// An event indicating that a surface's components were updated. class SurfaceUpdated extends RegistryEvent { - SurfaceUpdated(this.surfaceId, this.surface); + SurfaceUpdated(this.surfaceId, this.surface) + : definition = genui_model.SurfaceDefinition.fromCore(surface); final String surfaceId; + /// Snapshot definition for this surface. + final genui_model.SurfaceDefinition definition; + /// Live `a2ui_core` surface model. Intended for GenUI internals; most - /// consumers should read `SurfaceUpdate.definition` from the public - /// `SurfaceController.surfaceUpdates` stream instead. + /// consumers should read [definition] instead. @internal final SurfaceModel surface; } @@ -59,18 +65,13 @@ class SurfaceRegistry { /// The list of surface IDs in the order they were created or updated. List get surfaceOrder => List.unmodifiable(_surfaceOrder); - /// Returns a [ValueListenable] that tracks the surface with the given - /// [surfaceId]. The value is `null` until the surface is registered, and - /// becomes `null` again when it is removed. - ValueListenable watchSurface(String surfaceId) { - if (!_surfaces.containsKey(surfaceId)) { - genUiLogger.fine('Adding new surface watcher for $surfaceId'); - } - return _surfaces.putIfAbsent( - surfaceId, - () => ValueNotifier(null), - ); - } + /// Returns a [ValueListenable] tracking the + /// [genui_model.SurfaceDefinition] snapshot for [surfaceId]. The value is + /// `null` until the surface is registered, and becomes `null` again when + /// it is removed. + ValueListenable watchSurface( + String surfaceId, + ) => watchDefinition(surfaceId); /// Returns a [ValueListenable] tracking the /// [genui_model.SurfaceDefinition] snapshot for [surfaceId]. @@ -83,6 +84,19 @@ class SurfaceRegistry { ); } + /// Returns a [ValueListenable] tracking the live core surface model for + /// [surfaceId]. Intended for GenUI internals. + @internal + ValueListenable watchLiveSurface(String surfaceId) { + if (!_surfaces.containsKey(surfaceId)) { + genUiLogger.fine('Adding new surface watcher for $surfaceId'); + } + return _surfaces.putIfAbsent( + surfaceId, + () => ValueNotifier(null), + ); + } + /// Registers a new surface, emitting a [SurfaceAdded] event. void addSurface(SurfaceModel surface) { final ValueNotifier?> notifier = _surfaces @@ -140,8 +154,15 @@ class SurfaceRegistry { /// [surfaceId]. bool hasSurface(String surfaceId) => _surfaces[surfaceId]?.value != null; - /// Returns the current surface with the given [surfaceId], or `null`. - SurfaceModel? getSurface(String surfaceId) => _surfaces[surfaceId]?.value; + /// Returns the current [genui_model.SurfaceDefinition] snapshot for the + /// given [surfaceId], or `null` if the surface does not exist. + genui_model.SurfaceDefinition? getSurface(String surfaceId) => + _definitions[surfaceId]?.value; + + /// Returns the live core surface model for [surfaceId], or `null` if the + /// surface does not exist. Intended for GenUI internals. + @internal + SurfaceModel? getLiveSurface(String surfaceId) => _surfaces[surfaceId]?.value; /// Disposes of the registry and all per-surface notifiers. The underlying /// [SurfaceModel]s are owned and disposed by the substrate's diff --git a/packages/genui/test/engine/surface_controller_test.dart b/packages/genui/test/engine/surface_controller_test.dart index 27f1f88de..420d5672d 100644 --- a/packages/genui/test/engine/surface_controller_test.dart +++ b/packages/genui/test/engine/surface_controller_test.dart @@ -71,7 +71,7 @@ void main() { expect(definition.catalogId, 'test_catalog'); expect(controller.registry.getSurface(surfaceId), isNotNull); expect( - controller.registry.getSurface(surfaceId)!.catalog.id, + controller.registry.getSurface(surfaceId)!.catalogId, 'test_catalog', ); }); From 0d3b9d30670208b930b717448daa2d9cba2259bd Mon Sep 17 00:00:00 2001 From: Andrew Kolos Date: Thu, 28 May 2026 12:26:29 -0700 Subject: [PATCH 10/49] refactor(genui): preserve registry event public constructors `SurfaceAdded` and `SurfaceUpdated` regain their pre-migration public constructor signature `(surfaceId, SurfaceDefinition)`. The live core surface is moved to an `@internal SurfaceAdded.fromCore` / `SurfaceUpdated.fromCore` named constructor used by `SurfaceRegistry`; the `surface` field becomes nullable + `@internal` (null when constructed via the public ctor, populated when emitted by the registry). The controller's surfaceUpdates mapper unwraps `surface!` since registry- emitted events always use `.fromCore`. Adds a regression test exercising `watchSurface` / `getSurface` against the `SurfaceDefinition` snapshot. --- .../lib/src/engine/surface_controller.dart | 6 ++- .../lib/src/engine/surface_registry.dart | 47 ++++++++++++++----- .../test/engine/surface_controller_test.dart | 22 +++++++++ 3 files changed, 61 insertions(+), 14 deletions(-) diff --git a/packages/genui/lib/src/engine/surface_controller.dart b/packages/genui/lib/src/engine/surface_controller.dart index b8a293ca1..1762acfe9 100644 --- a/packages/genui/lib/src/engine/surface_controller.dart +++ b/packages/genui/lib/src/engine/surface_controller.dart @@ -66,10 +66,12 @@ interface class SurfaceController implements SurfaceHost, A2uiMessageSink { @override Stream get surfaceUpdates => _registry.events.map( (e) => switch (e) { + // Registry-emitted events always populate `surface` via + // `SurfaceAdded.fromCore` / `SurfaceUpdated.fromCore`. surface_reg.SurfaceAdded(:final surfaceId, :final surface) => - SurfaceAdded(surfaceId, surface), + SurfaceAdded(surfaceId, surface!), surface_reg.SurfaceUpdated(:final surfaceId, :final surface) => - ComponentsUpdated(surfaceId, surface), + ComponentsUpdated(surfaceId, surface!), surface_reg.SurfaceRemoved(:final surfaceId) => SurfaceRemoved(surfaceId), }, ); diff --git a/packages/genui/lib/src/engine/surface_registry.dart b/packages/genui/lib/src/engine/surface_registry.dart index edae3ee9b..bd7afe9b0 100644 --- a/packages/genui/lib/src/engine/surface_registry.dart +++ b/packages/genui/lib/src/engine/surface_registry.dart @@ -15,17 +15,29 @@ sealed class RegistryEvent {} /// An event indicating that a new surface has been added. class SurfaceAdded extends RegistryEvent { - SurfaceAdded(this.surfaceId, this.surface) - : definition = genui_model.SurfaceDefinition.fromCore(surface); + /// Constructs from a [genui_model.SurfaceDefinition]. The live [surface] + /// is `null` when constructed via this path (intended for tests/mocks); + /// the [SurfaceRegistry] uses [SurfaceAdded.fromCore] internally so + /// real-world events have both fields populated. + SurfaceAdded(this.surfaceId, this.definition) : surface = null; + + /// Internal: constructs from a live core surface, populating both + /// [surface] and [definition]. + @internal + SurfaceAdded.fromCore(this.surfaceId, SurfaceModel coreSurface) + : definition = genui_model.SurfaceDefinition.fromCore(coreSurface), + surface = coreSurface; + final String surfaceId; /// Snapshot definition for this surface. final genui_model.SurfaceDefinition definition; - /// Live `a2ui_core` surface model. Intended for GenUI internals; most - /// consumers should read [definition] instead. + /// Live `a2ui_core` surface model. Null when constructed via the public + /// constructor; populated when emitted by [SurfaceRegistry]. Intended for + /// GenUI internals. @internal - final SurfaceModel surface; + final SurfaceModel? surface; } /// An event indicating that a surface has been removed. @@ -36,17 +48,28 @@ class SurfaceRemoved extends RegistryEvent { /// An event indicating that a surface's components were updated. class SurfaceUpdated extends RegistryEvent { - SurfaceUpdated(this.surfaceId, this.surface) - : definition = genui_model.SurfaceDefinition.fromCore(surface); + /// Constructs from a [genui_model.SurfaceDefinition]. See [SurfaceAdded] + /// for the relationship between this constructor and + /// [SurfaceUpdated.fromCore]. + SurfaceUpdated(this.surfaceId, this.definition) : surface = null; + + /// Internal: constructs from a live core surface, populating both + /// [surface] and [definition]. + @internal + SurfaceUpdated.fromCore(this.surfaceId, SurfaceModel coreSurface) + : definition = genui_model.SurfaceDefinition.fromCore(coreSurface), + surface = coreSurface; + final String surfaceId; /// Snapshot definition for this surface. final genui_model.SurfaceDefinition definition; - /// Live `a2ui_core` surface model. Intended for GenUI internals; most - /// consumers should read [definition] instead. + /// Live `a2ui_core` surface model. Null when constructed via the public + /// constructor; populated when emitted by [SurfaceRegistry]. Intended for + /// GenUI internals. @internal - final SurfaceModel surface; + final SurfaceModel? surface; } /// Tracks live [SurfaceModel]s by surface ID and exposes Flutter-friendly @@ -114,7 +137,7 @@ class SurfaceRegistry { ..remove(surface.id) ..add(surface.id); genUiLogger.info('Created new surface ${surface.id}'); - _eventController.add(SurfaceAdded(surface.id, surface)); + _eventController.add(SurfaceAdded.fromCore(surface.id, surface)); } /// Signals that the components of a surface have changed. @@ -130,7 +153,7 @@ class SurfaceRegistry { .value = genui_model.SurfaceDefinition.fromCore( surface, ); - _eventController.add(SurfaceUpdated(surface.id, surface)); + _eventController.add(SurfaceUpdated.fromCore(surface.id, surface)); } /// Removes a surface from the registry, emitting a [SurfaceRemoved] event. diff --git a/packages/genui/test/engine/surface_controller_test.dart b/packages/genui/test/engine/surface_controller_test.dart index 420d5672d..88b6c948b 100644 --- a/packages/genui/test/engine/surface_controller_test.dart +++ b/packages/genui/test/engine/surface_controller_test.dart @@ -154,6 +154,28 @@ void main() { expect(notifier1.value, isNull); }); + test( + 'registry watchSurface/getSurface expose SurfaceDefinition snapshots', + () { + const surfaceId = 's1'; + final ValueListenable notifier = controller.registry + .watchSurface(surfaceId); + expect(notifier.value, isNull); + expect(controller.registry.getSurface(surfaceId), isNull); + + controller.handleMessage( + const CreateSurface(surfaceId: surfaceId, catalogId: 'test_catalog'), + ); + + final SurfaceDefinition? def = controller.registry.getSurface( + surfaceId, + ); + expect(def, isNotNull); + expect(def!.catalogId, 'test_catalog'); + expect(notifier.value, same(def)); + }, + ); + test('store exposes the live surface data model facade', () { const surfaceId = 's1'; controller.handleMessage( From 186a8c4354827c78de9c94bf904c53a9744e7689 Mon Sep 17 00:00:00 2001 From: Andrew Kolos Date: Thu, 28 May 2026 12:33:55 -0700 Subject: [PATCH 11/49] refactor(genui): preserve public SurfaceUpdate constructor signatures Same pattern as the registry-event compat fix, applied to the user- facing `SurfaceAdded` and `ComponentsUpdated` events in `packages/genui/lib/src/model/ui_models.dart`: - Public ctor takes `(surfaceId, SurfaceDefinition)` (pre-migration shape), leaves `surface` null. - `@internal SurfaceAdded.fromCore` / `ComponentsUpdated.fromCore` populate both the snapshot and the live `core.SurfaceModel`. - `surface` field is now `core.SurfaceModel?` and `@internal`. - The surfaceUpdates mapper in SurfaceController uses `.fromCore`. Marks `SurfaceRegistry.addSurface` and `notifyUpdated` `@internal` (new in this branch; only SurfaceController calls them). The pre-migration `SurfaceRegistry.updateSurface(SurfaceDefinition)` is intentionally not restored; the live-model registry can't honor the definition-only push path without diverging from the substrate state. The removal is now called out in the migration guide. Adds a regression test for the public SurfaceUpdate ctors. --- .../migration_genui_onto_a2ui_core.md | 4 ++ .../lib/src/engine/surface_controller.dart | 4 +- .../lib/src/engine/surface_registry.dart | 9 +++- packages/genui/lib/src/model/ui_models.dart | 49 +++++++++++++------ .../test/engine/surface_controller_test.dart | 14 ++++++ 5 files changed, 62 insertions(+), 18 deletions(-) diff --git a/docs/usage/migration/migration_genui_onto_a2ui_core.md b/docs/usage/migration/migration_genui_onto_a2ui_core.md index a5d33bb83..3110a2f33 100644 --- a/docs/usage/migration/migration_genui_onto_a2ui_core.md +++ b/docs/usage/migration/migration_genui_onto_a2ui_core.md @@ -102,6 +102,10 @@ These are substrate behavior changes, not rename requirements: of silently reusing the existing surface. 7. **JSON Pointer `~0`/`~1` escapes are not interpreted.** Paths split on `/`, matching the web core behavior. +8. **`SurfaceRegistry.updateSurface(...)` is removed.** Surface lifecycle + updates now flow through `SurfaceController.handleMessage`; the + definition-only push path is no longer supported. `addSurface` and + `notifyUpdated` exist on `SurfaceRegistry` but are marked `@internal`. ## On the future of the compatibility facades diff --git a/packages/genui/lib/src/engine/surface_controller.dart b/packages/genui/lib/src/engine/surface_controller.dart index 1762acfe9..95faae3b3 100644 --- a/packages/genui/lib/src/engine/surface_controller.dart +++ b/packages/genui/lib/src/engine/surface_controller.dart @@ -69,9 +69,9 @@ interface class SurfaceController implements SurfaceHost, A2uiMessageSink { // Registry-emitted events always populate `surface` via // `SurfaceAdded.fromCore` / `SurfaceUpdated.fromCore`. surface_reg.SurfaceAdded(:final surfaceId, :final surface) => - SurfaceAdded(surfaceId, surface!), + SurfaceAdded.fromCore(surfaceId, surface!), surface_reg.SurfaceUpdated(:final surfaceId, :final surface) => - ComponentsUpdated(surfaceId, surface!), + ComponentsUpdated.fromCore(surfaceId, surface!), surface_reg.SurfaceRemoved(:final surfaceId) => SurfaceRemoved(surfaceId), }, ); diff --git a/packages/genui/lib/src/engine/surface_registry.dart b/packages/genui/lib/src/engine/surface_registry.dart index bd7afe9b0..e255d757c 100644 --- a/packages/genui/lib/src/engine/surface_registry.dart +++ b/packages/genui/lib/src/engine/surface_registry.dart @@ -120,7 +120,10 @@ class SurfaceRegistry { ); } - /// Registers a new surface, emitting a [SurfaceAdded] event. + /// Registers a new surface, emitting a [SurfaceAdded] event. Intended + /// for GenUI internals; external callers should drive surface lifecycle + /// through `SurfaceController.handleMessage`. + @internal void addSurface(SurfaceModel surface) { final ValueNotifier?> notifier = _surfaces .putIfAbsent(surface.id, () => ValueNotifier(null)); @@ -140,7 +143,9 @@ class SurfaceRegistry { _eventController.add(SurfaceAdded.fromCore(surface.id, surface)); } - /// Signals that the components of a surface have changed. + /// Signals that the components of a surface have changed. Intended for + /// GenUI internals. + @internal void notifyUpdated(SurfaceModel surface) { _surfaceOrder ..remove(surface.id) diff --git a/packages/genui/lib/src/model/ui_models.dart b/packages/genui/lib/src/model/ui_models.dart index 6459a1d30..36a2acc1f 100644 --- a/packages/genui/lib/src/model/ui_models.dart +++ b/packages/genui/lib/src/model/ui_models.dart @@ -272,30 +272,51 @@ sealed class SurfaceUpdate { /// Fired when a new surface is created. final class SurfaceAdded extends SurfaceUpdate { - SurfaceAdded(super.surfaceId, this.surface); + /// Constructs from a [SurfaceDefinition]. The live [surface] is `null` + /// when constructed via this path (intended for tests/mocks); + /// `SurfaceController` uses [SurfaceAdded.fromCore] internally so + /// real-world events have both fields populated. + SurfaceAdded(super.surfaceId, this.definition) : surface = null; + + /// Internal: constructs from a live core surface, populating both + /// [surface] and [definition]. + @internal + SurfaceAdded.fromCore(super.surfaceId, core.SurfaceModel coreSurface) + : surface = coreSurface, + definition = SurfaceDefinition.fromCore(coreSurface); - /// Live `a2ui_core` surface model. Intended for GenUI internals; most - /// consumers should read [definition] instead. + /// Live `a2ui_core` surface model. Null when constructed via the public + /// constructor; populated when emitted by `SurfaceController`. Intended + /// for GenUI internals. @internal - final core.SurfaceModel surface; + final core.SurfaceModel? surface; - /// Snapshot facade. Materialized lazily so lifecycle-only listeners don't - /// pay the snapshot cost. - late final SurfaceDefinition definition = SurfaceDefinition.fromCore(surface); + /// Snapshot definition for this surface. + final SurfaceDefinition definition; } /// Fired when an existing surface's component set is modified. final class ComponentsUpdated extends SurfaceUpdate { - ComponentsUpdated(super.surfaceId, this.surface); + /// Constructs from a [SurfaceDefinition]. See [SurfaceAdded] for the + /// relationship between this constructor and + /// [ComponentsUpdated.fromCore]. + ComponentsUpdated(super.surfaceId, this.definition) : surface = null; + + /// Internal: constructs from a live core surface, populating both + /// [surface] and [definition]. + @internal + ComponentsUpdated.fromCore(super.surfaceId, core.SurfaceModel coreSurface) + : surface = coreSurface, + definition = SurfaceDefinition.fromCore(coreSurface); - /// Live `a2ui_core` surface model. Intended for GenUI internals; most - /// consumers should read [definition] instead. + /// Live `a2ui_core` surface model. Null when constructed via the public + /// constructor; populated when emitted by `SurfaceController`. Intended + /// for GenUI internals. @internal - final core.SurfaceModel surface; + final core.SurfaceModel? surface; - /// Snapshot facade. Materialized lazily so lifecycle-only listeners don't - /// pay the snapshot cost. - late final SurfaceDefinition definition = SurfaceDefinition.fromCore(surface); + /// Snapshot definition for this surface. + final SurfaceDefinition definition; } /// Fired when a surface is deleted. diff --git a/packages/genui/test/engine/surface_controller_test.dart b/packages/genui/test/engine/surface_controller_test.dart index 88b6c948b..f37abe28e 100644 --- a/packages/genui/test/engine/surface_controller_test.dart +++ b/packages/genui/test/engine/surface_controller_test.dart @@ -154,6 +154,20 @@ void main() { expect(notifier1.value, isNull); }); + test('public SurfaceAdded / ComponentsUpdated constructors are ' + 'definition-based', () { + final def = SurfaceDefinition(surfaceId: 's1'); + final added = SurfaceAdded('s1', def); + expect(added.surfaceId, 's1'); + expect(added.definition, same(def)); + expect(added.surface, isNull); + + final updated = ComponentsUpdated('s1', def); + expect(updated.surfaceId, 's1'); + expect(updated.definition, same(def)); + expect(updated.surface, isNull); + }); + test( 'registry watchSurface/getSurface expose SurfaceDefinition snapshots', () { From 44273327c25ef501e68a7f455e5f2f5fb4f7f5fd Mon Sep 17 00:00:00 2001 From: Andrew Kolos Date: Thu, 28 May 2026 12:37:08 -0700 Subject: [PATCH 12/49] chore(genui): const on public SurfaceUpdate ctors; CHANGELOG note for updateSurface removal - Adds `const` to public `SurfaceAdded` and `ComponentsUpdated` constructors to match the pre-migration shape (and the existing `SurfaceRemoved` ctor). - Promotes the `SurfaceRegistry.updateSurface(...)` removal to a BREAKING bullet in the CHANGELOG. It is a pre-migration public API removal; preserving it would require diverging the registry from the live core surface model. --- packages/genui/CHANGELOG.md | 11 ++++++++--- packages/genui/lib/src/model/ui_models.dart | 4 ++-- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/packages/genui/CHANGELOG.md b/packages/genui/CHANGELOG.md index b7e0e633f..10d2457ee 100644 --- a/packages/genui/CHANGELOG.md +++ b/packages/genui/CHANGELOG.md @@ -14,9 +14,14 @@ - **Behavior**: JSON Pointer `~0`/`~1` escapes are not interpreted on `DataPath`; paths split on `/`, matching the web reference implementation (see A2UI#1499 tracking spec clarification). -- **Internal**: `SurfaceRegistry` and the live `core.SurfaceModel` fields - on `SurfaceAdded` / `ComponentsUpdated` are marked `@internal`. Most - consumers should read `SurfaceUpdate.definition` instead. +- **BREAKING**: `SurfaceRegistry.updateSurface(...)` is removed. Surface + lifecycle now flows through `SurfaceController.handleMessage`; the + definition-only push path could not be preserved without diverging from + the live `a2ui_core` surface model. `SurfaceRegistry.addSurface` / + `notifyUpdated` exist as replacements but are marked `@internal`. +- **Internal**: The live `core.SurfaceModel` fields on `SurfaceAdded` / + `ComponentsUpdated` are marked `@internal`. Most consumers should read + `SurfaceUpdate.definition` instead. ## 0.9.2 diff --git a/packages/genui/lib/src/model/ui_models.dart b/packages/genui/lib/src/model/ui_models.dart index 36a2acc1f..9e16313cb 100644 --- a/packages/genui/lib/src/model/ui_models.dart +++ b/packages/genui/lib/src/model/ui_models.dart @@ -276,7 +276,7 @@ final class SurfaceAdded extends SurfaceUpdate { /// when constructed via this path (intended for tests/mocks); /// `SurfaceController` uses [SurfaceAdded.fromCore] internally so /// real-world events have both fields populated. - SurfaceAdded(super.surfaceId, this.definition) : surface = null; + const SurfaceAdded(super.surfaceId, this.definition) : surface = null; /// Internal: constructs from a live core surface, populating both /// [surface] and [definition]. @@ -300,7 +300,7 @@ final class ComponentsUpdated extends SurfaceUpdate { /// Constructs from a [SurfaceDefinition]. See [SurfaceAdded] for the /// relationship between this constructor and /// [ComponentsUpdated.fromCore]. - ComponentsUpdated(super.surfaceId, this.definition) : surface = null; + const ComponentsUpdated(super.surfaceId, this.definition) : surface = null; /// Internal: constructs from a live core surface, populating both /// [surface] and [definition]. From 70ab86aa6d325ea6c9531fb89676155c7d6ca228 Mon Sep 17 00:00:00 2001 From: Andrew Kolos Date: Thu, 28 May 2026 12:39:08 -0700 Subject: [PATCH 13/49] docs(genui): clarify CHANGELOG wording on addSurface/notifyUpdated Per reviewer nit: "replacements" implied external users should reach for them, which is the opposite of the @internal intent. Reads now as "internal lifecycle hooks." --- packages/genui/CHANGELOG.md | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/genui/CHANGELOG.md b/packages/genui/CHANGELOG.md index 10d2457ee..f9d89952b 100644 --- a/packages/genui/CHANGELOG.md +++ b/packages/genui/CHANGELOG.md @@ -18,13 +18,12 @@ lifecycle now flows through `SurfaceController.handleMessage`; the definition-only push path could not be preserved without diverging from the live `a2ui_core` surface model. `SurfaceRegistry.addSurface` / - `notifyUpdated` exist as replacements but are marked `@internal`. + `notifyUpdated` exist as internal lifecycle hooks and are marked + `@internal`. - **Internal**: The live `core.SurfaceModel` fields on `SurfaceAdded` / `ComponentsUpdated` are marked `@internal`. Most consumers should read `SurfaceUpdate.definition` instead. -## 0.9.2 - ## 0.9.1 - **Feature**: Updated example/README.md. From 482a71ab375e38bd68f97b8af81d0fd1d26edebb Mon Sep 17 00:00:00 2001 From: Andrew Kolos Date: Thu, 4 Jun 2026 07:35:49 -0700 Subject: [PATCH 14/49] refactor(genui): break ui_models/schema_validation import cycle schema_validation.dart imported ui_models.dart only to throw A2uiValidationException, while ui_models.dart imported schema_validation.dart for SurfaceDefinition.validate. That cycle failed layerlens --fail-on-cycles. A2uiValidationException is a cross-cutting error type thrown across the model, engine, and transport layers, and ui_models.dart never used the one it defined. Move it to a primitives leaf so schema_validation no longer depends on the model layer and the graph becomes an acyclic DAG. primitives.dart re-exports it, so package:genui consumers are unchanged. --- .../lib/src/engine/surface_controller.dart | 1 + .../genui/lib/src/model/a2ui_message.dart | 1 + .../lib/src/model/schema_validation.dart | 2 +- packages/genui/lib/src/model/ui_models.dart | 28 ----------------- packages/genui/lib/src/primitives.dart | 1 + .../primitives/a2ui_validation_exception.dart | 31 +++++++++++++++++++ .../transport/a2ui_parser_transformer.dart | 2 +- .../genui/test/model/a2ui_message_test.dart | 2 +- packages/genui/test/model/ui_models_test.dart | 1 + .../a2ui_parser_transformer_test.dart | 2 +- 10 files changed, 39 insertions(+), 32 deletions(-) create mode 100644 packages/genui/lib/src/primitives/a2ui_validation_exception.dart diff --git a/packages/genui/lib/src/engine/surface_controller.dart b/packages/genui/lib/src/engine/surface_controller.dart index 95faae3b3..b2a12a36a 100644 --- a/packages/genui/lib/src/engine/surface_controller.dart +++ b/packages/genui/lib/src/engine/surface_controller.dart @@ -19,6 +19,7 @@ import '../model/chat_message.dart'; import '../model/data_model.dart'; import '../model/schema_validation.dart' as schema_validation; import '../model/ui_models.dart'; +import '../primitives/a2ui_validation_exception.dart'; import '../primitives/logging.dart'; import 'data_model_store.dart'; diff --git a/packages/genui/lib/src/model/a2ui_message.dart b/packages/genui/lib/src/model/a2ui_message.dart index 96b715bfc..69f1fc4c7 100644 --- a/packages/genui/lib/src/model/a2ui_message.dart +++ b/packages/genui/lib/src/model/a2ui_message.dart @@ -5,6 +5,7 @@ import 'package:a2ui_core/a2ui_core.dart' as core; import 'package:json_schema_builder/json_schema_builder.dart'; +import '../primitives/a2ui_validation_exception.dart'; import '../primitives/simple_items.dart'; import 'a2ui_schemas.dart'; import 'catalog.dart'; diff --git a/packages/genui/lib/src/model/schema_validation.dart b/packages/genui/lib/src/model/schema_validation.dart index 1ce84b05a..70b2acf88 100644 --- a/packages/genui/lib/src/model/schema_validation.dart +++ b/packages/genui/lib/src/model/schema_validation.dart @@ -6,8 +6,8 @@ import 'dart:convert'; import 'package:json_schema_builder/json_schema_builder.dart'; +import '../primitives/a2ui_validation_exception.dart'; import '../primitives/simple_items.dart'; -import 'ui_models.dart' show A2uiValidationException; /// Validates a set of A2UI components against a catalog [schema]. /// diff --git a/packages/genui/lib/src/model/ui_models.dart b/packages/genui/lib/src/model/ui_models.dart index 9e16313cb..7328a5ed8 100644 --- a/packages/genui/lib/src/model/ui_models.dart +++ b/packages/genui/lib/src/model/ui_models.dart @@ -236,34 +236,6 @@ final class Component { Object.hash(id, type, const DeepCollectionEquality().hash(properties)); } -/// Exception thrown when an A2UI message fails parsing or validation. -class A2uiValidationException implements Exception { - /// Creates an [A2uiValidationException]. - A2uiValidationException( - this.message, { - this.surfaceId, - this.path, - this.json, - this.cause, - }); - - final String message; - final String? surfaceId; - final String? path; - final Object? json; - final Object? cause; - - @override - String toString() { - final buffer = StringBuffer('A2uiValidationException: $message'); - if (surfaceId != null) buffer.write(' (surface: $surfaceId)'); - if (path != null) buffer.write(' (path: $path)'); - if (cause != null) buffer.write('\nCause: $cause'); - if (json != null) buffer.write('\nJSON: $json'); - return buffer.toString(); - } -} - /// Surface lifecycle events emitted by `SurfaceController.surfaceUpdates`. sealed class SurfaceUpdate { const SurfaceUpdate(this.surfaceId); diff --git a/packages/genui/lib/src/primitives.dart b/packages/genui/lib/src/primitives.dart index b7a9faebe..5e71708f7 100644 --- a/packages/genui/lib/src/primitives.dart +++ b/packages/genui/lib/src/primitives.dart @@ -5,6 +5,7 @@ /// Low-level utilities used by the GenUI framework. library; +export 'primitives/a2ui_validation_exception.dart'; export 'primitives/cancellation.dart'; export 'primitives/constants.dart'; export 'primitives/logging.dart'; diff --git a/packages/genui/lib/src/primitives/a2ui_validation_exception.dart b/packages/genui/lib/src/primitives/a2ui_validation_exception.dart new file mode 100644 index 000000000..c168d84e5 --- /dev/null +++ b/packages/genui/lib/src/primitives/a2ui_validation_exception.dart @@ -0,0 +1,31 @@ +// 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. + +/// Exception thrown when an A2UI message fails parsing or validation. +class A2uiValidationException implements Exception { + /// Creates an [A2uiValidationException]. + A2uiValidationException( + this.message, { + this.surfaceId, + this.path, + this.json, + this.cause, + }); + + final String message; + final String? surfaceId; + final String? path; + final Object? json; + final Object? cause; + + @override + String toString() { + final buffer = StringBuffer('A2uiValidationException: $message'); + if (surfaceId != null) buffer.write(' (surface: $surfaceId)'); + if (path != null) buffer.write(' (path: $path)'); + if (cause != null) buffer.write('\nCause: $cause'); + if (json != null) buffer.write('\nJSON: $json'); + return buffer.toString(); + } +} diff --git a/packages/genui/lib/src/transport/a2ui_parser_transformer.dart b/packages/genui/lib/src/transport/a2ui_parser_transformer.dart index 7a96792f2..14ce0ae3d 100644 --- a/packages/genui/lib/src/transport/a2ui_parser_transformer.dart +++ b/packages/genui/lib/src/transport/a2ui_parser_transformer.dart @@ -7,7 +7,7 @@ import 'dart:convert'; import '../model/a2ui_message.dart'; import '../model/generation_events.dart'; -import '../model/ui_models.dart'; +import '../primitives/a2ui_validation_exception.dart'; /// Transforms a stream of text chunks into a stream of logical /// [GenerationEvent]s. diff --git a/packages/genui/test/model/a2ui_message_test.dart b/packages/genui/test/model/a2ui_message_test.dart index b59ffbcdb..1b14e98d0 100644 --- a/packages/genui/test/model/a2ui_message_test.dart +++ b/packages/genui/test/model/a2ui_message_test.dart @@ -8,7 +8,7 @@ import 'package:genui/src/catalog/basic_catalog.dart'; import 'package:genui/src/model/a2ui_message.dart'; import 'package:genui/src/model/catalog.dart'; import 'package:genui/src/model/data_model.dart'; -import 'package:genui/src/model/ui_models.dart'; +import 'package:genui/src/primitives/a2ui_validation_exception.dart'; import 'package:genui/src/primitives/simple_items.dart'; import 'package:json_schema_builder/src/schema/schema.dart'; diff --git a/packages/genui/test/model/ui_models_test.dart b/packages/genui/test/model/ui_models_test.dart index d7ae0642f..3d3a963f4 100644 --- a/packages/genui/test/model/ui_models_test.dart +++ b/packages/genui/test/model/ui_models_test.dart @@ -4,6 +4,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:genui/src/model/ui_models.dart'; +import 'package:genui/src/primitives/a2ui_validation_exception.dart'; import 'package:genui/src/primitives/simple_items.dart'; import 'package:json_schema_builder/json_schema_builder.dart'; diff --git a/packages/genui/test/transport/a2ui_parser_transformer_test.dart b/packages/genui/test/transport/a2ui_parser_transformer_test.dart index e614a7503..9c03872ee 100644 --- a/packages/genui/test/transport/a2ui_parser_transformer_test.dart +++ b/packages/genui/test/transport/a2ui_parser_transformer_test.dart @@ -7,7 +7,7 @@ import 'dart:async'; import 'package:async/async.dart'; import 'package:genui/src/model/a2ui_message.dart'; import 'package:genui/src/model/generation_events.dart'; -import 'package:genui/src/model/ui_models.dart'; +import 'package:genui/src/primitives/a2ui_validation_exception.dart'; import 'package:genui/src/transport/a2ui_parser_transformer.dart'; import 'package:test/test.dart'; From cd6e9378b65ccd084bbaa6b9a3b05f9ac71da78c Mon Sep 17 00:00:00 2001 From: Andrew Kolos Date: Thu, 4 Jun 2026 07:52:54 -0700 Subject: [PATCH 15/49] fix(genui): correct user-action detection and empty-surfaceId rejection handleUiEvent guarded on `event is! UserActionEvent`, but extension types erase to their representation at runtime, so the check was always false and every UiEvent was submitted to the AI as an action. Add a data-based discriminator (UiEvent.isUserAction, keyed on the action `name`) and use it in handleUiEvent and Surface._dispatchEvent, which had the same erased check. Empty surfaceId was rejected only for CreateSurface; UpdateComponents, UpdateDataModel, and DeleteSurface with an empty id buffered under "" until timeout. Reject an empty surfaceId on any message that carries one. Regression tests cover both. --- .../lib/src/engine/surface_controller.dart | 11 ++++--- packages/genui/lib/src/model/ui_models.dart | 5 +++ packages/genui/lib/src/widgets/surface.dart | 2 +- .../test/engine/surface_controller_test.dart | 31 +++++++++++++++++++ 4 files changed, 43 insertions(+), 6 deletions(-) diff --git a/packages/genui/lib/src/engine/surface_controller.dart b/packages/genui/lib/src/engine/surface_controller.dart index b2a12a36a..e03a47b61 100644 --- a/packages/genui/lib/src/engine/surface_controller.dart +++ b/packages/genui/lib/src/engine/surface_controller.dart @@ -109,10 +109,11 @@ interface class SurfaceController implements SurfaceHost, A2uiMessageSink { } void _handleCoreMessage(core.A2uiMessage coreMessage) { - // Reject empty surfaceId before delegating; the substrate would otherwise - // create a surface with id "". - if (coreMessage is core.CreateSurfaceMessage && - coreMessage.surfaceId.isEmpty) { + // Reject an empty surfaceId on any message that carries one. CreateSurface + // would otherwise create a surface with id ""; updates and deletes would + // buffer under "" until they time out, since a surface "" can never exist. + final String? surfaceId = _surfaceIdOf(coreMessage); + if (surfaceId != null && surfaceId.isEmpty) { reportError( A2uiValidationException( 'Surface ID cannot be empty', @@ -295,7 +296,7 @@ interface class SurfaceController implements SurfaceHost, A2uiMessageSink { /// Sends a [UserActionEvent] to [onSubmit] as a [ChatMessage]. No-op for /// non-action [UiEvent]s. void handleUiEvent(UiEvent event) { - if (event is! UserActionEvent) return; + if (!event.isUserAction) return; _onSubmit.add( ChatMessage.user( '', diff --git a/packages/genui/lib/src/model/ui_models.dart b/packages/genui/lib/src/model/ui_models.dart index 7328a5ed8..e57145e14 100644 --- a/packages/genui/lib/src/model/ui_models.dart +++ b/packages/genui/lib/src/model/ui_models.dart @@ -40,6 +40,11 @@ extension type UiEvent.fromMap(JsonMap _json) { /// The timestamp of when the event occurred. DateTime get timestamp => DateTime.parse(_json['timestamp'] as String); + /// Whether this is a [UserActionEvent]. Extension types are erased to their + /// representation at runtime, so `event is UserActionEvent` is always true + /// for any [UiEvent]; the action's `name` key is the real discriminator. + bool get isUserAction => _json.containsKey('name'); + /// Converts this event to a map, suitable for JSON serialization. JsonMap toMap() => _json; } diff --git a/packages/genui/lib/src/widgets/surface.dart b/packages/genui/lib/src/widgets/surface.dart index 5c971c977..175b6dfa3 100644 --- a/packages/genui/lib/src/widgets/surface.dart +++ b/packages/genui/lib/src/widgets/surface.dart @@ -350,7 +350,7 @@ class _SurfaceState extends State { ...event.toMap(), surfaceIdKey: widget.surfaceContext.surfaceId, }; - final UiEvent newEvent = event is UserActionEvent + final UiEvent newEvent = event.isUserAction ? UserActionEvent.fromMap(eventMap) : UiEvent.fromMap(eventMap); widget.surfaceContext.handleUiEvent(newEvent); diff --git a/packages/genui/test/engine/surface_controller_test.dart b/packages/genui/test/engine/surface_controller_test.dart index f37abe28e..807c9a0ae 100644 --- a/packages/genui/test/engine/surface_controller_test.dart +++ b/packages/genui/test/engine/surface_controller_test.dart @@ -2,6 +2,7 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. +import 'dart:async'; import 'dart:convert'; import 'package:flutter/foundation.dart'; @@ -303,6 +304,23 @@ void main() { expect(part.interaction, expectedJson); }); + test('handleUiEvent ignores non-action UiEvents', () async { + var submitted = false; + final StreamSubscription sub = controller.onSubmit.listen( + (_) => submitted = true, + ); + final event = UiEvent.fromMap({ + 'widgetId': 'testWidget', + 'eventType': 'onChanged', + 'value': 'hello', + 'timestamp': DateTime.now().toIso8601String(), + }); + controller.handleUiEvent(event); + await Future.delayed(Duration.zero); + expect(submitted, isFalse); + await sub.cancel(); + }); + test( 'handleMessage reports validation error with correct structure', () async { @@ -327,6 +345,19 @@ void main() { }, ); + test('rejects empty surfaceId on non-create messages', () async { + final Future messageFuture = controller.onSubmit.first; + controller.handleMessage(const UpdateDataModel(surfaceId: '', value: 1)); + + final ChatMessage message = await messageFuture; + final UiInteractionPart part = message.parts.uiInteractionParts.first; + final errorJson = jsonDecode(part.interaction) as Map; + final errorMap = errorJson['error']! as Map; + expect(errorMap['code'], 'VALIDATION_FAILED'); + expect(errorMap['surfaceId'], ''); + expect(errorMap['path'], 'surfaceId'); + }); + test('drops pending updates after timeout', () async { // Create controller with short timeout final shortTimeoutController = SurfaceController( From fb94089416cb52a782e18a8ed5c7fa89b7553311 Mon Sep 17 00:00:00 2001 From: Andrew Kolos Date: Thu, 4 Jun 2026 09:11:00 -0700 Subject: [PATCH 16/49] test(genui): cover live rebuild + duplicate-create, re-baseline coverage Add a widget test for live per-component rebuilds (the migration's headline behavior) and a test for the new duplicate-createSurface error. The migration nets a deletion of well-tested bespoke code, which lowered packages/genui aggregate line coverage, so re-mark the high-water baseline from 79.71% to its post-migration value (79.38%). --- coverage_baseline.yaml | 2 +- .../test/engine/surface_controller_test.dart | 19 ++++++++++ packages/genui/test/genui_surface_test.dart | 38 +++++++++++++++++++ 3 files changed, 58 insertions(+), 1 deletion(-) diff --git a/coverage_baseline.yaml b/coverage_baseline.yaml index 7bc26ea10..8a5f1285d 100644 --- a/coverage_baseline.yaml +++ b/coverage_baseline.yaml @@ -7,7 +7,7 @@ dev_tools/catalog_gallery: 52.91 dev_tools/composer: 20.49 packages/a2ui_core: 76.30 packages/genai_primitives: 100.00 -packages/genui: 79.71 +packages/genui: 79.38 packages/genui_a2a: 91.37 packages/json_schema_builder: 79.09 tool/e2e: 100.00 diff --git a/packages/genui/test/engine/surface_controller_test.dart b/packages/genui/test/engine/surface_controller_test.dart index 807c9a0ae..bf9ac6bc0 100644 --- a/packages/genui/test/engine/surface_controller_test.dart +++ b/packages/genui/test/engine/surface_controller_test.dart @@ -358,6 +358,25 @@ void main() { expect(errorMap['path'], 'surfaceId'); }); + test( + 'duplicate createSurface for an active surface reports an error', + () async { + controller.handleMessage( + const CreateSurface(surfaceId: 's1', catalogId: 'test_catalog'), + ); + final Future messageFuture = controller.onSubmit.first; + controller.handleMessage( + const CreateSurface(surfaceId: 's1', catalogId: 'test_catalog'), + ); + + final ChatMessage message = await messageFuture; + final UiInteractionPart part = message.parts.uiInteractionParts.first; + final errorJson = jsonDecode(part.interaction) as Map; + final errorMap = errorJson['error']! as Map; + expect(errorMap['surfaceId'], 's1'); + }, + ); + test('drops pending updates after timeout', () async { // Create controller with short timeout final shortTimeoutController = SurfaceController( diff --git a/packages/genui/test/genui_surface_test.dart b/packages/genui/test/genui_surface_test.dart index 6d22d20e9..1a2c3e272 100644 --- a/packages/genui/test/genui_surface_test.dart +++ b/packages/genui/test/genui_surface_test.dart @@ -139,4 +139,42 @@ void main() { ); }, ); + + testWidgets('rebuilds when components change after creation', ( + WidgetTester tester, + ) async { + const surfaceId = 'testSurface'; + controller.handleMessage( + const UpdateComponents( + surfaceId: surfaceId, + components: [ + Component(id: 'root', type: 'Text', properties: {'text': 'first'}), + ], + ), + ); + controller.handleMessage( + const CreateSurface(surfaceId: surfaceId, catalogId: 'test_catalog'), + ); + + await tester.pumpWidget( + MaterialApp( + home: Surface(surfaceContext: controller.contextFor(surfaceId)), + ), + ); + expect(find.text('first'), findsOneWidget); + + // Updating the live surface should rebuild it in place. + controller.handleMessage( + const UpdateComponents( + surfaceId: surfaceId, + components: [ + Component(id: 'root', type: 'Text', properties: {'text': 'second'}), + ], + ), + ); + await tester.pump(); + + expect(find.text('second'), findsOneWidget); + expect(find.text('first'), findsNothing); + }); } From 9f879bb1426a09d68d3886acf7ba5eeb6ac20cf0 Mon Sep 17 00:00:00 2001 From: Andrew Kolos Date: Thu, 4 Jun 2026 10:20:04 -0700 Subject: [PATCH 17/49] refactor(genui): render surfaces from the definition snapshot only Remove the live per-component render path from Surface: the _ComponentBuilder, the LiveSurfaceContext render subscription, and the component create/delete listeners. The widget now always rebuilds from SurfaceContext.definition, which the registry keeps current on every create/update; data changes still propagate through the Bound* widgets. Each component widget is keyed by id to preserve widget identity across rebuilds, which the live builder previously supplied. The live core.SurfaceModel remains tracked for the data adapter; only the render-path subscription is removed. --- packages/genui/lib/src/widgets/surface.dart | 276 +++----------------- 1 file changed, 30 insertions(+), 246 deletions(-) diff --git a/packages/genui/lib/src/widgets/surface.dart b/packages/genui/lib/src/widgets/surface.dart index 175b6dfa3..b714f48ca 100644 --- a/packages/genui/lib/src/widgets/surface.dart +++ b/packages/genui/lib/src/widgets/surface.dart @@ -2,7 +2,6 @@ // 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/material.dart'; @@ -20,10 +19,8 @@ typedef UiEventCallback = void Function(UiEvent event); /// A widget that renders a dynamic UI surface generated by the AI. /// -/// Drives off [SurfaceContext.definition] by default; when the context also -/// implements [LiveSurfaceContext] (GenUI's own controller does), the renderer -/// subscribes to per-component updates from the live core surface model for -/// granular rebuilds. +/// Rebuilds from [SurfaceContext.definition] whenever the surface's component +/// snapshot changes. class Surface extends StatefulWidget { /// Creates a [Surface]. const Surface({ @@ -47,59 +44,23 @@ class Surface extends StatefulWidget { } class _SurfaceState extends State { - core.SurfaceModel? _trackedSurface; - void Function(core.ComponentModel)? _onCreated; - void Function(String)? _onDeleted; - - LiveSurfaceContext? get _liveContext => - widget.surfaceContext is LiveSurfaceContext - ? widget.surfaceContext as LiveSurfaceContext - : null; - @override void initState() { super.initState(); - final LiveSurfaceContext? liveContext = _liveContext; - if (liveContext != null) { - liveContext.surface.addListener(_onSurfaceChanged); - _attachComponentsListeners(liveContext.surface.value); - } else { - widget.surfaceContext.definition.addListener(_onDefinitionChanged); - } + widget.surfaceContext.definition.addListener(_onDefinitionChanged); } @override void didUpdateWidget(Surface oldWidget) { super.didUpdateWidget(oldWidget); if (oldWidget.surfaceContext == widget.surfaceContext) return; - - if (oldWidget.surfaceContext is LiveSurfaceContext) { - (oldWidget.surfaceContext as LiveSurfaceContext).surface.removeListener( - _onSurfaceChanged, - ); - _detachComponentsListeners(); - } else { - oldWidget.surfaceContext.definition.removeListener(_onDefinitionChanged); - } - - final LiveSurfaceContext? liveContext = _liveContext; - if (liveContext != null) { - liveContext.surface.addListener(_onSurfaceChanged); - _attachComponentsListeners(liveContext.surface.value); - } else { - widget.surfaceContext.definition.addListener(_onDefinitionChanged); - } + oldWidget.surfaceContext.definition.removeListener(_onDefinitionChanged); + widget.surfaceContext.definition.addListener(_onDefinitionChanged); } @override void dispose() { - final LiveSurfaceContext? liveContext = _liveContext; - if (liveContext != null) { - liveContext.surface.removeListener(_onSurfaceChanged); - _detachComponentsListeners(); - } else { - widget.surfaceContext.definition.removeListener(_onDefinitionChanged); - } + widget.surfaceContext.definition.removeListener(_onDefinitionChanged); super.dispose(); } @@ -107,89 +68,11 @@ class _SurfaceState extends State { if (mounted) setState(() {}); } - void _onSurfaceChanged() { - final core.SurfaceModel? next = _liveContext?.surface.value; - if (!identical(next, _trackedSurface)) { - _detachComponentsListeners(); - _attachComponentsListeners(next); - } - if (mounted) setState(() {}); - } - - void _attachComponentsListeners(core.SurfaceModel? surface) { - _trackedSurface = surface; - if (surface == null) return; - _onCreated = (component) { - if (mounted) setState(() {}); - }; - _onDeleted = (id) { - if (mounted) setState(() {}); - }; - surface.componentsModel.onCreated.addListener(_onCreated!); - surface.componentsModel.onDeleted.addListener(_onDeleted!); - } - - void _detachComponentsListeners() { - final core.SurfaceModel? surface = _trackedSurface; - if (surface != null && _onCreated != null && _onDeleted != null) { - surface.componentsModel.onCreated.removeListener(_onCreated!); - surface.componentsModel.onDeleted.removeListener(_onDeleted!); - } - _onCreated = null; - _onDeleted = null; - _trackedSurface = null; - } - @override Widget build(BuildContext context) { - final LiveSurfaceContext? liveContext = _liveContext; - if (liveContext != null) { - return _buildLiveSurface(liveContext); - } return _buildDefinitionSurface(widget.surfaceContext.definition.value); } - Widget _buildLiveSurface(LiveSurfaceContext surfaceContext) { - final core.SurfaceModel? surface = surfaceContext.surface.value; - genUiLogger.fine('Building surface ${widget.surfaceContext.surfaceId}'); - - if (surface == null) { - genUiLogger.info( - 'Surface ${widget.surfaceContext.surfaceId} has no model yet.', - ); - return widget.defaultBuilder?.call(context) ?? const SizedBox.shrink(); - } - - const rootId = 'root'; - if (surface.componentsModel.get(rootId) == null) { - genUiLogger.warning( - 'Surface ${widget.surfaceContext.surfaceId} has no root component.', - ); - return const SizedBox.shrink(); - } - - final Catalog? catalog = widget.surfaceContext.catalog; - if (catalog == null) { - final error = Exception( - 'Catalog with id "${surface.catalog.id}" not found.', - ); - genUiLogger.severe(error.toString()); - widget.surfaceContext.reportError(error, StackTrace.current); - return FallbackWidget(error: error); - } - - return _buildLiveWidget( - surface, - catalog, - rootId, - DataContext( - widget.surfaceContext.dataModel, - DataPath.root, - functions: catalog.functions, - ), - ); - } - Widget _buildDefinitionSurface(SurfaceDefinition? definition) { genUiLogger.fine('Building surface ${widget.surfaceContext.surfaceId}'); if (definition == null) { @@ -229,62 +112,6 @@ class _SurfaceState extends State { ); } - Widget _buildLiveWidget( - core.SurfaceModel surface, - Catalog catalog, - String widgetId, - DataContext dataContext, - ) { - final core.ComponentModel? data = surface.componentsModel.get(widgetId); - if (data == null) { - final error = Exception('Widget with id: $widgetId not found.'); - genUiLogger.severe(error.toString()); - widget.surfaceContext.reportError(error, StackTrace.current); - return FallbackWidget(error: error); - } - - return _ComponentBuilder( - key: ValueKey(widgetId), - component: data, - builder: (BuildContext ctx) { - try { - genUiLogger.finest('Building widget $widgetId'); - final coreCtx = core.ComponentContext( - surface, - data, - basePath: dataContext.path.toString(), - ); - return catalog.buildWidget( - CatalogItemContext.fromCore( - componentContext: coreCtx, - buildChild: (String childId, [DataContext? childDataContext]) => - _buildLiveWidget( - surface, - catalog, - childId, - childDataContext ?? dataContext, - ), - dispatchEvent: _dispatchEvent, - buildContext: ctx, - dataContext: dataContext, - getCatalogItem: (String type) => - catalog.items.firstWhereOrNull((item) => item.name == type), - reportError: widget.surfaceContext.reportError, - ), - ); - } catch (exception, stackTrace) { - genUiLogger.severe( - 'Error building widget $widgetId', - exception, - stackTrace, - ); - widget.surfaceContext.reportError(exception, stackTrace); - return FallbackWidget(error: exception, stackTrace: stackTrace); - } - }, - ); - } - Widget _buildWidgetFromDefinition( SurfaceDefinition definition, Catalog catalog, @@ -302,27 +129,30 @@ class _SurfaceState extends State { final JsonMap widgetData = data.properties; genUiLogger.finest('Building widget $widgetId'); - return catalog.buildWidget( - CatalogItemContext( - id: widgetId, - data: widgetData, - type: data.type, - buildChild: (String childId, [DataContext? childDataContext]) => - _buildWidgetFromDefinition( - definition, - catalog, - childId, - childDataContext ?? dataContext, - ), - dispatchEvent: _dispatchEvent, - buildContext: context, - dataContext: dataContext, - getComponent: (String componentId) => - definition.components[componentId], - getCatalogItem: (String type) => - catalog.items.firstWhereOrNull((item) => item.name == type), - surfaceId: widget.surfaceContext.surfaceId, - reportError: widget.surfaceContext.reportError, + return KeyedSubtree( + key: ValueKey(widgetId), + child: catalog.buildWidget( + CatalogItemContext( + id: widgetId, + data: widgetData, + type: data.type, + buildChild: (String childId, [DataContext? childDataContext]) => + _buildWidgetFromDefinition( + definition, + catalog, + childId, + childDataContext ?? dataContext, + ), + dispatchEvent: _dispatchEvent, + buildContext: context, + dataContext: dataContext, + getComponent: (String componentId) => + definition.components[componentId], + getCatalogItem: (String type) => + catalog.items.firstWhereOrNull((item) => item.name == type), + surfaceId: widget.surfaceContext.surfaceId, + reportError: widget.surfaceContext.reportError, + ), ), ); } catch (exception, stackTrace) { @@ -399,49 +229,3 @@ class DefaultActionDelegate implements ActionDelegate { return false; } } - -/// Subscribes to a single [core.ComponentModel.onUpdated] and rebuilds its -/// child subtree when that component's properties change. -class _ComponentBuilder extends StatefulWidget { - const _ComponentBuilder({ - super.key, - required this.component, - required this.builder, - }); - - final core.ComponentModel component; - final WidgetBuilder builder; - - @override - State<_ComponentBuilder> createState() => _ComponentBuilderState(); -} - -class _ComponentBuilderState extends State<_ComponentBuilder> { - @override - void initState() { - super.initState(); - widget.component.onUpdated.addListener(_onComponentUpdated); - } - - @override - void didUpdateWidget(_ComponentBuilder oldWidget) { - super.didUpdateWidget(oldWidget); - if (!identical(oldWidget.component, widget.component)) { - oldWidget.component.onUpdated.removeListener(_onComponentUpdated); - widget.component.onUpdated.addListener(_onComponentUpdated); - } - } - - @override - void dispose() { - widget.component.onUpdated.removeListener(_onComponentUpdated); - super.dispose(); - } - - void _onComponentUpdated(core.ComponentModel _) { - if (mounted) setState(() {}); - } - - @override - Widget build(BuildContext context) => widget.builder(context); -} From 5a4709e60061b22f4cdc003f842b7038c4a9d8f9 Mon Sep 17 00:00:00 2001 From: Andrew Kolos Date: Thu, 4 Jun 2026 11:05:22 -0700 Subject: [PATCH 18/49] refactor(genui): consume a2ui_core message types directly Delete the genui-side A2UI message facade classes (A2uiMessage, CreateSurface, UpdateComponents, UpdateDataModel, DeleteSurface). Production code now passes a2ui_core message types straight through: handleMessage, the transport interface/adapter, the parser, and A2uiMessageEvent all use core.A2uiMessage. The only genui-specific piece retained from the old file is the catalog-parameterized message schema builder (a2uiMessageSchema). The parser preserves the friendly "version must be v0.9" error message. Tests build core messages via a small test-only helper (test/test_infra/message_builders.dart) that mirrors the old named-argument shape; component setup uses raw wire JSON. The facade-only message test is removed (those types no longer exist; a2ui_core covers them). Downstream consumers (genui_a2a, examples, dev_tools) are not yet migrated. --- .../development_utilities/catalog_view.dart | 23 +- .../lib/src/engine/surface_controller.dart | 5 +- .../genui/lib/src/facade/prompt_builder.dart | 4 +- .../lib/src/interfaces/a2ui_message_sink.dart | 6 +- .../genui/lib/src/interfaces/transport.dart | 7 +- .../genui/lib/src/model/a2ui_message.dart | 266 +----------------- .../lib/src/model/generation_events.dart | 6 +- .../transport/a2ui_parser_transformer.dart | 28 +- .../src/transport/a2ui_transport_adapter.dart | 19 +- .../core_widgets/audio_player_test.dart | 10 +- .../catalog/core_widgets/button_test.dart | 36 +-- .../test/catalog/core_widgets/card_test.dart | 12 +- .../catalog/core_widgets/check_box_test.dart | 10 +- .../core_widgets/choice_picker_test.dart | 205 +++++++------- .../catalog/core_widgets/column_test.dart | 32 +-- .../core_widgets/date_time_input_test.dart | 10 +- .../catalog/core_widgets/divider_test.dart | 10 +- .../test/catalog/core_widgets/icon_test.dart | 20 +- .../test/catalog/core_widgets/image_test.dart | 18 +- .../test/catalog/core_widgets/list_test.dart | 32 +-- .../test/catalog/core_widgets/modal_test.dart | 14 +- .../test/catalog/core_widgets/row_test.dart | 44 ++- .../catalog/core_widgets/slider_test.dart | 18 +- .../test/catalog/core_widgets/tabs_test.dart | 30 +- .../catalog/core_widgets/text_field_test.dart | 52 ++-- .../genui/test/catalog/core_widgets_test.dart | 42 ++- ...2ui_message_processor_validation_test.dart | 10 +- .../test/engine/surface_controller_test.dart | 87 +++--- packages/genui/test/error_reporting_test.dart | 8 +- .../genui/test/facade/conversation_test.dart | 10 +- packages/genui/test/genui_surface_test.dart | 47 ++-- .../genui/test/model/a2ui_message_test.dart | 230 --------------- .../test/test_infra/message_builders.dart | 70 +++++ .../test_infra/validation_test_utils.dart | 8 +- .../a2ui_parser_transformer_test.dart | 33 ++- .../a2ui_transport_adapter_test.dart | 23 +- 36 files changed, 538 insertions(+), 947 deletions(-) delete mode 100644 packages/genui/test/model/a2ui_message_test.dart create mode 100644 packages/genui/test/test_infra/message_builders.dart diff --git a/packages/genui/lib/src/development_utilities/catalog_view.dart b/packages/genui/lib/src/development_utilities/catalog_view.dart index 4c095d6e9..ba750580c 100644 --- a/packages/genui/lib/src/development_utilities/catalog_view.dart +++ b/packages/genui/lib/src/development_utilities/catalog_view.dart @@ -5,15 +5,13 @@ import 'dart:async' show StreamSubscription; import 'dart:convert'; -import 'package:collection/collection.dart'; +import 'package:a2ui_core/a2ui_core.dart' as core; import 'package:flutter/material.dart'; import '../engine/surface_controller.dart'; -import '../model/a2ui_message.dart'; import '../model/catalog.dart'; import '../model/catalog_item.dart'; import '../model/chat_message.dart'; -import '../model/ui_models.dart'; import '../primitives/logging.dart'; import '../primitives/simple_items.dart'; import '../widgets/surface.dart'; @@ -73,14 +71,11 @@ class _DebugCatalogViewState extends State { try { final exampleData = jsonDecode(exampleJsonString) as List; - final List components = exampleData - .map((e) => Component.fromJson(e as JsonMap)) + final List components = exampleData + .map((e) => e as JsonMap) .toList(); - Component? rootComponent; - rootComponent = components.firstWhereOrNull((c) => c.id == 'root'); - - if (rootComponent == null) { + if (!components.any((c) => c['id'] == 'root')) { genUiLogger.info( 'Skipping example for ${item.name} because it is missing a root ' 'component.', @@ -89,10 +84,16 @@ class _DebugCatalogViewState extends State { } _surfaceController.handleMessage( - UpdateComponents(surfaceId: surfaceId, components: components), + core.UpdateComponentsMessage( + surfaceId: surfaceId, + components: components, + ), ); _surfaceController.handleMessage( - CreateSurface(surfaceId: surfaceId, catalogId: catalog.catalogId!), + core.CreateSurfaceMessage( + surfaceId: surfaceId, + catalogId: catalog.catalogId!, + ), ); surfaceIds.add(surfaceId); } catch (exception, stackTrace) { diff --git a/packages/genui/lib/src/engine/surface_controller.dart b/packages/genui/lib/src/engine/surface_controller.dart index e03a47b61..e9cb06dfe 100644 --- a/packages/genui/lib/src/engine/surface_controller.dart +++ b/packages/genui/lib/src/engine/surface_controller.dart @@ -13,7 +13,6 @@ import '../interfaces/a2ui_message_sink.dart'; import '../interfaces/surface_context.dart'; import '../interfaces/surface_host.dart'; import '../model/a2ui_client_capabilities.dart'; -import '../model/a2ui_message.dart'; import '../model/catalog.dart'; import '../model/chat_message.dart'; import '../model/data_model.dart'; @@ -101,11 +100,11 @@ interface class SurfaceController implements SurfaceHost, A2uiMessageSink { /// Processes a message from the AI service. @override - void handleMessage(A2uiMessage message) { + void handleMessage(core.A2uiMessage message) { genUiLogger.info( 'SurfaceController.handleMessage received: ${message.runtimeType}', ); - _handleCoreMessage(message.toCoreMessage()); + _handleCoreMessage(message); } void _handleCoreMessage(core.A2uiMessage coreMessage) { diff --git a/packages/genui/lib/src/facade/prompt_builder.dart b/packages/genui/lib/src/facade/prompt_builder.dart index 77ecada7c..68a1bba51 100644 --- a/packages/genui/lib/src/facade/prompt_builder.dart +++ b/packages/genui/lib/src/facade/prompt_builder.dart @@ -359,9 +359,7 @@ final class _BasicPromptBuilder extends PromptBuilder { @override Iterable systemPrompt() { - final String a2uiSchema = A2uiMessage.a2uiMessageSchema( - catalog, - ).toJson(indent: ' '); + final String a2uiSchema = a2uiMessageSchema(catalog).toJson(indent: ' '); final fragments = [ ...systemPromptFragments, diff --git a/packages/genui/lib/src/interfaces/a2ui_message_sink.dart b/packages/genui/lib/src/interfaces/a2ui_message_sink.dart index 869aec6c6..c8e71ee7a 100644 --- a/packages/genui/lib/src/interfaces/a2ui_message_sink.dart +++ b/packages/genui/lib/src/interfaces/a2ui_message_sink.dart @@ -2,10 +2,10 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. -import '../model/a2ui_message.dart'; +import 'package:a2ui_core/a2ui_core.dart' as core; -/// An interface for a message sink that accepts [A2uiMessage]s. +/// An interface for a message sink that accepts [core.A2uiMessage]s. abstract interface class A2uiMessageSink { /// Handles a message from the client. - void handleMessage(A2uiMessage message); + void handleMessage(core.A2uiMessage message); } diff --git a/packages/genui/lib/src/interfaces/transport.dart b/packages/genui/lib/src/interfaces/transport.dart index ab53ffaed..46e52807a 100644 --- a/packages/genui/lib/src/interfaces/transport.dart +++ b/packages/genui/lib/src/interfaces/transport.dart @@ -4,7 +4,8 @@ import 'dart:async'; -import '../model/a2ui_message.dart'; +import 'package:a2ui_core/a2ui_core.dart' as core; + import '../model/chat_message.dart'; /// An interface for transporting messages between GenUI and an AI service. @@ -18,8 +19,8 @@ abstract interface class Transport { /// up over time. Stream get incomingText; - /// A stream of parsed [A2uiMessage]s received from the AI service. - Stream get incomingMessages; + /// A stream of parsed [core.A2uiMessage]s received from the AI service. + Stream get incomingMessages; /// Sends a request to the AI service. Future sendRequest(ChatMessage message); diff --git a/packages/genui/lib/src/model/a2ui_message.dart b/packages/genui/lib/src/model/a2ui_message.dart index 69f1fc4c7..d6e702e6a 100644 --- a/packages/genui/lib/src/model/a2ui_message.dart +++ b/packages/genui/lib/src/model/a2ui_message.dart @@ -2,71 +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:json_schema_builder/json_schema_builder.dart'; -import '../primitives/a2ui_validation_exception.dart'; -import '../primitives/simple_items.dart'; import 'a2ui_schemas.dart'; import 'catalog.dart'; -import 'data_model.dart'; -import 'ui_models.dart'; - -/// A source-compatible GenUI facade for A2UI protocol messages. -/// -/// The canonical parser and processor live in `a2ui_core`; these classes keep -/// the legacy GenUI names while converting to/from the core message types at -/// the renderer boundary. -abstract class A2uiMessage { - const A2uiMessage({this.version = 'v0.9'}); - - factory A2uiMessage.fromJson(JsonMap json) { - try { - return A2uiMessage.fromCore( - core.A2uiMessage.fromJson(Map.from(json)), - ); - } on core.A2uiValidationError catch (e) { - String message = e.message; - if (message.contains("'version'")) { - message = 'A2UI message must have version "v0.9"'; - } - throw A2uiValidationException(message, json: json, cause: e); - } catch (e) { - throw A2uiValidationException( - 'Failed to parse A2UI message', - json: json, - cause: e, - ); - } - } - - /// Creates a facade message from a core message. - factory A2uiMessage.fromCore(core.A2uiMessage message) { - return switch (message) { - core.CreateSurfaceMessage() => CreateSurface.fromCore(message), - core.UpdateComponentsMessage() => UpdateComponents.fromCore(message), - core.UpdateDataModelMessage() => UpdateDataModel.fromCore(message), - core.DeleteSurfaceMessage() => DeleteSurface.fromCore(message), - _ => throw A2uiValidationException( - 'Unknown A2UI message type: ${message.runtimeType}', - ), - }; - } - - /// Returns the JSON schema for an A2UI message. - static Schema a2uiMessageSchema(Catalog catalog) => - _buildA2uiMessageSchema(catalog); - - /// The protocol version. - final String version; - - /// Converts this facade message to the core substrate message. - core.A2uiMessage toCoreMessage(); - - Map toJson() => toCoreMessage().toJson(); -} /// Returns the JSON schema for an A2UI message, parameterized by [catalog]. +/// +/// The message types themselves live in `package:a2ui_core`; this schema is +/// GenUI-specific because it is parameterized by the renderer's [Catalog]. Schema a2uiMessageSchema(Catalog catalog) => _buildA2uiMessageSchema(catalog); Schema _buildA2uiMessageSchema(Catalog catalog) { @@ -111,207 +55,3 @@ Schema _buildA2uiMessageSchema(Catalog catalog) { ], ); } - -/// An A2UI message that signals the client to create and show a new surface. -final class CreateSurface extends A2uiMessage { - const CreateSurface({ - super.version, - required this.surfaceId, - required this.catalogId, - this.theme, - this.sendDataModel = false, - }); - - /// Creates a [CreateSurface] message from a JSON map body. - factory CreateSurface.fromJson(JsonMap json) { - return CreateSurface( - surfaceId: json[surfaceIdKey] as String, - catalogId: json['catalogId'] as String, - theme: json['theme'] as JsonMap?, - sendDataModel: json['sendDataModel'] as bool? ?? false, - ); - } - - factory CreateSurface.fromCore(core.CreateSurfaceMessage message) { - return CreateSurface( - version: message.version, - surfaceId: message.surfaceId, - catalogId: message.catalogId, - theme: message.theme == null ? null : JsonMap.from(message.theme!), - sendDataModel: message.sendDataModel, - ); - } - - /// The ID of the surface that this message applies to. - final String surfaceId; - - /// The ID of the catalog to use for rendering this surface. - final String catalogId; - - /// The theme parameters for this surface. - final JsonMap? theme; - - /// If true, the client sends the full data model in A2A metadata. - final bool sendDataModel; - - @override - core.CreateSurfaceMessage toCoreMessage() { - return core.CreateSurfaceMessage( - version: version, - surfaceId: surfaceId, - catalogId: catalogId, - theme: theme == null ? null : Map.from(theme!), - sendDataModel: sendDataModel, - ); - } -} - -/// An A2UI message that updates a surface with new components. -final class UpdateComponents extends A2uiMessage { - const UpdateComponents({ - super.version, - required this.surfaceId, - required this.components, - }); - - /// Creates an [UpdateComponents] message from a JSON map body. - factory UpdateComponents.fromJson(JsonMap json) { - return UpdateComponents( - surfaceId: json[surfaceIdKey] as String, - components: (json['components'] as List) - .map((e) => Component.fromJson(e as JsonMap)) - .toList(), - ); - } - - factory UpdateComponents.fromCore(core.UpdateComponentsMessage message) { - return UpdateComponents( - version: message.version, - surfaceId: message.surfaceId, - components: message.components - .map((json) => Component.fromJson(JsonMap.from(json))) - .toList(), - ); - } - - /// The ID of the surface that this message applies to. - final String surfaceId; - - /// The list of components to add or update. - final List components; - - @override - core.UpdateComponentsMessage toCoreMessage() { - return core.UpdateComponentsMessage( - version: version, - surfaceId: surfaceId, - components: components.map((c) => c.toCoreJson()).toList(), - ); - } -} - -/// An A2UI message that updates the data model. -final class UpdateDataModel extends A2uiMessage { - /// Creates an [UpdateDataModel] message that sets [path] to [value]. - const UpdateDataModel({ - super.version, - required this.surfaceId, - this.path = DataPath.root, - this.value, - }) : hasValue = true; - - /// Creates an [UpdateDataModel] message that removes the key at [path]. - const UpdateDataModel.removeKey({ - super.version, - required this.surfaceId, - this.path = DataPath.root, - }) : value = null, - hasValue = false; - - /// Creates an [UpdateDataModel] message from a JSON map body. - factory UpdateDataModel.fromJson(JsonMap json) { - final path = DataPath(json['path'] as String? ?? '/'); - if (json.containsKey('value')) { - return UpdateDataModel( - surfaceId: json[surfaceIdKey] as String, - path: path, - value: json['value'], - ); - } - return UpdateDataModel.removeKey( - surfaceId: json[surfaceIdKey] as String, - path: path, - ); - } - - factory UpdateDataModel.fromCore(core.UpdateDataModelMessage message) { - final path = DataPath(message.path ?? '/'); - if (message.hasValue) { - return UpdateDataModel( - version: message.version, - surfaceId: message.surfaceId, - path: path, - value: message.value, - ); - } - return UpdateDataModel.removeKey( - version: message.version, - surfaceId: message.surfaceId, - path: path, - ); - } - - /// The ID of the surface that this message applies to. - final String surfaceId; - - /// The path in the data model to update. Defaults to root '/'. - final DataPath path; - - /// The new value to write to the data model. - final Object? value; - - /// Whether the wire JSON carries an explicit `value` key. - final bool hasValue; - - @override - core.UpdateDataModelMessage toCoreMessage() { - if (!hasValue) { - return core.UpdateDataModelMessage.removeKey( - version: version, - surfaceId: surfaceId, - path: path.toString(), - ); - } - return core.UpdateDataModelMessage( - version: version, - surfaceId: surfaceId, - path: path.toString(), - value: value, - ); - } -} - -/// An A2UI message that deletes a surface. -final class DeleteSurface extends A2uiMessage { - const DeleteSurface({super.version, required this.surfaceId}); - - /// Creates a [DeleteSurface] message from a JSON map body. - factory DeleteSurface.fromJson(JsonMap json) { - return DeleteSurface(surfaceId: json[surfaceIdKey] as String); - } - - factory DeleteSurface.fromCore(core.DeleteSurfaceMessage message) { - return DeleteSurface( - version: message.version, - surfaceId: message.surfaceId, - ); - } - - /// The ID of the surface that this message applies to. - final String surfaceId; - - @override - core.DeleteSurfaceMessage toCoreMessage() { - return core.DeleteSurfaceMessage(version: version, surfaceId: surfaceId); - } -} diff --git a/packages/genui/lib/src/model/generation_events.dart b/packages/genui/lib/src/model/generation_events.dart index c441602f7..3894b0616 100644 --- a/packages/genui/lib/src/model/generation_events.dart +++ b/packages/genui/lib/src/model/generation_events.dart @@ -2,7 +2,7 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. -import 'a2ui_message.dart'; +import 'package:a2ui_core/a2ui_core.dart' as core; /// A base class for events related to the GenUI generation process. sealed class GenerationEvent { @@ -18,11 +18,11 @@ class TextEvent extends GenerationEvent { final String text; } -/// An event containing a parsed [A2uiMessage]. +/// An event containing a parsed [core.A2uiMessage]. class A2uiMessageEvent extends GenerationEvent { /// Creates an [A2uiMessageEvent] with the given [message]. const A2uiMessageEvent(this.message); /// The parsed message. - final A2uiMessage message; + final core.A2uiMessage message; } diff --git a/packages/genui/lib/src/transport/a2ui_parser_transformer.dart b/packages/genui/lib/src/transport/a2ui_parser_transformer.dart index 14ce0ae3d..50597d30a 100644 --- a/packages/genui/lib/src/transport/a2ui_parser_transformer.dart +++ b/packages/genui/lib/src/transport/a2ui_parser_transformer.dart @@ -5,7 +5,8 @@ import 'dart:async'; import 'dart:convert'; -import '../model/a2ui_message.dart'; +import 'package:a2ui_core/a2ui_core.dart' as core; + import '../model/generation_events.dart'; import '../primitives/a2ui_validation_exception.dart'; @@ -210,16 +211,18 @@ class _A2uiParserStream { void _tryEmitOne(Map json) { try { - _controller.add(A2uiMessageEvent(A2uiMessage.fromJson(json))); + _controller.add(A2uiMessageEvent(_parseMessage(json))); _wasLastEventA2ui = true; } catch (e) { if (_looksLikeA2uiMessage(json)) { _controller.addError( - A2uiValidationException( - 'Failed to parse A2UI message', - json: json, - cause: e, - ), + e is A2uiValidationException + ? e + : A2uiValidationException( + 'Failed to parse A2UI message', + json: json, + cause: e, + ), ); } else { // Not an A2UI message; emit as plain text. @@ -229,6 +232,17 @@ class _A2uiParserStream { } } + core.A2uiMessage _parseMessage(Map json) { + try { + return core.A2uiMessage.fromJson(json); + } on core.A2uiValidationError catch (e) { + final String message = e.message.contains("'version'") + ? 'A2UI message must have version "v0.9"' + : e.message; + throw A2uiValidationException(message, json: json, cause: e); + } + } + _Match? _findMarkdownJson(String text) { final regex = RegExp(r'```(?:json)?\s*([\s\S]*?)\s*```'); final RegExpMatch? match = regex.firstMatch(text); diff --git a/packages/genui/lib/src/transport/a2ui_transport_adapter.dart b/packages/genui/lib/src/transport/a2ui_transport_adapter.dart index 5eac93b30..7eacaa883 100644 --- a/packages/genui/lib/src/transport/a2ui_transport_adapter.dart +++ b/packages/genui/lib/src/transport/a2ui_transport_adapter.dart @@ -7,7 +7,6 @@ import 'dart:async'; import 'package:a2ui_core/a2ui_core.dart' as core; import '../interfaces/transport.dart'; -import '../model/a2ui_message.dart'; import '../model/chat_message.dart'; import '../model/generation_events.dart'; import 'a2ui_parser_transformer.dart'; @@ -39,7 +38,7 @@ class A2uiTransportAdapter implements Transport { final ManualSendCallback? onSend; final StreamController _inputStream = StreamController(); - final StreamController _messageStream = + final StreamController _messageStream = StreamController.broadcast(); late final Stream _pipeline; StreamSubscription? _pipelineSubscription; @@ -57,18 +56,8 @@ class A2uiTransportAdapter implements Transport { } /// Feeds a raw A2UI message (e.g. from a tool output or separate channel). - void addMessage(Object message) { - if (message is A2uiMessage) { - _messageStream.add(message); - return; - } - if (message is core.A2uiMessage) { - _messageStream.add(A2uiMessage.fromCore(message)); - return; - } - throw ArgumentError( - 'Unsupported A2UI message type: ${message.runtimeType}', - ); + void addMessage(core.A2uiMessage message) { + _messageStream.add(message); } /// A stream of sanitizer text for the chat UI. @@ -81,7 +70,7 @@ class A2uiTransportAdapter implements Transport { /// A stream of A2UI messages parsed from the input. @override - Stream get incomingMessages => _messageStream.stream; + Stream get incomingMessages => _messageStream.stream; @override Future sendRequest(ChatMessage message) async { diff --git a/packages/genui/test/catalog/core_widgets/audio_player_test.dart b/packages/genui/test/catalog/core_widgets/audio_player_test.dart index 7d115e0d1..3a7b6cfab 100644 --- a/packages/genui/test/catalog/core_widgets/audio_player_test.dart +++ b/packages/genui/test/catalog/core_widgets/audio_player_test.dart @@ -6,6 +6,8 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:genui/genui.dart'; +import '../../test_infra/message_builders.dart'; + void main() { testWidgets('AudioPlayer widget renders and has description in semantics', ( WidgetTester tester, @@ -16,8 +18,8 @@ void main() { ], ); const surfaceId = 'testSurface'; - final components = [ - const Component( + final List components = [ + component( id: 'root', type: 'AudioPlayer', properties: { @@ -27,10 +29,10 @@ void main() { ), ]; surfaceController.handleMessage( - UpdateComponents(surfaceId: surfaceId, components: components), + updateComponents(surfaceId: surfaceId, components: components), ); surfaceController.handleMessage( - const CreateSurface(surfaceId: surfaceId, catalogId: 'test_catalog'), + createSurface(surfaceId: surfaceId, catalogId: 'test_catalog'), ); await tester.pumpWidget( diff --git a/packages/genui/test/catalog/core_widgets/button_test.dart b/packages/genui/test/catalog/core_widgets/button_test.dart index 5df6ef82b..8743a9691 100644 --- a/packages/genui/test/catalog/core_widgets/button_test.dart +++ b/packages/genui/test/catalog/core_widgets/button_test.dart @@ -10,6 +10,8 @@ import 'package:genui/genui.dart'; import 'package:json_schema_builder/json_schema_builder.dart'; +import '../../test_infra/message_builders.dart'; + void main() { testWidgets('Button widget renders and handles taps', ( WidgetTester tester, @@ -25,8 +27,8 @@ void main() { ); surfaceController.onSubmit.listen((event) => message = event); const surfaceId = 'testSurface'; - final components = [ - const Component( + final List components = [ + component( id: 'root', type: 'Button', properties: { @@ -36,17 +38,17 @@ void main() { }, }, ), - const Component( + component( id: 'button_text', type: 'Text', properties: {'text': 'Click Me'}, ), ]; surfaceController.handleMessage( - UpdateComponents(surfaceId: surfaceId, components: components), + updateComponents(surfaceId: surfaceId, components: components), ); surfaceController.handleMessage( - const CreateSurface(surfaceId: surfaceId, catalogId: 'test_catalog'), + createSurface(surfaceId: surfaceId, catalogId: 'test_catalog'), ); await tester.pumpWidget( @@ -95,8 +97,8 @@ void main() { surfaceController.onSubmit.listen((event) => message = event); const surfaceId = 'testSurface'; - final components = [ - const Component( + final List components = [ + component( id: 'root', type: 'Button', properties: { @@ -104,17 +106,17 @@ void main() { 'action': {'call': 'throwError'}, }, ), - const Component( + component( id: 'button_text', type: 'Text', properties: {'text': 'Click Me'}, ), ]; surfaceController.handleMessage( - UpdateComponents(surfaceId: surfaceId, components: components), + updateComponents(surfaceId: surfaceId, components: components), ); surfaceController.handleMessage( - const CreateSurface(surfaceId: surfaceId, catalogId: 'test_catalog'), + createSurface(surfaceId: surfaceId, catalogId: 'test_catalog'), ); await tester.pumpWidget( @@ -168,15 +170,15 @@ void main() { const surfaceId = 'validationTest'; // Initialize with a value that fails the check surfaceController.handleMessage( - UpdateDataModel( + updateDataModel( surfaceId: surfaceId, path: DataPath('/count'), value: 0, ), ); - final components = [ - const Component( + final List components = [ + component( id: 'root', type: 'Button', properties: { @@ -198,7 +200,7 @@ void main() { ], }, ), - const Component( + component( id: 'button_text', type: 'Text', properties: {'text': 'Click Me'}, @@ -206,10 +208,10 @@ void main() { ]; surfaceController.handleMessage( - UpdateComponents(surfaceId: surfaceId, components: components), + updateComponents(surfaceId: surfaceId, components: components), ); surfaceController.handleMessage( - const CreateSurface(surfaceId: surfaceId, catalogId: 'test_catalog'), + createSurface(surfaceId: surfaceId, catalogId: 'test_catalog'), ); await tester.pumpWidget( @@ -238,7 +240,7 @@ void main() { // Update data model to pass the check surfaceController.handleMessage( - UpdateDataModel( + updateDataModel( surfaceId: surfaceId, path: DataPath('/count'), value: 1, diff --git a/packages/genui/test/catalog/core_widgets/card_test.dart b/packages/genui/test/catalog/core_widgets/card_test.dart index bf54d6938..2b54e2a92 100644 --- a/packages/genui/test/catalog/core_widgets/card_test.dart +++ b/packages/genui/test/catalog/core_widgets/card_test.dart @@ -6,6 +6,8 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:genui/genui.dart'; +import '../../test_infra/message_builders.dart'; + void main() { testWidgets('Card widget renders child', (WidgetTester tester) async { final surfaceController = SurfaceController( @@ -17,19 +19,19 @@ void main() { ], ); const surfaceId = 'testSurface'; - final components = [ - const Component(id: 'root', type: 'Card', properties: {'child': 'text'}), - const Component( + final List components = [ + component(id: 'root', type: 'Card', properties: {'child': 'text'}), + component( id: 'text', type: 'Text', properties: {'text': 'This is a card.'}, ), ]; surfaceController.handleMessage( - UpdateComponents(surfaceId: surfaceId, components: components), + updateComponents(surfaceId: surfaceId, components: components), ); surfaceController.handleMessage( - const CreateSurface(surfaceId: surfaceId, catalogId: 'test_catalog'), + createSurface(surfaceId: surfaceId, catalogId: 'test_catalog'), ); await tester.pumpWidget( diff --git a/packages/genui/test/catalog/core_widgets/check_box_test.dart b/packages/genui/test/catalog/core_widgets/check_box_test.dart index de6aced90..38aea9824 100644 --- a/packages/genui/test/catalog/core_widgets/check_box_test.dart +++ b/packages/genui/test/catalog/core_widgets/check_box_test.dart @@ -6,6 +6,8 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:genui/genui.dart'; +import '../../test_infra/message_builders.dart'; + void main() { testWidgets('CheckBox widget renders and handles changes', ( WidgetTester tester, @@ -16,8 +18,8 @@ void main() { ], ); const surfaceId = 'testSurface'; - final components = [ - const Component( + final List components = [ + component( id: 'root', type: 'CheckBox', properties: { @@ -27,10 +29,10 @@ void main() { ), ]; surfaceController.handleMessage( - UpdateComponents(surfaceId: surfaceId, components: components), + updateComponents(surfaceId: surfaceId, components: components), ); surfaceController.handleMessage( - const CreateSurface(surfaceId: surfaceId, catalogId: 'test_catalog'), + createSurface(surfaceId: surfaceId, catalogId: 'test_catalog'), ); surfaceController .contextFor(surfaceId) diff --git a/packages/genui/test/catalog/core_widgets/choice_picker_test.dart b/packages/genui/test/catalog/core_widgets/choice_picker_test.dart index 1f23e4b6a..7bd8820c2 100644 --- a/packages/genui/test/catalog/core_widgets/choice_picker_test.dart +++ b/packages/genui/test/catalog/core_widgets/choice_picker_test.dart @@ -2,10 +2,13 @@ // 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'; +import '../../test_infra/message_builders.dart'; + void main() { // Test case based on jobApplication.1.sample testWidgets( @@ -17,12 +20,12 @@ void main() { final controller = SurfaceController(catalogs: [catalog]); // Initial message to create surface and components - final createSurface = const CreateSurface( + final core.CreateSurfaceMessage createSurfaceMessage = createSurface( surfaceId: 'test', catalogId: 'test', ); - final updateData = const UpdateDataModel( + final core.UpdateDataModelMessage updateData = updateDataModel( surfaceId: 'test', value: { 'experience': '2-5', // Single string value, not a list @@ -30,29 +33,30 @@ void main() { path: DataPath.root, ); - final updateComponents = const UpdateComponents( - surfaceId: 'test', - components: [ - Component( - id: 'root', - type: 'ChoicePicker', - properties: { - 'label': 'Years of Experience', - 'variant': 'mutuallyExclusive', - 'options': [ - {'label': '0-1', 'value': '0-1'}, - {'label': '2-5', 'value': '2-5'}, - {'label': '5+', 'value': '5+'}, - ], - 'value': {'path': '/experience'}, - }, - ), - ], - ); + final core.UpdateComponentsMessage updateComponentsMessage = + updateComponents( + surfaceId: 'test', + components: [ + component( + id: 'root', + type: 'ChoicePicker', + properties: { + 'label': 'Years of Experience', + 'variant': 'mutuallyExclusive', + 'options': [ + {'label': '0-1', 'value': '0-1'}, + {'label': '2-5', 'value': '2-5'}, + {'label': '5+', 'value': '5+'}, + ], + 'value': {'path': '/experience'}, + }, + ), + ], + ); - controller.handleMessage(createSurface); + controller.handleMessage(createSurfaceMessage); controller.handleMessage(updateData); - controller.handleMessage(updateComponents); + controller.handleMessage(updateComponentsMessage); await tester.pumpWidget( MaterialApp( @@ -90,7 +94,7 @@ void main() { // Update data model to another single string controller.handleMessage( - UpdateDataModel( + updateDataModel( surfaceId: 'test', path: DataPath('/experience'), value: '5+', @@ -112,40 +116,41 @@ void main() { final controller = SurfaceController(catalogs: [catalog]); - final createSurface = const CreateSurface( + final core.CreateSurfaceMessage createSurfaceMessage = createSurface( surfaceId: 'test2', catalogId: 'std', ); - final updateData = const UpdateDataModel( + final core.UpdateDataModelMessage updateData = updateDataModel( surfaceId: 'test2', value: { 'selections': ['A', 'B'], }, path: DataPath.root, ); - final updateComponents = const UpdateComponents( - surfaceId: 'test2', - components: [ - Component( - id: 'root', - type: 'ChoicePicker', - properties: { - 'label': 'Multi', - 'variant': 'multipleSelection', - 'options': [ - {'label': 'A', 'value': 'A'}, - {'label': 'B', 'value': 'B'}, - {'label': 'C', 'value': 'C'}, - ], - 'value': {'path': '/selections'}, - }, - ), - ], - ); + final core.UpdateComponentsMessage updateComponentsMessage = + updateComponents( + surfaceId: 'test2', + components: [ + component( + id: 'root', + type: 'ChoicePicker', + properties: { + 'label': 'Multi', + 'variant': 'multipleSelection', + 'options': [ + {'label': 'A', 'value': 'A'}, + {'label': 'B', 'value': 'B'}, + {'label': 'C', 'value': 'C'}, + ], + 'value': {'path': '/selections'}, + }, + ), + ], + ); - controller.handleMessage(createSurface); + controller.handleMessage(createSurfaceMessage); controller.handleMessage(updateData); - controller.handleMessage(updateComponents); + controller.handleMessage(updateComponentsMessage); await tester.pumpWidget( MaterialApp( @@ -175,42 +180,43 @@ void main() { final catalog = Catalog([choicePicker], catalogId: 'std'); final controller = SurfaceController(catalogs: [catalog]); - final createSurface = const CreateSurface( + final core.CreateSurfaceMessage createSurfaceMessage = createSurface( surfaceId: 'chipsTest', catalogId: 'std', ); - final updateData = const UpdateDataModel( + final core.UpdateDataModelMessage updateData = updateDataModel( surfaceId: 'chipsTest', value: { 'tags': ['flutter'], }, path: DataPath.root, ); - final updateComponents = const UpdateComponents( - surfaceId: 'chipsTest', - components: [ - Component( - id: 'root', - type: 'ChoicePicker', - properties: { - 'label': 'Tags', - 'variant': 'multipleSelection', - 'displayStyle': 'chips', - 'filterable': true, - 'options': [ - {'label': 'Flutter', 'value': 'flutter'}, - {'label': 'Dart', 'value': 'dart'}, - {'label': 'GenUI', 'value': 'genui'}, - ], - 'value': {'path': '/tags'}, - }, - ), - ], - ); + final core.UpdateComponentsMessage updateComponentsMessage = + updateComponents( + surfaceId: 'chipsTest', + components: [ + component( + id: 'root', + type: 'ChoicePicker', + properties: { + 'label': 'Tags', + 'variant': 'multipleSelection', + 'displayStyle': 'chips', + 'filterable': true, + 'options': [ + {'label': 'Flutter', 'value': 'flutter'}, + {'label': 'Dart', 'value': 'dart'}, + {'label': 'GenUI', 'value': 'genui'}, + ], + 'value': {'path': '/tags'}, + }, + ), + ], + ); - controller.handleMessage(createSurface); + controller.handleMessage(createSurfaceMessage); controller.handleMessage(updateData); - controller.handleMessage(updateComponents); + controller.handleMessage(updateComponentsMessage); await tester.pumpWidget( MaterialApp( @@ -253,32 +259,33 @@ void main() { final catalog = Catalog([choicePicker], catalogId: 'std'); final controller = SurfaceController(catalogs: [catalog]); - final createSurface = const CreateSurface( + final core.CreateSurfaceMessage createSurfaceMessage = createSurface( surfaceId: 'nullTest', catalogId: 'std', ); // Note: We are NOT sending UpdateDataModel with the value initially. - final updateComponents = const UpdateComponents( - surfaceId: 'nullTest', - components: [ - Component( - id: 'root', - type: 'ChoicePicker', - properties: { - 'label': 'Null Check', - 'variant': 'multipleSelection', - 'options': [ - {'label': 'A', 'value': 'A'}, - ], - // Points to a path that doesn't exist yet - 'value': {'path': '/missing_path'}, - }, - ), - ], - ); + final core.UpdateComponentsMessage updateComponentsMessage = + updateComponents( + surfaceId: 'nullTest', + components: [ + component( + id: 'root', + type: 'ChoicePicker', + properties: { + 'label': 'Null Check', + 'variant': 'multipleSelection', + 'options': [ + {'label': 'A', 'value': 'A'}, + ], + // Points to a path that doesn't exist yet + 'value': {'path': '/missing_path'}, + }, + ), + ], + ); - controller.handleMessage(createSurface); - controller.handleMessage(updateComponents); + controller.handleMessage(createSurfaceMessage); + controller.handleMessage(updateComponentsMessage); await tester.pumpWidget( MaterialApp( @@ -310,15 +317,15 @@ void main() { // Store a non-list string (should be treated as selected option if // it matches). controller.handleMessage( - UpdateDataModel( + updateDataModel( surfaceId: surfaceId, path: DataPath('/choice'), value: 'option1', ), ); - final components = [ - const Component( + final List components = [ + component( id: 'root', type: 'ChoicePicker', properties: { @@ -333,10 +340,10 @@ void main() { ]; controller.handleMessage( - UpdateComponents(surfaceId: surfaceId, components: components), + updateComponents(surfaceId: surfaceId, components: components), ); controller.handleMessage( - const CreateSurface(surfaceId: surfaceId, catalogId: 'std'), + createSurface(surfaceId: surfaceId, catalogId: 'std'), ); await tester.pumpWidget( @@ -360,7 +367,7 @@ void main() { // Also test number type controller.handleMessage( - UpdateDataModel( + updateDataModel( surfaceId: surfaceId, path: DataPath('/choice'), value: 123, diff --git a/packages/genui/test/catalog/core_widgets/column_test.dart b/packages/genui/test/catalog/core_widgets/column_test.dart index b6366d296..e4431e161 100644 --- a/packages/genui/test/catalog/core_widgets/column_test.dart +++ b/packages/genui/test/catalog/core_widgets/column_test.dart @@ -6,6 +6,8 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:genui/genui.dart'; +import '../../test_infra/message_builders.dart'; + void main() { testWidgets('Column widget renders children', (WidgetTester tester) async { final surfaceController = SurfaceController( @@ -17,26 +19,22 @@ void main() { ], ); const surfaceId = 'testSurface'; - final components = [ - const Component( + final List components = [ + component( id: 'root', type: 'Column', properties: { 'children': ['text1', 'text2'], }, ), - const Component(id: 'text1', type: 'Text', properties: {'text': 'First'}), - const Component( - id: 'text2', - type: 'Text', - properties: {'text': 'Second'}, - ), + component(id: 'text1', type: 'Text', properties: {'text': 'First'}), + component(id: 'text2', type: 'Text', properties: {'text': 'Second'}), ]; surfaceController.handleMessage( - UpdateComponents(surfaceId: surfaceId, components: components), + updateComponents(surfaceId: surfaceId, components: components), ); surfaceController.handleMessage( - const CreateSurface(surfaceId: surfaceId, catalogId: 'test_catalog'), + createSurface(surfaceId: surfaceId, catalogId: 'test_catalog'), ); await tester.pumpWidget( @@ -65,31 +63,31 @@ void main() { ], ); const surfaceId = 'testSurface'; - final components = [ - const Component( + final List components = [ + component( id: 'root', type: 'Column', properties: { 'children': ['text1', 'text2', 'text3'], }, ), - const Component( + component( id: 'text1', type: 'Text', properties: {'text': 'First', 'weight': 1}, ), - const Component( + component( id: 'text2', type: 'Text', properties: {'text': 'Second', 'weight': 2}, ), - const Component(id: 'text3', type: 'Text', properties: {'text': 'Third'}), + component(id: 'text3', type: 'Text', properties: {'text': 'Third'}), ]; surfaceController.handleMessage( - UpdateComponents(surfaceId: surfaceId, components: components), + updateComponents(surfaceId: surfaceId, components: components), ); surfaceController.handleMessage( - const CreateSurface(surfaceId: surfaceId, catalogId: 'test_catalog'), + createSurface(surfaceId: surfaceId, catalogId: 'test_catalog'), ); await tester.pumpWidget( diff --git a/packages/genui/test/catalog/core_widgets/date_time_input_test.dart b/packages/genui/test/catalog/core_widgets/date_time_input_test.dart index 5e216de70..7387837ad 100644 --- a/packages/genui/test/catalog/core_widgets/date_time_input_test.dart +++ b/packages/genui/test/catalog/core_widgets/date_time_input_test.dart @@ -6,6 +6,8 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:genui/genui.dart'; +import '../../test_infra/message_builders.dart'; + void main() { testWidgets('renders and handles explicit updates', (tester) async { final robot = DateTimeInputRobot(tester); @@ -262,15 +264,15 @@ void main() { final surfaceController = SurfaceController(catalogs: [catalog]); const surfaceId = 'testSurface'; - final components = [ - Component(id: 'root', type: 'DateTimeInput', properties: props), + final List components = [ + component(id: 'root', type: 'DateTimeInput', properties: props), ]; surfaceController.handleMessage( - UpdateComponents(surfaceId: surfaceId, components: components), + updateComponents(surfaceId: surfaceId, components: components), ); surfaceController.handleMessage( - const CreateSurface(surfaceId: surfaceId, catalogId: 'test_catalog'), + createSurface(surfaceId: surfaceId, catalogId: 'test_catalog'), ); return (surfaceController, surfaceId); diff --git a/packages/genui/test/catalog/core_widgets/divider_test.dart b/packages/genui/test/catalog/core_widgets/divider_test.dart index ad0627096..d4b8d07ee 100644 --- a/packages/genui/test/catalog/core_widgets/divider_test.dart +++ b/packages/genui/test/catalog/core_widgets/divider_test.dart @@ -6,6 +6,8 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:genui/genui.dart'; +import '../../test_infra/message_builders.dart'; + void main() { testWidgets('Divider widget renders', (WidgetTester tester) async { final surfaceController = SurfaceController( @@ -14,14 +16,14 @@ void main() { ], ); const surfaceId = 'testSurface'; - final components = [ - const Component(id: 'root', type: 'Divider', properties: {}), + final List components = [ + component(id: 'root', type: 'Divider', properties: {}), ]; surfaceController.handleMessage( - UpdateComponents(surfaceId: surfaceId, components: components), + updateComponents(surfaceId: surfaceId, components: components), ); surfaceController.handleMessage( - const CreateSurface(surfaceId: surfaceId, catalogId: 'test_catalog'), + createSurface(surfaceId: surfaceId, catalogId: 'test_catalog'), ); await tester.pumpWidget( diff --git a/packages/genui/test/catalog/core_widgets/icon_test.dart b/packages/genui/test/catalog/core_widgets/icon_test.dart index aab63d10f..712d1d9c0 100644 --- a/packages/genui/test/catalog/core_widgets/icon_test.dart +++ b/packages/genui/test/catalog/core_widgets/icon_test.dart @@ -6,6 +6,8 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:genui/genui.dart'; +import '../../test_infra/message_builders.dart'; + void main() { testWidgets('Icon widget renders with literal string', ( WidgetTester tester, @@ -16,14 +18,14 @@ void main() { ], ); const surfaceId = 'testSurface'; - final components = [ - const Component(id: 'root', type: 'Icon', properties: {'name': 'add'}), + final List components = [ + component(id: 'root', type: 'Icon', properties: {'name': 'add'}), ]; surfaceController.handleMessage( - UpdateComponents(surfaceId: surfaceId, components: components), + updateComponents(surfaceId: surfaceId, components: components), ); surfaceController.handleMessage( - const CreateSurface(surfaceId: surfaceId, catalogId: 'test_catalog'), + createSurface(surfaceId: surfaceId, catalogId: 'test_catalog'), ); await tester.pumpWidget( @@ -48,8 +50,8 @@ void main() { ], ); const surfaceId = 'testSurface'; - final components = [ - const Component( + final List components = [ + component( id: 'root', type: 'Icon', properties: { @@ -58,17 +60,17 @@ void main() { ), ]; surfaceController.handleMessage( - UpdateComponents(surfaceId: surfaceId, components: components), + updateComponents(surfaceId: surfaceId, components: components), ); surfaceController.handleMessage( - UpdateDataModel( + updateDataModel( surfaceId: 'testSurface', path: DataPath('/iconName'), value: 'close', ), ); surfaceController.handleMessage( - const CreateSurface(surfaceId: surfaceId, catalogId: 'test_catalog'), + createSurface(surfaceId: surfaceId, catalogId: 'test_catalog'), ); await tester.pumpWidget( diff --git a/packages/genui/test/catalog/core_widgets/image_test.dart b/packages/genui/test/catalog/core_widgets/image_test.dart index 42f7e00e7..f82e2bd4e 100644 --- a/packages/genui/test/catalog/core_widgets/image_test.dart +++ b/packages/genui/test/catalog/core_widgets/image_test.dart @@ -9,6 +9,8 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:genui/genui.dart'; +import '../../test_infra/message_builders.dart'; + class _FakeHttpClient extends Fake implements HttpClient { @override bool autoUncompress = true; @@ -32,18 +34,18 @@ void main() { ], ); const surfaceId = 'testSurface'; - final components = [ - const Component( + final List components = [ + component( id: 'root', type: 'Image', properties: {'url': 'https://example.com/nonexistent.png'}, ), ]; surfaceController.handleMessage( - UpdateComponents(surfaceId: surfaceId, components: components), + updateComponents(surfaceId: surfaceId, components: components), ); surfaceController.handleMessage( - const CreateSurface(surfaceId: surfaceId, catalogId: 'test_catalog'), + createSurface(surfaceId: surfaceId, catalogId: 'test_catalog'), ); await tester.pumpWidget( @@ -82,18 +84,18 @@ void main() { ], ); const surfaceId = 'testSurface'; - final components = [ - const Component( + final List components = [ + component( id: 'root', type: 'Image', properties: {'url': 'https://example.com/image.png'}, ), ]; surfaceController.handleMessage( - UpdateComponents(surfaceId: surfaceId, components: components), + updateComponents(surfaceId: surfaceId, components: components), ); surfaceController.handleMessage( - const CreateSurface(surfaceId: surfaceId, catalogId: 'test_catalog'), + createSurface(surfaceId: surfaceId, catalogId: 'test_catalog'), ); await tester.pumpWidget( diff --git a/packages/genui/test/catalog/core_widgets/list_test.dart b/packages/genui/test/catalog/core_widgets/list_test.dart index 05947aedf..25c0faf75 100644 --- a/packages/genui/test/catalog/core_widgets/list_test.dart +++ b/packages/genui/test/catalog/core_widgets/list_test.dart @@ -6,6 +6,8 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:genui/genui.dart'; +import '../../test_infra/message_builders.dart'; + void main() { testWidgets('List widget renders children', (WidgetTester tester) async { final surfaceController = SurfaceController( @@ -17,26 +19,22 @@ void main() { ], ); const surfaceId = 'testSurface'; - final components = [ - const Component( + final List components = [ + component( id: 'root', type: 'List', properties: { 'children': ['text1', 'text2'], }, ), - const Component(id: 'text1', type: 'Text', properties: {'text': 'First'}), - const Component( - id: 'text2', - type: 'Text', - properties: {'text': 'Second'}, - ), + component(id: 'text1', type: 'Text', properties: {'text': 'First'}), + component(id: 'text2', type: 'Text', properties: {'text': 'Second'}), ]; surfaceController.handleMessage( - UpdateComponents(surfaceId: surfaceId, components: components), + updateComponents(surfaceId: surfaceId, components: components), ); surfaceController.handleMessage( - const CreateSurface(surfaceId: surfaceId, catalogId: 'test_catalog'), + createSurface(surfaceId: surfaceId, catalogId: 'test_catalog'), ); await tester.pumpWidget( @@ -64,8 +62,8 @@ void main() { ], ); const surfaceId = 'testSurface'; - final components = [ - const Component( + final List components = [ + component( id: 'root', type: 'List', properties: { @@ -73,17 +71,13 @@ void main() { 'children': ['text1'], }, ), - const Component( - id: 'text1', - type: 'Text', - properties: {'text': 'Center'}, - ), + component(id: 'text1', type: 'Text', properties: {'text': 'Center'}), ]; surfaceController.handleMessage( - UpdateComponents(surfaceId: surfaceId, components: components), + updateComponents(surfaceId: surfaceId, components: components), ); surfaceController.handleMessage( - const CreateSurface(surfaceId: surfaceId, catalogId: 'test_catalog'), + createSurface(surfaceId: surfaceId, catalogId: 'test_catalog'), ); await tester.pumpWidget( diff --git a/packages/genui/test/catalog/core_widgets/modal_test.dart b/packages/genui/test/catalog/core_widgets/modal_test.dart index dec6dc399..6e61cdbdc 100644 --- a/packages/genui/test/catalog/core_widgets/modal_test.dart +++ b/packages/genui/test/catalog/core_widgets/modal_test.dart @@ -6,6 +6,8 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:genui/genui.dart'; +import '../../test_infra/message_builders.dart'; + void main() { testWidgets('Modal widget renders and handles taps', ( WidgetTester tester, @@ -20,28 +22,28 @@ void main() { ], ); const surfaceId = 'testSurface'; - final components = [ - const Component( + final List components = [ + component( id: 'root', type: 'Modal', properties: {'trigger': 'trigger_text', 'content': 'modal_content'}, ), - const Component( + component( id: 'trigger_text', type: 'Text', properties: {'text': 'Open Modal'}, ), - const Component( + component( id: 'modal_content', type: 'Text', properties: {'text': 'This is a modal.'}, ), ]; surfaceController.handleMessage( - UpdateComponents(surfaceId: surfaceId, components: components), + updateComponents(surfaceId: surfaceId, components: components), ); surfaceController.handleMessage( - const CreateSurface(surfaceId: surfaceId, catalogId: 'test_catalog'), + createSurface(surfaceId: surfaceId, catalogId: 'test_catalog'), ); await tester.pumpWidget( diff --git a/packages/genui/test/catalog/core_widgets/row_test.dart b/packages/genui/test/catalog/core_widgets/row_test.dart index 6cadc5896..df5234d6f 100644 --- a/packages/genui/test/catalog/core_widgets/row_test.dart +++ b/packages/genui/test/catalog/core_widgets/row_test.dart @@ -6,6 +6,8 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:genui/genui.dart'; +import '../../test_infra/message_builders.dart'; + void main() { testWidgets('Row widget renders children', (WidgetTester tester) async { final surfaceController = SurfaceController( @@ -17,26 +19,22 @@ void main() { ], ); const surfaceId = 'testSurface'; - final components = [ - const Component( + final List components = [ + component( id: 'root', type: 'Row', properties: { 'children': ['text1', 'text2'], }, ), - const Component(id: 'text1', type: 'Text', properties: {'text': 'First'}), - const Component( - id: 'text2', - type: 'Text', - properties: {'text': 'Second'}, - ), + component(id: 'text1', type: 'Text', properties: {'text': 'First'}), + component(id: 'text2', type: 'Text', properties: {'text': 'Second'}), ]; surfaceController.handleMessage( - UpdateComponents(surfaceId: surfaceId, components: components), + updateComponents(surfaceId: surfaceId, components: components), ); surfaceController.handleMessage( - const CreateSurface(surfaceId: surfaceId, catalogId: 'test_catalog'), + createSurface(surfaceId: surfaceId, catalogId: 'test_catalog'), ); await tester.pumpWidget( @@ -65,31 +63,31 @@ void main() { ], ); const surfaceId = 'testSurface'; - final components = [ - const Component( + final List components = [ + component( id: 'root', type: 'Row', properties: { 'children': ['text1', 'text2', 'text3'], }, ), - const Component( + component( id: 'text1', type: 'Text', properties: {'text': 'First', 'weight': 1}, ), - const Component( + component( id: 'text2', type: 'Text', properties: {'text': 'Second', 'weight': 2}, ), - const Component(id: 'text3', type: 'Text', properties: {'text': 'Third'}), + component(id: 'text3', type: 'Text', properties: {'text': 'Third'}), ]; surfaceController.handleMessage( - UpdateComponents(surfaceId: surfaceId, components: components), + updateComponents(surfaceId: surfaceId, components: components), ); surfaceController.handleMessage( - const CreateSurface(surfaceId: surfaceId, catalogId: 'test_catalog'), + createSurface(surfaceId: surfaceId, catalogId: 'test_catalog'), ); await tester.pumpWidget( @@ -137,7 +135,7 @@ void main() { // Initial data with items surfaceController.handleMessage( - const UpdateDataModel( + updateDataModel( surfaceId: surfaceId, value: { 'items': ['Item 1', 'Item 2', 'Item 3'], @@ -145,15 +143,15 @@ void main() { ), ); - final components = [ - const Component( + final List components = [ + component( id: 'root', type: 'Row', properties: { 'children': {'path': '/items', 'componentId': 'textItem'}, }, ), - const Component( + component( id: 'textItem', type: 'Text', properties: { @@ -163,10 +161,10 @@ void main() { ]; surfaceController.handleMessage( - UpdateComponents(surfaceId: surfaceId, components: components), + updateComponents(surfaceId: surfaceId, components: components), ); surfaceController.handleMessage( - const CreateSurface(surfaceId: surfaceId, catalogId: 'test_catalog'), + createSurface(surfaceId: surfaceId, catalogId: 'test_catalog'), ); await tester.pumpWidget( diff --git a/packages/genui/test/catalog/core_widgets/slider_test.dart b/packages/genui/test/catalog/core_widgets/slider_test.dart index c8c4bec5d..48e5ec1cb 100644 --- a/packages/genui/test/catalog/core_widgets/slider_test.dart +++ b/packages/genui/test/catalog/core_widgets/slider_test.dart @@ -6,6 +6,8 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:genui/genui.dart'; +import '../../test_infra/message_builders.dart'; + void main() { testWidgets('Slider widget renders and handles changes', ( WidgetTester tester, @@ -16,8 +18,8 @@ void main() { ], ); const surfaceId = 'testSurface'; - final components = [ - const Component( + final List components = [ + component( id: 'root', type: 'Slider', properties: { @@ -26,10 +28,10 @@ void main() { ), ]; surfaceController.handleMessage( - UpdateComponents(surfaceId: surfaceId, components: components), + updateComponents(surfaceId: surfaceId, components: components), ); surfaceController.handleMessage( - const CreateSurface(surfaceId: surfaceId, catalogId: 'test_catalog'), + createSurface(surfaceId: surfaceId, catalogId: 'test_catalog'), ); surfaceController .contextFor(surfaceId) @@ -66,8 +68,8 @@ void main() { ], ); const surfaceId = 'testSurface'; - final components = [ - const Component( + final List components = [ + component( id: 'root', type: 'Slider', properties: { @@ -77,10 +79,10 @@ void main() { ), ]; surfaceController.handleMessage( - UpdateComponents(surfaceId: surfaceId, components: components), + updateComponents(surfaceId: surfaceId, components: components), ); surfaceController.handleMessage( - const CreateSurface(surfaceId: surfaceId, catalogId: 'test_catalog'), + createSurface(surfaceId: surfaceId, catalogId: 'test_catalog'), ); surfaceController .contextFor(surfaceId) diff --git a/packages/genui/test/catalog/core_widgets/tabs_test.dart b/packages/genui/test/catalog/core_widgets/tabs_test.dart index 57e3385ac..0f00b127d 100644 --- a/packages/genui/test/catalog/core_widgets/tabs_test.dart +++ b/packages/genui/test/catalog/core_widgets/tabs_test.dart @@ -6,6 +6,8 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:genui/genui.dart'; +import '../../test_infra/message_builders.dart'; + void main() { testWidgets('Tabs widget renders and handles taps', ( WidgetTester tester, @@ -19,8 +21,8 @@ void main() { ], ); const surfaceId = 'testSurface'; - final components = [ - const Component( + final List components = [ + component( id: 'root', type: 'Tabs', properties: { @@ -31,22 +33,22 @@ void main() { ], }, ), - const Component( + component( id: 'text1', type: 'Text', properties: {'component': 'Text', 'text': 'This is the first tab.'}, ), - const Component( + component( id: 'text2', type: 'Text', properties: {'component': 'Text', 'text': 'This is the second tab.'}, ), ]; surfaceController.handleMessage( - UpdateComponents(surfaceId: surfaceId, components: components), + updateComponents(surfaceId: surfaceId, components: components), ); surfaceController.handleMessage( - const CreateSurface(surfaceId: surfaceId, catalogId: 'test_catalog'), + createSurface(surfaceId: surfaceId, catalogId: 'test_catalog'), ); await tester.pumpWidget( @@ -84,15 +86,15 @@ void main() { // Initialize data model with tab 1 (index 1) active surfaceController.handleMessage( - UpdateDataModel( + updateDataModel( surfaceId: surfaceId, path: DataPath('/'), value: {'currentTab': 1}, ), ); - final components = [ - const Component( + final List components = [ + component( id: 'root', type: 'Tabs', properties: { @@ -104,12 +106,12 @@ void main() { ], }, ), - const Component( + component( id: 'text1', type: 'Text', properties: {'component': 'Text', 'text': 'Content 1'}, ), - const Component( + component( id: 'text2', type: 'Text', properties: {'component': 'Text', 'text': 'Content 2'}, @@ -117,10 +119,10 @@ void main() { ]; surfaceController.handleMessage( - UpdateComponents(surfaceId: surfaceId, components: components), + updateComponents(surfaceId: surfaceId, components: components), ); surfaceController.handleMessage( - const CreateSurface(surfaceId: surfaceId, catalogId: 'test_catalog'), + createSurface(surfaceId: surfaceId, catalogId: 'test_catalog'), ); // Initial build @@ -141,7 +143,7 @@ void main() { // Update data model to switch to Tab 1 (index 0) surfaceController.handleMessage( - UpdateDataModel( + updateDataModel( surfaceId: 'testSurface', path: DataPath('/currentTab'), value: 0, diff --git a/packages/genui/test/catalog/core_widgets/text_field_test.dart b/packages/genui/test/catalog/core_widgets/text_field_test.dart index 9abfc9d82..2b30efba1 100644 --- a/packages/genui/test/catalog/core_widgets/text_field_test.dart +++ b/packages/genui/test/catalog/core_widgets/text_field_test.dart @@ -6,6 +6,8 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:genui/genui.dart'; +import '../../test_infra/message_builders.dart'; + void main() { testWidgets('TextField with no weight in Row defaults to weight: 1 ' 'and expands', (WidgetTester tester) async { @@ -14,15 +16,15 @@ void main() { ); addTearDown(surfaceController.dispose); const surfaceId = 'testSurface'; - final components = [ - const Component( + final List components = [ + component( id: 'root', type: 'Row', properties: { 'children': ['text_field'], }, ), - const Component( + component( id: 'text_field', type: 'TextField', properties: {'label': 'Input'}, @@ -31,10 +33,10 @@ void main() { ]; surfaceController.handleMessage( - UpdateComponents(surfaceId: surfaceId, components: components), + updateComponents(surfaceId: surfaceId, components: components), ); surfaceController.handleMessage( - const CreateSurface(surfaceId: surfaceId, catalogId: basicCatalogId), + createSurface(surfaceId: surfaceId, catalogId: basicCatalogId), ); await tester.pumpWidget( @@ -70,15 +72,15 @@ void main() { ); addTearDown(surfaceController.dispose); const surfaceId = 'testSurface'; - final components = [ - const Component( + final List components = [ + component( id: 'root', type: 'Row', properties: { 'children': ['text_field'], }, ), - const Component( + component( id: 'text_field', type: 'TextField', properties: {'label': 'Input', 'weight': 1}, @@ -86,10 +88,10 @@ void main() { ]; surfaceController.handleMessage( - UpdateComponents(surfaceId: surfaceId, components: components), + updateComponents(surfaceId: surfaceId, components: components), ); surfaceController.handleMessage( - const CreateSurface(surfaceId: surfaceId, catalogId: basicCatalogId), + createSurface(surfaceId: surfaceId, catalogId: basicCatalogId), ); await tester.pumpWidget( @@ -125,15 +127,15 @@ void main() { const surfaceId = 'validationTest'; // Initialize with invalid value surfaceController.handleMessage( - UpdateDataModel( + updateDataModel( surfaceId: surfaceId, path: DataPath('/myValue'), value: 'initial', ), ); - final components = [ - const Component( + final List components = [ + component( id: 'root', type: 'TextField', properties: { @@ -156,10 +158,10 @@ void main() { ]; surfaceController.handleMessage( - UpdateComponents(surfaceId: surfaceId, components: components), + updateComponents(surfaceId: surfaceId, components: components), ); surfaceController.handleMessage( - const CreateSurface(surfaceId: surfaceId, catalogId: basicCatalogId), + createSurface(surfaceId: surfaceId, catalogId: basicCatalogId), ); await tester.pumpWidget( @@ -192,11 +194,11 @@ void main() { const surfaceId = 'validationWrapperTest'; // Initialize with invalid value (empty string) surfaceController.handleMessage( - UpdateDataModel(surfaceId: surfaceId, path: DataPath('/name'), value: ''), + updateDataModel(surfaceId: surfaceId, path: DataPath('/name'), value: ''), ); - final components = [ - const Component( + final List components = [ + component( id: 'root', type: 'TextField', properties: { @@ -220,10 +222,10 @@ void main() { ]; surfaceController.handleMessage( - UpdateComponents(surfaceId: surfaceId, components: components), + updateComponents(surfaceId: surfaceId, components: components), ); surfaceController.handleMessage( - const CreateSurface(surfaceId: surfaceId, catalogId: basicCatalogId), + createSurface(surfaceId: surfaceId, catalogId: basicCatalogId), ); await tester.pumpWidget( @@ -257,15 +259,15 @@ void main() { const surfaceId = 'validationTypeTest'; // Initialize with an integer value surfaceController.handleMessage( - UpdateDataModel( + updateDataModel( surfaceId: surfaceId, path: DataPath('/name'), value: 123, ), ); - final components = [ - const Component( + final List components = [ + component( id: 'root', type: 'TextField', properties: { @@ -276,10 +278,10 @@ void main() { ]; surfaceController.handleMessage( - UpdateComponents(surfaceId: surfaceId, components: components), + updateComponents(surfaceId: surfaceId, components: components), ); surfaceController.handleMessage( - const CreateSurface(surfaceId: surfaceId, catalogId: basicCatalogId), + createSurface(surfaceId: surfaceId, catalogId: basicCatalogId), ); await tester.pumpWidget( diff --git a/packages/genui/test/catalog/core_widgets_test.dart b/packages/genui/test/catalog/core_widgets_test.dart index d2c140a46..9a6f69603 100644 --- a/packages/genui/test/catalog/core_widgets_test.dart +++ b/packages/genui/test/catalog/core_widgets_test.dart @@ -6,6 +6,8 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:genui/genui.dart'; +import '../test_infra/message_builders.dart'; + void main() { group('Basic Widgets', () { final Catalog testCatalog = BasicCatalogItems.asCatalog(); @@ -16,7 +18,7 @@ void main() { Future pumpWidgetWithDefinition( WidgetTester tester, String rootId, - List components, + List components, ) async { message = null; controller?.dispose(); @@ -24,10 +26,10 @@ void main() { controller!.onSubmit.listen((event) => message = event); const surfaceId = 'testSurface'; controller!.handleMessage( - UpdateComponents(surfaceId: surfaceId, components: components), + updateComponents(surfaceId: surfaceId, components: components), ); controller!.handleMessage( - CreateSurface(surfaceId: surfaceId, catalogId: testCatalog.catalogId!), + createSurface(surfaceId: surfaceId, catalogId: testCatalog.catalogId!), ); await tester.pumpWidget( MaterialApp( @@ -39,8 +41,8 @@ void main() { } testWidgets('Button renders and handles taps', (WidgetTester tester) async { - final components = [ - const Component( + final List components = [ + component( id: 'root', type: 'Button', properties: { @@ -50,11 +52,7 @@ void main() { }, }, ), - const Component( - id: 'text', - type: 'Text', - properties: {'text': 'Click Me'}, - ), + component(id: 'text', type: 'Text', properties: {'text': 'Click Me'}), ]; await pumpWidgetWithDefinition(tester, 'root', components); @@ -67,8 +65,8 @@ void main() { }); testWidgets('Text renders from data model', (WidgetTester tester) async { - final components = [ - const Component( + final List components = [ + component( id: 'root', type: 'Text', properties: { @@ -88,24 +86,16 @@ void main() { }); testWidgets('Column renders children', (WidgetTester tester) async { - final components = [ - const Component( + final List components = [ + component( id: 'root', type: 'Column', properties: { 'children': ['text1', 'text2'], }, ), - const Component( - id: 'text1', - type: 'Text', - properties: {'text': 'First'}, - ), - const Component( - id: 'text2', - type: 'Text', - properties: {'text': 'Second'}, - ), + component(id: 'text1', type: 'Text', properties: {'text': 'First'}), + component(id: 'text2', type: 'Text', properties: {'text': 'Second'}), ]; await pumpWidgetWithDefinition(tester, 'root', components); @@ -117,8 +107,8 @@ void main() { testWidgets('TextField renders and handles changes/submissions', ( WidgetTester tester, ) async { - final components = [ - const Component( + final List components = [ + component( id: 'root', type: 'TextField', properties: { diff --git a/packages/genui/test/core/a2ui_message_processor_validation_test.dart b/packages/genui/test/core/a2ui_message_processor_validation_test.dart index 1177720a4..f081afe02 100644 --- a/packages/genui/test/core/a2ui_message_processor_validation_test.dart +++ b/packages/genui/test/core/a2ui_message_processor_validation_test.dart @@ -7,6 +7,8 @@ import 'dart:convert'; import 'package:flutter_test/flutter_test.dart'; import 'package:genui/genui.dart'; +import '../test_infra/message_builders.dart'; + void main() { group('SurfaceController Validation', () { test('CreateSurface fails validation with empty surfaceId', () async { @@ -28,7 +30,7 @@ void main() { ); controller.handleMessage( - const CreateSurface(surfaceId: '', catalogId: 'default'), + createSurface(surfaceId: '', catalogId: 'default'), ); await future; @@ -56,14 +58,14 @@ void main() { ); controller.handleMessage( - const CreateSurface(surfaceId: 'surf1', catalogId: basicCatalogId), + createSurface(surfaceId: 'surf1', catalogId: basicCatalogId), ); controller.handleMessage( - const UpdateComponents( + updateComponents( surfaceId: 'surf1', components: [ - Component( + component( id: 'badText', type: 'Text', properties: {}, diff --git a/packages/genui/test/engine/surface_controller_test.dart b/packages/genui/test/engine/surface_controller_test.dart index bf9ac6bc0..92c0129b5 100644 --- a/packages/genui/test/engine/surface_controller_test.dart +++ b/packages/genui/test/engine/surface_controller_test.dart @@ -11,6 +11,8 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:genui/genui.dart'; import 'package:json_schema_builder/json_schema_builder.dart'; +import '../test_infra/message_builders.dart'; + void main() { group('$SurfaceController', () { late SurfaceController controller; @@ -35,16 +37,12 @@ void main() { test('handleMessage adds a new surface and fires SurfaceAdded with ' 'definition', () async { const surfaceId = 's1'; - final components = [ - const Component( - id: 'root', - type: 'Text', - properties: {'text': 'Hello'}, - ), + final List components = [ + component(id: 'root', type: 'Text', properties: {'text': 'Hello'}), ]; controller.handleMessage( - UpdateComponents(surfaceId: surfaceId, components: components), + updateComponents(surfaceId: surfaceId, components: components), ); final Future> futureUpdates = controller @@ -52,7 +50,7 @@ void main() { .take(2) .toList(); controller.handleMessage( - const CreateSurface(surfaceId: surfaceId, catalogId: 'test_catalog'), + createSurface(surfaceId: surfaceId, catalogId: 'test_catalog'), ); final List updates = await futureUpdates; @@ -81,19 +79,11 @@ void main() { 'handleMessage updates an existing surface and fires ComponentsUpdated', () async { const surfaceId = 's1'; - final oldComponents = [ - const Component( - id: 'root', - type: 'Text', - properties: {'text': 'Old'}, - ), + final List oldComponents = [ + component(id: 'root', type: 'Text', properties: {'text': 'Old'}), ]; - final newComponents = [ - const Component( - id: 'root', - type: 'Text', - properties: {'text': 'New'}, - ), + final List newComponents = [ + component(id: 'root', type: 'Text', properties: {'text': 'New'}), ]; final Future expectation = expectLater( @@ -106,13 +96,13 @@ void main() { ); controller.handleMessage( - const CreateSurface(surfaceId: surfaceId, catalogId: 'test_catalog'), + createSurface(surfaceId: surfaceId, catalogId: 'test_catalog'), ); controller.handleMessage( - UpdateComponents(surfaceId: surfaceId, components: oldComponents), + updateComponents(surfaceId: surfaceId, components: oldComponents), ); controller.handleMessage( - UpdateComponents(surfaceId: surfaceId, components: newComponents), + updateComponents(surfaceId: surfaceId, components: newComponents), ); await expectation; @@ -121,24 +111,20 @@ void main() { test('handleMessage removes a surface and fires SurfaceRemoved', () async { const surfaceId = 's1'; - final components = [ - const Component( - id: 'root', - type: 'Text', - properties: {'text': 'Hello'}, - ), + final List components = [ + component(id: 'root', type: 'Text', properties: {'text': 'Hello'}), ]; controller.handleMessage( - const CreateSurface(surfaceId: surfaceId, catalogId: 'test_catalog'), + createSurface(surfaceId: surfaceId, catalogId: 'test_catalog'), ); controller.handleMessage( - UpdateComponents(surfaceId: surfaceId, components: components), + updateComponents(surfaceId: surfaceId, components: components), ); final Future futureUpdate = controller.surfaceUpdates.first; - controller.handleMessage(const DeleteSurface(surfaceId: surfaceId)); + controller.handleMessage(deleteSurface(surfaceId: surfaceId)); final SurfaceUpdate update = await futureUpdate; expect(update, isA()); @@ -179,7 +165,7 @@ void main() { expect(controller.registry.getSurface(surfaceId), isNull); controller.handleMessage( - const CreateSurface(surfaceId: surfaceId, catalogId: 'test_catalog'), + createSurface(surfaceId: surfaceId, catalogId: 'test_catalog'), ); final SurfaceDefinition? def = controller.registry.getSurface( @@ -194,10 +180,10 @@ void main() { test('store exposes the live surface data model facade', () { const surfaceId = 's1'; controller.handleMessage( - const CreateSurface(surfaceId: surfaceId, catalogId: 'test_catalog'), + createSurface(surfaceId: surfaceId, catalogId: 'test_catalog'), ); controller.handleMessage( - const UpdateDataModel( + updateDataModel( surfaceId: surfaceId, path: DataPath.root, value: {'name': 'Alice'}, @@ -209,7 +195,7 @@ void main() { expect(controller.store.dataModels[surfaceId], same(model)); controller.handleMessage( - UpdateDataModel( + updateDataModel( surfaceId: surfaceId, path: DataPath('/name'), value: 'Bob', @@ -235,7 +221,7 @@ void main() { .update(DataPath('/name'), 'Alice'); controller.handleMessage( - const CreateSurface(surfaceId: surfaceId, catalogId: 'test_catalog'), + createSurface(surfaceId: surfaceId, catalogId: 'test_catalog'), ); // Post-create, contextFor.dataModel routes through the live core @@ -249,7 +235,7 @@ void main() { controller.contextFor(surfaceId).dataModel.update(DataPath.root, null); controller.handleMessage( - const CreateSurface(surfaceId: surfaceId, catalogId: 'test_catalog'), + createSurface(surfaceId: surfaceId, catalogId: 'test_catalog'), ); final DataModel model = controller.contextFor(surfaceId).dataModel; @@ -327,7 +313,7 @@ void main() { // Trigger validation error by using an empty surface ID. final Future messageFuture = controller.onSubmit.first; controller.handleMessage( - const CreateSurface(surfaceId: '', catalogId: 'test_catalog'), + createSurface(surfaceId: '', catalogId: 'test_catalog'), ); final ChatMessage message = await messageFuture; @@ -347,7 +333,7 @@ void main() { test('rejects empty surfaceId on non-create messages', () async { final Future messageFuture = controller.onSubmit.first; - controller.handleMessage(const UpdateDataModel(surfaceId: '', value: 1)); + controller.handleMessage(updateDataModel(surfaceId: '', value: 1)); final ChatMessage message = await messageFuture; final UiInteractionPart part = message.parts.uiInteractionParts.first; @@ -362,11 +348,11 @@ void main() { 'duplicate createSurface for an active surface reports an error', () async { controller.handleMessage( - const CreateSurface(surfaceId: 's1', catalogId: 'test_catalog'), + createSurface(surfaceId: 's1', catalogId: 'test_catalog'), ); final Future messageFuture = controller.onSubmit.first; controller.handleMessage( - const CreateSurface(surfaceId: 's1', catalogId: 'test_catalog'), + createSurface(surfaceId: 's1', catalogId: 'test_catalog'), ); final ChatMessage message = await messageFuture; @@ -386,8 +372,8 @@ void main() { addTearDown(shortTimeoutController.dispose); const surfaceId = 'timedOutSurface'; - final components = [ - const Component( + final List components = [ + component( id: 'root', type: 'Text', properties: {'text': 'Should not be seen'}, @@ -396,7 +382,7 @@ void main() { // 1. Send update for non-existent surface (buffered) shortTimeoutController.handleMessage( - UpdateComponents(surfaceId: surfaceId, components: components), + updateComponents(surfaceId: surfaceId, components: components), ); // 2. Wait for timeout @@ -408,7 +394,7 @@ void main() { .take(1) .toList(); shortTimeoutController.handleMessage( - const CreateSurface(surfaceId: surfaceId, catalogId: 'test_catalog'), + createSurface(surfaceId: surfaceId, catalogId: 'test_catalog'), ); // 4. Verify surface created but NO update applied @@ -453,20 +439,17 @@ void main() { const surfaceId = 'strictSurface'; strictController.handleMessage( - const CreateSurface( - surfaceId: surfaceId, - catalogId: 'strict_catalog', - ), + createSurface(surfaceId: surfaceId, catalogId: 'strict_catalog'), ); final Future future = strictController.onSubmit.first; // Send invalid component (missing requiredProp) strictController.handleMessage( - const UpdateComponents( + updateComponents( surfaceId: surfaceId, components: [ - Component(id: 'bad', type: 'StrictWidget', properties: {}), + component(id: 'bad', type: 'StrictWidget', properties: {}), ], ), ); diff --git a/packages/genui/test/error_reporting_test.dart b/packages/genui/test/error_reporting_test.dart index 281c03626..fcd30711d 100644 --- a/packages/genui/test/error_reporting_test.dart +++ b/packages/genui/test/error_reporting_test.dart @@ -4,11 +4,11 @@ import 'dart:async'; +import 'package:a2ui_core/a2ui_core.dart' as core; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:genui/genui.dart'; - import 'package:json_schema_builder/json_schema_builder.dart'; import 'package:logging/logging.dart'; @@ -35,9 +35,9 @@ void main() { }; try { - A2uiMessage.fromJson(json); - fail('Should have thrown A2uiValidationException'); - } on A2uiValidationException catch (e) { + core.A2uiMessage.fromJson(json); + fail('Should have thrown A2uiValidationError'); + } on core.A2uiValidationError catch (e) { expect(e.message, contains('Unknown A2UI message type')); } }, diff --git a/packages/genui/test/facade/conversation_test.dart b/packages/genui/test/facade/conversation_test.dart index d666cc1e8..e74886949 100644 --- a/packages/genui/test/facade/conversation_test.dart +++ b/packages/genui/test/facade/conversation_test.dart @@ -7,6 +7,8 @@ import 'dart:async'; import 'package:flutter_test/flutter_test.dart'; import 'package:genui/genui.dart'; +import '../test_infra/message_builders.dart'; + void main() { group('Conversation', () { late A2uiTransportAdapter adapter; @@ -134,9 +136,7 @@ void main() { final events = []; conversation.events.listen(events.add); - adapter.addMessage( - const CreateSurface(surfaceId: 'surf1', catalogId: 'cat'), - ); + adapter.addMessage(createSurface(surfaceId: 'surf1', catalogId: 'cat')); await Future.delayed(Duration.zero); expect(conversation.state.value.surfaces, contains('surf1')); @@ -149,7 +149,7 @@ void main() { // update components adapter.addMessage( - const UpdateComponents(surfaceId: 'surf1', components: []), + updateComponents(surfaceId: 'surf1', components: const []), ); await Future.delayed(Duration.zero); @@ -161,7 +161,7 @@ void main() { ); // remove surface - adapter.addMessage(const DeleteSurface(surfaceId: 'surf1')); + adapter.addMessage(deleteSurface(surfaceId: 'surf1')); await Future.delayed(Duration.zero); expect(conversation.state.value.surfaces, isNot(contains('surf1'))); diff --git a/packages/genui/test/genui_surface_test.dart b/packages/genui/test/genui_surface_test.dart index 1a2c3e272..75959f1b4 100644 --- a/packages/genui/test/genui_surface_test.dart +++ b/packages/genui/test/genui_surface_test.dart @@ -7,6 +7,8 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:genui/genui.dart'; import 'package:logging/logging.dart'; +import 'test_infra/message_builders.dart'; + void main() { late SurfaceController controller; final testCatalog = Catalog([ @@ -26,8 +28,8 @@ void main() { WidgetTester tester, ) async { const surfaceId = 'testSurface'; - final components = [ - const Component( + final List components = [ + component( id: 'root', type: 'Button', properties: { @@ -37,13 +39,13 @@ void main() { }, }, ), - const Component(id: 'text', type: 'Text', properties: {'text': 'Hello'}), + component(id: 'text', type: 'Text', properties: {'text': 'Hello'}), ]; controller.handleMessage( - UpdateComponents(surfaceId: surfaceId, components: components), + updateComponents(surfaceId: surfaceId, components: components), ); controller.handleMessage( - const CreateSurface(surfaceId: surfaceId, catalogId: 'test_catalog'), + createSurface(surfaceId: surfaceId, catalogId: 'test_catalog'), ); await tester.pumpWidget( @@ -58,8 +60,8 @@ void main() { testWidgets('SurfaceWidget handles events', (WidgetTester tester) async { const surfaceId = 'testSurface'; - final components = [ - const Component( + final List components = [ + component( id: 'root', type: 'Button', properties: { @@ -69,13 +71,13 @@ void main() { }, }, ), - const Component(id: 'text', type: 'Text', properties: {'text': 'Hello'}), + component(id: 'text', type: 'Text', properties: {'text': 'Hello'}), ]; controller.handleMessage( - UpdateComponents(surfaceId: surfaceId, components: components), + updateComponents(surfaceId: surfaceId, components: components), ); controller.handleMessage( - const CreateSurface(surfaceId: surfaceId, catalogId: 'test_catalog'), + createSurface(surfaceId: surfaceId, catalogId: 'test_catalog'), ); await tester.pumpWidget( @@ -92,22 +94,15 @@ void main() { 'SurfaceWidget renders container and logs error on catalog miss', (WidgetTester tester) async { const surfaceId = 'testSurface'; - final components = [ - const Component( - id: 'root', - type: 'Text', - properties: {'text': 'Hello'}, - ), + final List components = [ + component(id: 'root', type: 'Text', properties: {'text': 'Hello'}), ]; controller.handleMessage( - UpdateComponents(surfaceId: surfaceId, components: components), + updateComponents(surfaceId: surfaceId, components: components), ); // Request a catalogId that doesn't exist in the controller. controller.handleMessage( - const CreateSurface( - surfaceId: surfaceId, - catalogId: 'non_existent_catalog', - ), + createSurface(surfaceId: surfaceId, catalogId: 'non_existent_catalog'), ); final logs = []; @@ -145,15 +140,15 @@ void main() { ) async { const surfaceId = 'testSurface'; controller.handleMessage( - const UpdateComponents( + updateComponents( surfaceId: surfaceId, components: [ - Component(id: 'root', type: 'Text', properties: {'text': 'first'}), + component(id: 'root', type: 'Text', properties: {'text': 'first'}), ], ), ); controller.handleMessage( - const CreateSurface(surfaceId: surfaceId, catalogId: 'test_catalog'), + createSurface(surfaceId: surfaceId, catalogId: 'test_catalog'), ); await tester.pumpWidget( @@ -165,10 +160,10 @@ void main() { // Updating the live surface should rebuild it in place. controller.handleMessage( - const UpdateComponents( + updateComponents( surfaceId: surfaceId, components: [ - Component(id: 'root', type: 'Text', properties: {'text': 'second'}), + component(id: 'root', type: 'Text', properties: {'text': 'second'}), ], ), ); diff --git a/packages/genui/test/model/a2ui_message_test.dart b/packages/genui/test/model/a2ui_message_test.dart deleted file mode 100644 index 1b14e98d0..000000000 --- a/packages/genui/test/model/a2ui_message_test.dart +++ /dev/null @@ -1,230 +0,0 @@ -// 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 'dart:convert'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:genui/src/catalog/basic_catalog.dart'; -import 'package:genui/src/model/a2ui_message.dart'; -import 'package:genui/src/model/catalog.dart'; -import 'package:genui/src/model/data_model.dart'; -import 'package:genui/src/primitives/a2ui_validation_exception.dart'; -import 'package:genui/src/primitives/simple_items.dart'; -import 'package:json_schema_builder/src/schema/schema.dart'; - -void main() { - group('A2uiMessage', () { - // ... existing tests ... - test('CreateSurface.fromJson parses correctly', () { - final Map json = { - 'version': 'v0.9', - 'createSurface': { - surfaceIdKey: 's1', - 'catalogId': 'catalog1', - 'theme': {'color': 'blue'}, - 'sendDataModel': true, - }, - }; - - final message = A2uiMessage.fromJson(json); - expect(message, isA()); - final create = message as CreateSurface; - expect(create.surfaceId, 's1'); - expect(create.catalogId, 'catalog1'); - expect(create.theme, {'color': 'blue'}); - expect(create.sendDataModel, isTrue); - }); - - test('UpdateComponents.fromJson parses correctly', () { - final Map json = { - 'version': 'v0.9', - 'updateComponents': { - surfaceIdKey: 's1', - 'components': [ - {'id': 'c1', 'component': 'Text', 'text': 'Hello'}, - ], - }, - }; - - final message = A2uiMessage.fromJson(json); - expect(message, isA()); - final update = message as UpdateComponents; - expect(update.surfaceId, 's1'); - expect(update.components.length, 1); - expect(update.components.first.id, 'c1'); - expect(update.components.first.type, 'Text'); - }); - - test('UpdateDataModel.fromJson parses correctly', () { - final Map json = { - 'version': 'v0.9', - 'updateDataModel': { - surfaceIdKey: 's1', - 'path': '/user/name', - 'value': 'Alice', - }, - }; - - final message = A2uiMessage.fromJson(json); - expect(message, isA()); - final update = message as UpdateDataModel; - expect(update.surfaceId, 's1'); - expect(update.path, DataPath('/user/name')); - expect(update.value, 'Alice'); - }); - - test('DeleteSurface.fromJson parses correctly', () { - final Map json = { - 'version': 'v0.9', - 'deleteSurface': {surfaceIdKey: 's1'}, - }; - - final message = A2uiMessage.fromJson(json); - expect(message, isA()); - final delete = message as DeleteSurface; - expect(delete.surfaceId, 's1'); - }); - - test('CreateSurface.toJson includes version', () { - const message = CreateSurface(surfaceId: 's1', catalogId: 'c1'); - expect(message.toJson(), containsPair('version', 'v0.9')); - }); - - test('UpdateComponents.toJson includes version', () { - const message = UpdateComponents(surfaceId: 's1', components: []); - expect(message.toJson(), containsPair('version', 'v0.9')); - }); - - test('UpdateDataModel.toJson includes version', () { - const message = UpdateDataModel(surfaceId: 's1'); - expect(message.toJson(), containsPair('version', 'v0.9')); - }); - - test('UpdateDataModel preserves explicit null value', () { - final message = A2uiMessage.fromJson({ - 'version': 'v0.9', - 'updateDataModel': { - surfaceIdKey: 's1', - 'path': '/x', - 'value': null, - }, - }); - - final update = message as UpdateDataModel; - expect(update.path, DataPath('/x')); - expect(update.value, isNull); - expect(update.hasValue, isTrue); - - final body = update.toJson()['updateDataModel'] as Map; - expect(body.containsKey('value'), isTrue); - expect(body['value'], isNull); - }); - - test('UpdateDataModel preserves omitted value as remove-key', () { - final message = UpdateDataModel.removeKey( - surfaceId: 's1', - path: DataPath('/x'), - ); - - final body = message.toJson()['updateDataModel'] as Map; - expect(message.path, DataPath('/x')); - expect(message.hasValue, isFalse); - expect(body.containsKey('value'), isFalse); - - final reparsed = - A2uiMessage.fromJson({ - 'version': 'v0.9', - 'updateDataModel': { - surfaceIdKey: 's1', - 'path': '/x', - }, - }) - as UpdateDataModel; - expect(reparsed.hasValue, isFalse); - }); - - test( - 'UpdateDataModel.toCoreMessage/fromCore preserves explicit null value', - () { - final original = UpdateDataModel(surfaceId: 's1', path: DataPath('/x')); - expect(original.hasValue, isTrue); - expect(original.value, isNull); - - final roundtripped = UpdateDataModel.fromCore(original.toCoreMessage()); - expect(roundtripped.hasValue, isTrue); - expect(roundtripped.value, isNull); - expect(roundtripped.path, DataPath('/x')); - }, - ); - - test('UpdateDataModel.toCoreMessage/fromCore preserves omitted value', () { - final original = UpdateDataModel.removeKey( - surfaceId: 's1', - path: DataPath('/x'), - ); - expect(original.hasValue, isFalse); - - final roundtripped = UpdateDataModel.fromCore(original.toCoreMessage()); - expect(roundtripped.hasValue, isFalse); - expect(roundtripped.path, DataPath('/x')); - }); - - test('DeleteSurface.toJson includes version', () { - const message = DeleteSurface(surfaceId: 's1'); - expect(message.toJson(), containsPair('version', 'v0.9')); - }); - - test('fromJson throws on unknown message type', () { - final json = {'version': 'v0.9', 'unknown': {}}; - expect( - () => A2uiMessage.fromJson(json), - throwsA( - isA().having( - (e) => e.message, - 'message', - contains('Unknown A2UI message type'), - ), - ), - ); - }); - - test('fromJson throws on missing or invalid version', () { - final json = { - 'createSurface': {surfaceIdKey: 's1', 'catalogId': 'c1'}, - }; - expect( - () => A2uiMessage.fromJson(json), - throwsA( - isA().having( - (e) => e.message, - 'message', - contains('A2UI message must have version "v0.9"'), - ), - ), - ); - }); - - test('a2uiMessageSchema requires version field', () { - final Catalog catalog = BasicCatalogItems.asCatalog(); - final Schema schema = A2uiMessage.a2uiMessageSchema(catalog); - final json = jsonDecode(schema.toJson()) as Map; - - // Structure is combined -> oneOf -> [object, ...] - expect(json['oneOf'], isA>()); - final oneOf = json['oneOf'] as List; - expect(oneOf, isNotEmpty); - - // Every variant must require 'version' and enforce 'v0.9' - for (final variant in oneOf) { - final variantMap = variant as Map; - final required = variantMap['required'] as List; - expect(required, contains('version')); - - final properties = variantMap['properties'] as Map; - expect(properties, contains('version')); - final versionSchema = properties['version'] as Map; - expect(versionSchema, containsPair('const', 'v0.9')); - } - }); - }); -} diff --git a/packages/genui/test/test_infra/message_builders.dart b/packages/genui/test/test_infra/message_builders.dart new file mode 100644 index 000000000..88f657f97 --- /dev/null +++ b/packages/genui/test/test_infra/message_builders.dart @@ -0,0 +1,70 @@ +// 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. + +// Test-only builders for `a2ui_core` messages and component wire JSON. +// +// `package:genui` consumes the core message types directly; these helpers keep +// test setup terse and mirror the named-argument shape the tests already use. + +import 'package:a2ui_core/a2ui_core.dart' as core; +import 'package:genui/src/model/data_path.dart'; +import 'package:genui/src/primitives/simple_items.dart'; + +/// Builds a component's wire JSON: `{'id': ..., 'component': ..., ...props}`. +JsonMap component({ + required String id, + required String type, + JsonMap properties = const {}, +}) => {'id': id, 'component': type, ...properties}; + +core.CreateSurfaceMessage createSurface({ + String version = 'v0.9', + required String surfaceId, + required String catalogId, + JsonMap? theme, + bool sendDataModel = false, +}) => core.CreateSurfaceMessage( + version: version, + surfaceId: surfaceId, + catalogId: catalogId, + theme: theme, + sendDataModel: sendDataModel, +); + +core.UpdateComponentsMessage updateComponents({ + String version = 'v0.9', + required String surfaceId, + required List components, +}) => core.UpdateComponentsMessage( + version: version, + surfaceId: surfaceId, + components: components, +); + +core.UpdateDataModelMessage updateDataModel({ + String version = 'v0.9', + required String surfaceId, + DataPath path = DataPath.root, + Object? value, +}) => core.UpdateDataModelMessage( + version: version, + surfaceId: surfaceId, + path: path.toString(), + value: value, +); + +core.UpdateDataModelMessage updateDataModelRemoveKey({ + String version = 'v0.9', + required String surfaceId, + DataPath path = DataPath.root, +}) => core.UpdateDataModelMessage.removeKey( + version: version, + surfaceId: surfaceId, + path: path.toString(), +); + +core.DeleteSurfaceMessage deleteSurface({ + String version = 'v0.9', + required String surfaceId, +}) => core.DeleteSurfaceMessage(version: version, surfaceId: surfaceId); diff --git a/packages/genui/test/test_infra/validation_test_utils.dart b/packages/genui/test/test_infra/validation_test_utils.dart index 415f75d48..3d8a4031b 100644 --- a/packages/genui/test/test_infra/validation_test_utils.dart +++ b/packages/genui/test/test_infra/validation_test_utils.dart @@ -4,8 +4,8 @@ import 'dart:convert'; +import 'package:a2ui_core/a2ui_core.dart' as core; import 'package:flutter_test/flutter_test.dart'; -import 'package:genui/src/model/a2ui_message.dart'; import 'package:genui/src/model/a2ui_schemas.dart'; import 'package:genui/src/model/catalog.dart'; import 'package:genui/src/model/catalog_item.dart'; @@ -13,6 +13,8 @@ import 'package:genui/src/model/ui_models.dart'; import 'package:genui/src/primitives/simple_items.dart'; import 'package:json_schema_builder/json_schema_builder.dart'; +import 'message_builders.dart'; + /// Validates the examples in the catalog items in the catalog. void validateCatalogExamples( Catalog catalog, [ @@ -48,9 +50,9 @@ void validateCatalogExamples( reason: 'Example must have a component with id "root"', ); - final surfaceUpdate = UpdateComponents( + final core.UpdateComponentsMessage surfaceUpdate = updateComponents( surfaceId: 'test-surface', - components: components, + components: components.map((c) => c.toJson()).toList(), ); final List validationErrors = await schema.validate( diff --git a/packages/genui/test/transport/a2ui_parser_transformer_test.dart b/packages/genui/test/transport/a2ui_parser_transformer_test.dart index 9c03872ee..f419dcfda 100644 --- a/packages/genui/test/transport/a2ui_parser_transformer_test.dart +++ b/packages/genui/test/transport/a2ui_parser_transformer_test.dart @@ -4,8 +4,8 @@ import 'dart:async'; +import 'package:a2ui_core/a2ui_core.dart' as core; import 'package:async/async.dart'; -import 'package:genui/src/model/a2ui_message.dart'; import 'package:genui/src/model/generation_events.dart'; import 'package:genui/src/primitives/a2ui_validation_exception.dart'; import 'package:genui/src/transport/a2ui_parser_transformer.dart'; @@ -63,8 +63,8 @@ void main() { ); final msgEvent = (await queue.next) as A2uiMessageEvent; - expect(msgEvent.message, isA()); - expect((msgEvent.message as CreateSurface).surfaceId, 'foo'); + expect(msgEvent.message, isA()); + expect((msgEvent.message as core.CreateSurfaceMessage).surfaceId, 'foo'); // The text after might be just the newline or "End of message." // We accept whatever text comes next, potentially fragmented. @@ -100,8 +100,8 @@ void main() { ); final msgEvent = (await queue.next) as A2uiMessageEvent; - expect(msgEvent.message, isA()); - expect((msgEvent.message as DeleteSurface).surfaceId, 'bar'); + expect(msgEvent.message, isA()); + expect((msgEvent.message as core.DeleteSurfaceMessage).surfaceId, 'bar'); expect( (await queue.next) as TextEvent, @@ -125,8 +125,11 @@ void main() { ); final msgEvent = (await queue.next) as A2uiMessageEvent; - expect(msgEvent.message, isA()); - expect((msgEvent.message as DeleteSurface).surfaceId, '[{]bar[}]'); + expect(msgEvent.message, isA()); + expect( + (msgEvent.message as core.DeleteSurfaceMessage).surfaceId, + '[{]bar[}]', + ); expect( (await queue.next) as TextEvent, @@ -156,7 +159,7 @@ void main() { test('emits error for invalid A2UI message', () async { final StreamQueue queue = StreamQueue(stream); - // Malformed CreateSurface (missing required fields) + // Malformed core.CreateSurfaceMessage (missing required fields) controller.add('{"version": "v0.9", "createSurface": {}}'); // Should emit error @@ -254,12 +257,18 @@ void main() { ); final firstEvent = (await queue.next) as A2uiMessageEvent; - expect(firstEvent.message, isA()); - expect((firstEvent.message as CreateSurface).surfaceId, 'foo'); + expect(firstEvent.message, isA()); + expect( + (firstEvent.message as core.CreateSurfaceMessage).surfaceId, + 'foo', + ); final secondEvent = (await queue.next) as A2uiMessageEvent; - expect(secondEvent.message, isA()); - expect((secondEvent.message as CreateSurface).surfaceId, 'bar'); + expect(secondEvent.message, isA()); + expect( + (secondEvent.message as core.CreateSurfaceMessage).surfaceId, + 'bar', + ); await queue.cancel(); }); diff --git a/packages/genui/test/transport/a2ui_transport_adapter_test.dart b/packages/genui/test/transport/a2ui_transport_adapter_test.dart index 7c58a13ff..3c6b92f08 100644 --- a/packages/genui/test/transport/a2ui_transport_adapter_test.dart +++ b/packages/genui/test/transport/a2ui_transport_adapter_test.dart @@ -4,8 +4,7 @@ import 'dart:async'; -import 'package:genui/src/model/a2ui_message.dart'; - +import 'package:a2ui_core/a2ui_core.dart' as core; import 'package:genui/src/transport/a2ui_transport_adapter.dart'; import 'package:test/test.dart'; @@ -39,7 +38,11 @@ void main() { final Future stateFuture = expectLater( transportAdapter.incomingMessages, emits( - isA().having((e) => e.surfaceId, 'id', 'test_chunk'), + isA().having( + (e) => e.surfaceId, + 'id', + 'test_chunk', + ), ), ); @@ -48,7 +51,7 @@ void main() { }); test('addMessage updates state directly', () async { - final msg = const CreateSurface( + final msg = core.CreateSurfaceMessage( surfaceId: 'direct_msg', catalogId: 'direct-cat', ); @@ -56,7 +59,11 @@ void main() { final Future stateFuture = expectLater( transportAdapter.incomingMessages, emits( - isA().having((e) => e.surfaceId, 'id', 'direct_msg'), + isA().having( + (e) => e.surfaceId, + 'id', + 'direct_msg', + ), ), ); @@ -70,10 +77,10 @@ void main() { final Future expectation = expectLater( adapter.incomingMessages, emits( - predicate((m) { - return m is UpdateComponents && + predicate((m) { + return m is core.UpdateComponentsMessage && m.components.length == 1 && - m.components.first.id == 'root'; + m.components.first['id'] == 'root'; }), ), ); From 5b792169847532593732253e47152db7f531afff Mon Sep 17 00:00:00 2001 From: Andrew Kolos Date: Thu, 4 Jun 2026 12:41:06 -0700 Subject: [PATCH 19/49] refactor(genui_a2a,catalog_gallery,verdure): consume a2ui_core messages directly The A2UI message types are renderer-agnostic (pure-Dart a2ui_core), so message- layer consumers depend on a2ui_core directly rather than reaching through the Flutter renderer, mirroring how the React/Lit renderers consume web_core. - genui_a2a: agent connector stream + parsing use core.A2uiMessage. - catalog_gallery: sample parser/view + tests use core message types (components are raw wire maps now: component['component'], not Component.type). - verdure: ai_provider matches core.CreateSurfaceMessage. Adds a2ui_core as a direct dependency of each. --- .../catalog_gallery/lib/sample_parser.dart | 8 ++++---- .../catalog_gallery/lib/samples_view.dart | 3 ++- dev_tools/catalog_gallery/pubspec.yaml | 1 + .../catalog_gallery/test/layout_test.dart | 9 +++++---- .../test/sample_parser_test.dart | 20 +++++++++---------- .../test/samples_rendering_test.dart | 3 ++- .../client/lib/features/ai/ai_provider.dart | 3 ++- examples/verdure/client/pubspec.yaml | 1 + .../lib/src/a2ui_agent_connector.dart | 7 ++++--- packages/genui_a2a/pubspec.yaml | 1 + .../test/a2ui_agent_connector_test.dart | 5 +++-- 11 files changed, 35 insertions(+), 26 deletions(-) diff --git a/dev_tools/catalog_gallery/lib/sample_parser.dart b/dev_tools/catalog_gallery/lib/sample_parser.dart index 7f7860b9d..121bd837e 100644 --- a/dev_tools/catalog_gallery/lib/sample_parser.dart +++ b/dev_tools/catalog_gallery/lib/sample_parser.dart @@ -4,14 +4,14 @@ import 'dart:convert'; +import 'package:a2ui_core/a2ui_core.dart' as core; import 'package:file/file.dart'; -import 'package:genui/genui.dart'; import 'package:yaml/yaml.dart'; class Sample { final String name; final String description; - final Stream messages; + final Stream messages; Sample({ required this.name, @@ -52,14 +52,14 @@ class SampleParser { final String name = header['name'] as String? ?? 'Untitled Sample'; final String description = header['description'] as String? ?? ''; - final Stream messages = Stream.fromIterable( + final Stream messages = Stream.fromIterable( const LineSplitter() .convert(jsonlBody) .where((line) => line.trim().isNotEmpty) .map((line) { final dynamic json = jsonDecode(line); if (json is Map) { - return A2uiMessage.fromJson(json); + return core.A2uiMessage.fromJson(json); } throw FormatException('Invalid JSON line: $line'); }), diff --git a/dev_tools/catalog_gallery/lib/samples_view.dart b/dev_tools/catalog_gallery/lib/samples_view.dart index b81139301..f5b68005d 100644 --- a/dev_tools/catalog_gallery/lib/samples_view.dart +++ b/dev_tools/catalog_gallery/lib/samples_view.dart @@ -4,6 +4,7 @@ import 'dart:async'; +import 'package:a2ui_core/a2ui_core.dart' as core; import 'package:file/file.dart'; import 'package:file/local.dart'; import 'package:flutter/material.dart'; @@ -35,7 +36,7 @@ class _SamplesViewState extends State { final List _surfaceIds = []; int _currentSurfaceIndex = 0; StreamSubscription? _surfaceSubscription; - StreamSubscription? _messageSubscription; + StreamSubscription? _messageSubscription; @override void initState() { diff --git a/dev_tools/catalog_gallery/pubspec.yaml b/dev_tools/catalog_gallery/pubspec.yaml index b75e56ccb..73d30bc67 100644 --- a/dev_tools/catalog_gallery/pubspec.yaml +++ b/dev_tools/catalog_gallery/pubspec.yaml @@ -13,6 +13,7 @@ environment: resolution: workspace dependencies: + a2ui_core: ^0.0.1-wip002 args: ^2.7.0 file: ^7.0.1 flutter: diff --git a/dev_tools/catalog_gallery/test/layout_test.dart b/dev_tools/catalog_gallery/test/layout_test.dart index 47fae8f70..911fedebf 100644 --- a/dev_tools/catalog_gallery/test/layout_test.dart +++ b/dev_tools/catalog_gallery/test/layout_test.dart @@ -4,6 +4,7 @@ import 'dart:io'; +import 'package:a2ui_core/a2ui_core.dart' as core; import 'package:catalog_gallery/sample_parser.dart'; import 'package:file/file.dart' as file_pkg; import 'package:flutter/material.dart'; @@ -38,13 +39,13 @@ void main() { catalogs: [BasicCatalogItems.asCatalog()], ); - await for (final A2uiMessage message in sample.messages) { + await for (final core.A2uiMessage message in sample.messages) { var messageToProcess = message; - if (message is CreateSurface) { + if (message is core.CreateSurfaceMessage) { // We manually inject the basic catalog since createSurface might ref // external URL in this test environment, we just assume the basic // catalog is available - messageToProcess = CreateSurface( + messageToProcess = core.CreateSurfaceMessage( surfaceId: message.surfaceId, catalogId: basicCatalogId, theme: message.theme, @@ -81,7 +82,7 @@ void main() { catalogs: [BasicCatalogItems.asCatalog()], ); - await for (final A2uiMessage message in sample.messages) { + await for (final core.A2uiMessage message in sample.messages) { controller.handleMessage(message); } diff --git a/dev_tools/catalog_gallery/test/sample_parser_test.dart b/dev_tools/catalog_gallery/test/sample_parser_test.dart index 5c4efadb4..97ae69623 100644 --- a/dev_tools/catalog_gallery/test/sample_parser_test.dart +++ b/dev_tools/catalog_gallery/test/sample_parser_test.dart @@ -2,9 +2,9 @@ // 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:catalog_gallery/sample_parser.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:genui/genui.dart'; void main() { test('SampleParser parses valid sample string', () async { @@ -21,19 +21,19 @@ description: A test description expect(sample.name, 'Test Sample'); expect(sample.description, 'A test description'); - final List messages = await sample.messages.toList(); + final List messages = await sample.messages.toList(); expect(messages.length, 2); - expect(messages.first, isA()); - expect(messages.last, isA()); + expect(messages.first, isA()); + expect(messages.last, isA()); - final update = messages.first as UpdateComponents; + final update = messages.first as core.UpdateComponentsMessage; expect(update.surfaceId, 'default'); expect(update.components.length, 1); - expect(update.components.first.type, 'Text'); + expect(update.components.first['component'], 'Text'); - final begin = messages.last as CreateSurface; + final begin = messages.last as core.CreateSurfaceMessage; expect(begin.surfaceId, 'default'); - // begin.root check removed as it doesn't exist in CreateSurface + // begin.root check removed as it doesn't exist in CreateSurfaceMessage }); test( @@ -48,7 +48,7 @@ description: A description '''; final Sample sample = SampleParser.parseString(sampleContent); expect(sample.name, 'Frontmatter Sample'); - final List messages = await sample.messages.toList(); + final List messages = await sample.messages.toList(); expect(messages.length, 1); }, ); @@ -61,7 +61,7 @@ description: A description '''; final Sample sample = SampleParser.parseString(sampleContent); expect(sample.name, 'Untitled Sample'); - final List messages = await sample.messages.toList(); + final List messages = await sample.messages.toList(); expect(messages.length, 1); }); diff --git a/dev_tools/catalog_gallery/test/samples_rendering_test.dart b/dev_tools/catalog_gallery/test/samples_rendering_test.dart index a9f8f8d44..adad62cf5 100644 --- a/dev_tools/catalog_gallery/test/samples_rendering_test.dart +++ b/dev_tools/catalog_gallery/test/samples_rendering_test.dart @@ -4,6 +4,7 @@ import 'dart:io'; +import 'package:a2ui_core/a2ui_core.dart' as core; import 'package:catalog_gallery/sample_parser.dart'; import 'package:file/file.dart'; import 'package:file/local.dart'; @@ -78,7 +79,7 @@ void main() { ); try { - await for (final A2uiMessage message in sample.messages) { + await for (final core.A2uiMessage message in sample.messages) { controller.handleMessage(message); await tester.pump(); } diff --git a/examples/verdure/client/lib/features/ai/ai_provider.dart b/examples/verdure/client/lib/features/ai/ai_provider.dart index 09b1e55b7..886da8b1e 100644 --- a/examples/verdure/client/lib/features/ai/ai_provider.dart +++ b/examples/verdure/client/lib/features/ai/ai_provider.dart @@ -5,6 +5,7 @@ import 'dart:async'; import 'dart:io'; +import 'package:a2ui_core/a2ui_core.dart' as core; import 'package:device_info_plus/device_info_plus.dart'; import 'package:flutter/foundation.dart'; import 'package:genui/genui.dart'; @@ -93,7 +94,7 @@ class Ai extends _$Ai { final surfaceUpdateController = StreamController.broadcast(); connector.stream.listen((message) { - if (message is CreateSurface) { + if (message is core.CreateSurfaceMessage) { surfaceUpdateController.add(message.surfaceId); } }); diff --git a/examples/verdure/client/pubspec.yaml b/examples/verdure/client/pubspec.yaml index 8b636dce4..89c178257 100644 --- a/examples/verdure/client/pubspec.yaml +++ b/examples/verdure/client/pubspec.yaml @@ -14,6 +14,7 @@ environment: resolution: workspace dependencies: + a2ui_core: ^0.0.1-wip002 device_info_plus: ^13.1.0 flutter: sdk: flutter diff --git a/packages/genui_a2a/lib/src/a2ui_agent_connector.dart b/packages/genui_a2a/lib/src/a2ui_agent_connector.dart index f5942d5e1..72f38f442 100644 --- a/packages/genui_a2a/lib/src/a2ui_agent_connector.dart +++ b/packages/genui_a2a/lib/src/a2ui_agent_connector.dart @@ -5,6 +5,7 @@ import 'dart:async'; import 'dart:convert'; +import 'package:a2ui_core/a2ui_core.dart' as core; import 'package:flutter/foundation.dart'; import 'package:genui/genui.dart' as genui; import 'package:logging/logging.dart'; @@ -52,7 +53,7 @@ class A2uiAgentConnector { ); } - final _controller = StreamController.broadcast(); + final _controller = StreamController.broadcast(); final _textController = StreamController.broadcast(); final _errorController = StreamController.broadcast(); @visibleForTesting @@ -68,7 +69,7 @@ class A2uiAgentConnector { String? get contextId => _contextId; /// The stream of A2UI messages. - Stream get stream => _controller.stream; + Stream get stream => _controller.stream; /// The stream of text responses. Stream get textStream => _textController.stream; @@ -338,7 +339,7 @@ class A2uiAgentConnector { data.containsKey('deleteSurface')) { if (!_controller.isClosed) { _log.finest('Adding message to stream: $prettyJson'); - _controller.add(genui.A2uiMessage.fromJson(data)); + _controller.add(core.A2uiMessage.fromJson(data)); } } else { _log.warning('A2A data part did not contain any known A2UI messages.'); diff --git a/packages/genui_a2a/pubspec.yaml b/packages/genui_a2a/pubspec.yaml index 6bd8838be..259dfd9f3 100644 --- a/packages/genui_a2a/pubspec.yaml +++ b/packages/genui_a2a/pubspec.yaml @@ -16,6 +16,7 @@ environment: resolution: workspace dependencies: + a2ui_core: ^0.0.1-wip002 collection: ^1.19.1 flutter: sdk: flutter diff --git a/packages/genui_a2a/test/a2ui_agent_connector_test.dart b/packages/genui_a2a/test/a2ui_agent_connector_test.dart index 8bc41420e..c739b8c8f 100644 --- a/packages/genui_a2a/test/a2ui_agent_connector_test.dart +++ b/packages/genui_a2a/test/a2ui_agent_connector_test.dart @@ -5,6 +5,7 @@ import 'dart:async'; import 'dart:typed_data'; +import 'package:a2ui_core/a2ui_core.dart' as core; import 'package:flutter_test/flutter_test.dart'; import 'package:genui/genui.dart' as genui; import 'package:genui_a2a/genui_a2a.dart'; @@ -103,7 +104,7 @@ void main() { ]; fakeClient.messageStreamHandler = (_) => Stream.fromIterable(responses); - final messages = []; + final messages = []; connector.stream.listen(messages.add); final userMessage = genui.ChatMessage.user( @@ -122,7 +123,7 @@ void main() { expect(connector.contextId, 'context1'); expect(fakeClient.messageStreamCalled, 1); expect(messages.length, 1); - expect(messages.first, isA()); + expect(messages.first, isA()); }); test('connectAndSend sends multiple text parts', () async { From 325955b1d01205a506df24e5265f62869456ed01 Mon Sep 17 00:00:00 2001 From: Andrew Kolos Date: Thu, 4 Jun 2026 12:54:59 -0700 Subject: [PATCH 20/49] refactor(genui): remove the DataModelStore facade DataModelStore existed only to preserve the legacy SurfaceController.store API; nothing outside genui used it. Inline its small pre-create buffering (a writable model handed out by contextFor(id).dataModel before the surface exists, migrated into the live core model on creation) directly into SurfaceController as private state, and drop the public `store` getter, the class, and its barrel export. Post-create, data models come from the live core surface; the dead attachSurface/detachSurface bookkeeping is gone. --- packages/genui/lib/src/engine.dart | 1 - .../lib/src/engine/data_model_store.dart | 91 ------------------- .../lib/src/engine/surface_controller.dart | 45 ++++++--- .../test/engine/data_model_store_test.dart | 70 -------------- .../test/engine/surface_controller_test.dart | 6 +- 5 files changed, 34 insertions(+), 179 deletions(-) delete mode 100644 packages/genui/lib/src/engine/data_model_store.dart delete mode 100644 packages/genui/test/engine/data_model_store_test.dart diff --git a/packages/genui/lib/src/engine.dart b/packages/genui/lib/src/engine.dart index 0ba6b7c5a..86e2dc429 100644 --- a/packages/genui/lib/src/engine.dart +++ b/packages/genui/lib/src/engine.dart @@ -5,6 +5,5 @@ /// The core runtime components for GenUI. library; -export 'engine/data_model_store.dart'; export 'engine/surface_controller.dart'; export 'engine/surface_registry.dart'; diff --git a/packages/genui/lib/src/engine/data_model_store.dart b/packages/genui/lib/src/engine/data_model_store.dart deleted file mode 100644 index 121d2c9e2..000000000 --- a/packages/genui/lib/src/engine/data_model_store.dart +++ /dev/null @@ -1,91 +0,0 @@ -// 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 '../model/data_model.dart'; - -/// A facade over per-surface data models managed by -/// `a2ui_core.SurfaceGroupModel`. -/// -/// Kept to preserve the legacy `SurfaceController.store` / -/// `store.getDataModel(surfaceId)` API. Pre-`createSurface`, returns a -/// standalone in-memory model; when `SurfaceController` later attaches a -/// live surface via [attachLive], any data written to the standalone model -/// is migrated into the live one and future [getDataModel] calls return the -/// live wrapper. -/// -/// Slated for removal alongside the rest of the GenUI->a2ui_core facade -/// renames. New code should read from `SurfaceController.registry -/// .getSurface(id)?.dataModel` directly. -class DataModelStore { - /// Creates a [DataModelStore]. - DataModelStore({DataModel? Function(String surfaceId)? lookup}) - : _lookup = lookup; - - final DataModel? Function(String surfaceId)? _lookup; - final Map _dataModels = {}; - final Map _liveDataModels = {}; - final Set _attachedSurfaces = {}; - - /// Retrieves the data model for the given [surfaceId], creating it if it - /// does not exist. Already-attached live models are returned directly - /// without re-invoking [_lookup]. - DataModel getDataModel(String surfaceId) { - final DataModel? cached = _liveDataModels[surfaceId]; - if (cached != null) return cached; - final DataModel? liveModel = _lookup?.call(surfaceId); - if (liveModel != null) { - _liveDataModels[surfaceId] = liveModel; - return liveModel; - } - return _dataModels.putIfAbsent(surfaceId, InMemoryDataModel.new); - } - - /// Caches [liveModel] for [surfaceId] and migrates any pre-create - /// fallback data into it. Callers that had a reference to the fallback - /// model must refetch via [getDataModel]. - void attachLive(String surfaceId, DataModel liveModel) { - final DataModel? fallback = _dataModels.remove(surfaceId); - if (fallback != null) { - final Object? snapshot = fallback.getValue(DataPath.root); - liveModel.update(DataPath.root, snapshot); - fallback.dispose(); - } - _liveDataModels[surfaceId] = liveModel; - } - - /// Removes the data model for the given [surfaceId] and detaches the surface. - void removeDataModel(String surfaceId) { - final DataModel? model = _dataModels.remove(surfaceId); - model?.dispose(); - final DataModel? liveModel = _liveDataModels.remove(surfaceId); - liveModel?.dispose(); - _attachedSurfaces.remove(surfaceId); - } - - /// Marks the surface with the given [surfaceId] as attached. - void attachSurface(String surfaceId) { - _attachedSurfaces.add(surfaceId); - } - - /// Marks the surface with the given [surfaceId] as detached. - void detachSurface(String surfaceId) { - _attachedSurfaces.remove(surfaceId); - } - - /// An unmodifiable map of all registered data models. - Map get dataModels => - Map.unmodifiable({..._dataModels, ..._liveDataModels}); - - /// Disposes of all data models in this store. - void dispose() { - for (final DataModel model in _dataModels.values) { - model.dispose(); - } - for (final DataModel model in _liveDataModels.values) { - model.dispose(); - } - _dataModels.clear(); - _liveDataModels.clear(); - } -} diff --git a/packages/genui/lib/src/engine/surface_controller.dart b/packages/genui/lib/src/engine/surface_controller.dart index e9cb06dfe..42e7cd24c 100644 --- a/packages/genui/lib/src/engine/surface_controller.dart +++ b/packages/genui/lib/src/engine/surface_controller.dart @@ -21,7 +21,6 @@ import '../model/ui_models.dart'; import '../primitives/a2ui_validation_exception.dart'; import '../primitives/logging.dart'; -import 'data_model_store.dart'; import 'surface_registry.dart' as surface_reg; /// The runtime controller for the GenUI system. @@ -51,13 +50,10 @@ interface class SurfaceController implements SurfaceHost, A2uiMessageSink { late final core.MessageProcessor _processor; late final surface_reg.SurfaceRegistry _registry = surface_reg.SurfaceRegistry(); - late final DataModelStore _store = DataModelStore( - lookup: (String surfaceId) { - final core.SurfaceModel? surface = _registry.getLiveSurface(surfaceId); - if (surface == null) return null; - return InMemoryDataModel.wrap(surface.dataModel); - }, - ); + // Writable data models handed out by `contextFor(id).dataModel` before the + // surface exists; migrated into the live core model on surface creation. + final Map _preCreateDataModels = {}; + final Map _liveDataModels = {}; final _onSubmit = StreamController.broadcast(); final _pendingUpdates = >{}; @@ -95,8 +91,17 @@ interface class SurfaceController implements SurfaceHost, A2uiMessageSink { /// The registry of surfaces managed by this controller. surface_reg.SurfaceRegistry get registry => _registry; - /// The store of data models managed by this controller. - DataModelStore get store => _store; + DataModel _dataModelFor(String surfaceId) { + final DataModel? live = _liveDataModels[surfaceId]; + if (live != null) return live; + final core.SurfaceModel? surface = _registry.getLiveSurface(surfaceId); + if (surface != null) { + final DataModel wrapped = InMemoryDataModel.wrap(surface.dataModel); + _liveDataModels[surfaceId] = wrapped; + return wrapped; + } + return _preCreateDataModels.putIfAbsent(surfaceId, InMemoryDataModel.new); + } /// Processes a message from the AI service. @override @@ -233,7 +238,13 @@ interface class SurfaceController implements SurfaceHost, A2uiMessageSink { // registry listeners; otherwise a synchronous listener could call // contextFor(...).dataModel and cache an empty live wrapper before the // fallback's data is copied in. - _store.attachLive(surface.id, InMemoryDataModel.wrap(surface.dataModel)); + final DataModel live = InMemoryDataModel.wrap(surface.dataModel); + final DataModel? fallback = _preCreateDataModels.remove(surface.id); + if (fallback != null) { + live.update(DataPath.root, fallback.getValue(DataPath.root)); + fallback.dispose(); + } + _liveDataModels[surface.id] = live; _registry.addSurface(surface); final List? pending = _pendingUpdates.remove(surface.id); _pendingUpdateTimers.remove(surface.id)?.cancel(); @@ -247,7 +258,8 @@ interface class SurfaceController implements SurfaceHost, A2uiMessageSink { void _onCoreSurfaceDeleted(String surfaceId) { _pendingUpdates.remove(surfaceId); _pendingUpdateTimers.remove(surfaceId)?.cancel(); - _store.removeDataModel(surfaceId); + _preCreateDataModels.remove(surfaceId)?.dispose(); + _liveDataModels.remove(surfaceId)?.dispose(); _registry.removeSurface(surfaceId); } @@ -340,7 +352,12 @@ interface class SurfaceController implements SurfaceHost, A2uiMessageSink { _onCoreSurfaceDeleted, ); _processor.groupModel.dispose(); - _store.dispose(); + for (final DataModel model in _preCreateDataModels.values) { + model.dispose(); + } + for (final DataModel model in _liveDataModels.values) { + model.dispose(); + } _registry.dispose(); _onSubmit.close(); for (final Timer timer in _pendingUpdateTimers.values) { @@ -365,7 +382,7 @@ class _ControllerContext implements LiveSurfaceContext { _controller.registry.watchDefinition(surfaceId); @override - DataModel get dataModel => _controller.store.getDataModel(surfaceId); + DataModel get dataModel => _controller._dataModelFor(surfaceId); @override Catalog? get catalog => _controller._findCatalogForSurface(surfaceId); diff --git a/packages/genui/test/engine/data_model_store_test.dart b/packages/genui/test/engine/data_model_store_test.dart deleted file mode 100644 index f1d76affd..000000000 --- a/packages/genui/test/engine/data_model_store_test.dart +++ /dev/null @@ -1,70 +0,0 @@ -// 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:flutter_test/flutter_test.dart'; -import 'package:genui/src/engine/data_model_store.dart'; -import 'package:genui/src/model/data_model.dart'; - -void main() { - group('DataModelStore', () { - late DataModelStore store; - - setUp(() { - store = DataModelStore(); - }); - - test('getDataModel creates new model if not exists', () { - final DataModel model = store.getDataModel('s1'); - expect(model, isNotNull); - expect(store.dataModels['s1'], same(model)); - }); - - test('getDataModel returns existing model', () { - final DataModel model1 = store.getDataModel('s1'); - final DataModel model2 = store.getDataModel('s1'); - expect(model1, same(model2)); - }); - - test('removeDataModel removes model and detaches surface', () { - final DataModel model = store.getDataModel('s1'); - store.attachSurface('s1'); - - store.removeDataModel('s1'); - expect(store.dataModels.containsKey('s1'), isFalse); - // We can't directly check `_attachedSurfaces`, but calling detach - // directly shouldn't throw. (Internal state check is implicit via - // coverage) - - // We check that getting it again returns a new one - final DataModel newModel = store.getDataModel('s1'); - expect(model, isNot(same(newModel))); - }); - - test('attachSurface and detachSurface', () { - // These are tested primarily through side-effects in DataModelStore, - // but their execution shouldn't throw errors. - store.attachSurface('s1'); - store.detachSurface('s1'); - expect(true, isTrue); - }); - - test('dataModels returns unmodifiable map', () { - store.getDataModel('s1'); - expect( - () => store.dataModels['s2'] = InMemoryDataModel(), - throwsUnsupportedError, - ); - }); - - test('dispose calls dispose on all data models', () { - store.getDataModel('s1'); - store.getDataModel('s2'); - expect(store.dataModels.length, 2); - - store.dispose(); - // Dispose should not throw - expect(true, isTrue); - }); - }); -} diff --git a/packages/genui/test/engine/surface_controller_test.dart b/packages/genui/test/engine/surface_controller_test.dart index 92c0129b5..a6aa9d649 100644 --- a/packages/genui/test/engine/surface_controller_test.dart +++ b/packages/genui/test/engine/surface_controller_test.dart @@ -177,7 +177,7 @@ void main() { }, ); - test('store exposes the live surface data model facade', () { + test('contextFor exposes the live surface data model', () { const surfaceId = 's1'; controller.handleMessage( createSurface(surfaceId: surfaceId, catalogId: 'test_catalog'), @@ -190,9 +190,9 @@ void main() { ), ); - final DataModel model = controller.store.getDataModel(surfaceId); + final DataModel model = controller.contextFor(surfaceId).dataModel; expect(model.getValue(DataPath('/name')), 'Alice'); - expect(controller.store.dataModels[surfaceId], same(model)); + expect(controller.contextFor(surfaceId).dataModel, same(model)); controller.handleMessage( updateDataModel( From dfbd5bc924f07055004503e1978181b4d456c73a Mon Sep 17 00:00:00 2001 From: Andrew Kolos Date: Thu, 4 Jun 2026 12:59:57 -0700 Subject: [PATCH 21/49] docs(genui,genui_a2a): correct CHANGELOG for the a2ui_core message migration The 0.10.0 entry described the abandoned "all public types preserved as facades" approach. Update it to reflect reality: A2UI message types and DataModelStore are removed (not facaded), with the breaking changes spelled out; SurfaceDefinition/Component are noted as the retained snapshot types pending #801. Add the matching breaking-change entry to genui_a2a. --- packages/genui/CHANGELOG.md | 40 ++++++++++++++++++++++++--------- packages/genui_a2a/CHANGELOG.md | 6 +++++ 2 files changed, 35 insertions(+), 11 deletions(-) diff --git a/packages/genui/CHANGELOG.md b/packages/genui/CHANGELOG.md index f9d89952b..9dcc7901e 100644 --- a/packages/genui/CHANGELOG.md +++ b/packages/genui/CHANGELOG.md @@ -3,8 +3,28 @@ ## 0.10.0 (in progress) - **Refactor**: Migrate the runtime substrate onto `package:a2ui_core`. - Public GenUI types are preserved as compatibility facades; see - [docs/usage/migration/migration_genui_onto_a2ui_core.md](../../docs/usage/migration/migration_genui_onto_a2ui_core.md). + `SurfaceController` delegates to `a2ui_core.MessageProcessor`, and surface, + component, and data-model state are backed by the live `a2ui_core` models. + See + [the migration guide](../../docs/usage/migration/migration_genui_onto_a2ui_core.md). +- **BREAKING**: A2UI message types are now `package:a2ui_core` types directly. + The GenUI message classes (`A2uiMessage`, `CreateSurface`, `UpdateComponents`, + `UpdateDataModel`, `DeleteSurface`) are removed. `SurfaceController.handleMessage`, + `Transport.incomingMessages`, and `A2uiMessageEvent.message` now use + `core.A2uiMessage`. Construct messages with `core.CreateSurfaceMessage(...)` + etc.; `core.UpdateComponentsMessage` carries raw component JSON maps rather + than `Component` objects. Message-handling consumers should depend on + `a2ui_core` directly. +- **BREAKING**: `SurfaceController.store` and the `DataModelStore` class are + removed. Read a surface's data model via + `SurfaceController.contextFor(id).dataModel` (writable before the surface is + created) or `SurfaceController.registry.getSurface(id)?.dataModel`. +- **BREAKING**: `SurfaceRegistry.updateSurface(...)` is removed. Surface + lifecycle now flows through `SurfaceController.handleMessage`; the + definition-only push path could not be preserved without diverging from + the live `a2ui_core` surface model. `SurfaceRegistry.addSurface` / + `notifyUpdated` exist as internal lifecycle hooks and are marked + `@internal`. - **Behavior**: `DataModel` writes are stricter (core data errors on type-mismatched intermediate paths and excessively large list indices) and sparse list writes now fill skipped entries with `null` instead of @@ -14,15 +34,13 @@ - **Behavior**: JSON Pointer `~0`/`~1` escapes are not interpreted on `DataPath`; paths split on `/`, matching the web reference implementation (see A2UI#1499 tracking spec clarification). -- **BREAKING**: `SurfaceRegistry.updateSurface(...)` is removed. Surface - lifecycle now flows through `SurfaceController.handleMessage`; the - definition-only push path could not be preserved without diverging from - the live `a2ui_core` surface model. `SurfaceRegistry.addSurface` / - `notifyUpdated` exist as internal lifecycle hooks and are marked - `@internal`. -- **Internal**: The live `core.SurfaceModel` fields on `SurfaceAdded` / - `ComponentsUpdated` are marked `@internal`. Most consumers should read - `SurfaceUpdate.definition` instead. +- **Internal**: The renderer rebuilds each surface from its `SurfaceDefinition` + snapshot. The live `core.SurfaceModel` fields on `SurfaceAdded` / + `ComponentsUpdated` are marked `@internal`; consumers read + `SurfaceUpdate.definition`. +- The catalog-widget authoring API is unchanged. `SurfaceDefinition` and + `Component` remain GenUI snapshot types, to be unified with the `a2ui_core` + models in a follow-up (#801). ## 0.9.1 diff --git a/packages/genui_a2a/CHANGELOG.md b/packages/genui_a2a/CHANGELOG.md index bfedd9f4c..3ffed97e3 100644 --- a/packages/genui_a2a/CHANGELOG.md +++ b/packages/genui_a2a/CHANGELOG.md @@ -1,5 +1,11 @@ # `genui_a2a` Changelog +## 0.10.0 (in progress) + +- **BREAKING**: `A2uiAgentConnector.stream` now emits `package:a2ui_core` + message types (`core.A2uiMessage`) rather than the removed GenUI message + facades. Depend on `a2ui_core` directly to consume them. + ## 0.9.0 - **BREAKING**: `A2uiAgentConnector` constructor now requires exactly one of `url` or `client` (#886). From 93af987d267469a2ea49ca51e9234bd84585306d Mon Sep 17 00:00:00 2001 From: Andrew Kolos Date: Thu, 4 Jun 2026 13:06:07 -0700 Subject: [PATCH 22/49] docs(genui): rewrite the a2ui_core migration guide to match reality The guide described the abandoned facade approach: kept message classes, a SurfaceController.store facade, and granular per-component rebuilds. Rewrite it around what actually ships: A2UI messages and DataModelStore are removed (with before/after code), the catalog authoring API + surface/data-model snapshots are the retained types pending #801, and the renderer rebuilds from the SurfaceDefinition snapshot. --- .../migration_genui_onto_a2ui_core.md | 249 ++++++++++-------- 1 file changed, 132 insertions(+), 117 deletions(-) diff --git a/docs/usage/migration/migration_genui_onto_a2ui_core.md b/docs/usage/migration/migration_genui_onto_a2ui_core.md index 3110a2f33..d11d7b775 100644 --- a/docs/usage/migration/migration_genui_onto_a2ui_core.md +++ b/docs/usage/migration/migration_genui_onto_a2ui_core.md @@ -1,122 +1,137 @@ # Migration Guide: GenUI on `a2ui_core` -This PR migrates `package:genui`'s runtime substrate onto the shared -`package:a2ui_core` implementation, but it intentionally keeps the existing -GenUI-facing API shape as a compatibility facade. - -The goal is to make this PR about the substrate swap (#811): shared protocol -parsing, shared surface/data-model state, and granular Flutter rebuilds. Public -API renames for closer web/core parity are deferred to a follow-up PR (#801). - -## Scope - -Substrate / state migration only. Deferred to follow-ups: - -- Catalog widget bodies stay on `CatalogItemContext`. A typed-props authoring - API (flutter/genui#801) is on hold until the upstream Node Layer - (A2UI#1282) settles. +`package:genui`'s runtime substrate now runs on the shared `package:a2ui_core` +implementation: shared protocol parsing, message processing, and +surface/data-model state (#811). + +This migration unifies the **message layer** with `a2ui_core` directly. The +catalog-widget authoring API is intentionally left unchanged, pending the +typed-props authoring API (flutter/genui#801) and the upstream Node Layer +(A2UI#1282). So the surface/component snapshot types and the data-model API are +retained for now as GenUI types. + +## Breaking changes + +### A2UI messages are now `a2ui_core` types + +The GenUI message classes (`A2uiMessage`, `CreateSurface`, `UpdateComponents`, +`UpdateDataModel`, `DeleteSurface`) are removed. Use the `a2ui_core` types +directly: + +- Add `a2ui_core` as a dependency of any package that builds or inspects A2UI + messages. +- `SurfaceController.handleMessage`, `Transport.incomingMessages`, and + `A2uiMessageEvent.message` now use `core.A2uiMessage`. +- Construct messages with `core.CreateSurfaceMessage(...)`, + `core.UpdateComponentsMessage(...)`, `core.UpdateDataModelMessage(...)` / + `.removeKey(...)`, and `core.DeleteSurfaceMessage(...)`. +- `core.UpdateComponentsMessage.components` is a `List>` of + raw component wire JSON (`{'id': ..., 'component': ..., ...props}`), not + `Component` objects. +- Parse from JSON with `core.A2uiMessage.fromJson(json)`. + +```dart +// Before +controller.handleMessage( + UpdateComponents(surfaceId: 's', components: [ + Component(id: 'root', type: 'Text', properties: {'text': 'Hi'}), + ]), +); +controller.handleMessage(CreateSurface(surfaceId: 's', catalogId: 'demo')); + +// After +import 'package:a2ui_core/a2ui_core.dart' as core; + +controller.handleMessage( + core.UpdateComponentsMessage(surfaceId: 's', components: [ + {'id': 'root', 'component': 'Text', 'text': 'Hi'}, + ]), +); +controller.handleMessage( + core.CreateSurfaceMessage(surfaceId: 's', catalogId: 'demo'), +); +``` + +### `SurfaceController.store` / `DataModelStore` removed + +Read a surface's data model directly: + +- `SurfaceController.contextFor(id).dataModel` is writable, and usable before the + surface is created (the data is migrated into the live model on creation). +- `SurfaceController.registry.getSurface(id)?.dataModel` once the surface exists. + +### `SurfaceRegistry.updateSurface(...)` removed + +Surface lifecycle updates flow through `SurfaceController.handleMessage`; the +definition-only push path is no longer supported. `addSurface` and +`notifyUpdated` remain on `SurfaceRegistry` but are marked `@internal`. + +## What stays the same (for now) + +These are unchanged by this migration and remain GenUI types, pending #801: + +- **Catalog widget authoring.** `CatalogItemContext.id`, `type`, `data`, + `surfaceId`, `getComponent` (returns a `Component?` snapshot), `dataContext`, + `buildChild`, `dispatchEvent`, and `reportError`. Catalog widget bodies do not + change. +- **Data model API.** `DataPath`, `DataModel`, `InMemoryDataModel`, + `DataContext.update` / `getValue` / `subscribe` / `bindExternalState`, and the + `Bound*` widgets. +- **Surface snapshots.** `Component`, `SurfaceDefinition`, + `SurfaceContext.definition`, `SurfaceUpdate.definition`, + `ActionDelegate.handleEvent`'s `SurfaceDefinition` callback, and + `UiPart.create(definition: SurfaceDefinition(...))`. + +Internally these wrap or snapshot from `a2ui_core`: `InMemoryDataModel` wraps +`a2ui_core.DataModel`; `CatalogItemContext` is backed by +`a2ui_core.ComponentContext`; `SurfaceDefinition` is a snapshot of the live +`a2ui_core.SurfaceModel`. + +The live core model is reachable through a few `@internal` APIs +(`SurfaceAdded.surface`, `ComponentsUpdated.surface`, +`SurfaceRegistry.watchLiveSurface` / `getLiveSurface`). Prefer +`SurfaceUpdate.definition` or `SurfaceRegistry.getSurface` / `watchSurface` +(which return `SurfaceDefinition`) unless you specifically need live core access. + +## Behavior changes to watch for + +These come from the `a2ui_core` substrate, independent of any rename: + +1. **`DataModel` is stricter.** Writes that previously no-op'd now throw core + data errors, especially type-mismatched intermediate paths and very large + list indices. Sparse list writes fill skipped entries with `null`. +2. **Stored containers are mutable copies.** Incoming map/list values are copied + before storage so nested updates work even when callers pass const literals. +3. **Data reactivity is signal-backed internally.** `subscribe(...)` still + returns a `ValueListenable` and the `Bound*` widgets keep their API; + internally those listenables bridge to `preact_signals`. +4. **Protocol validation is stricter.** The core parser rejects malformed + messages more consistently, including missing/incorrect versions and messages + with more than one top-level action key. +5. **Duplicate `createSurface` for an active surface id is an error** instead of + silently reusing the existing surface. +6. **JSON Pointer `~0`/`~1` escapes are not interpreted.** Paths split on `/`, + matching the web core behavior (A2UI#1499 tracks the spec clarification). +7. **The renderer rebuilds from the `SurfaceDefinition` snapshot.** The built-in + `Surface` rebuilds when a surface's snapshot changes; data-bound values update + through the `Bound*` widgets. +8. **`core.UpdateDataModelMessage.hasValue`** distinguishes `value: null` from an + omitted `value` on the wire, but runtime mutation currently treats both as + "remove the key" (flutter/genui#938, A2UI#1504). + +## Still deferred + +- The catalog widget authoring API stays on `CatalogItemContext`; a typed-props + API (#801) is on hold until A2UI#1282 settles. - Action dispatch and `sendDataModel` synchronization still flow through the - existing GenUI path, not `core.SurfaceGroupModel.onAction` or + existing GenUI path rather than `core.SurfaceGroupModel.onAction` or `MessageProcessor.getClientDataModel()`. - `GenericBinder` is not exposed as a Flutter-side public API. -- `UpdateDataModelMessage.hasValue` is parsed and serialized losslessly - through the genui facade (with round-trip test coverage), but runtime - mutation still treats both `value: null` and an omitted `value` as "remove - the key" pending flutter/genui#938 (adds `DataModel.remove` and the - processor-level branch). -- `DataPath` no longer interprets RFC 6901 `~0` / `~1` escapes, matching the - TypeScript reference implementation. A2UI#1499 tracks the spec - clarification. - -## What stays source-compatible in this PR - -GenUI applications and catalog authors should continue to use the existing -`package:genui` API names: - -- `CreateSurface`, `UpdateComponents`, `UpdateDataModel`, `DeleteSurface`, and - `A2uiMessage.fromJson`. -- `DataPath`, `DataModel`, `InMemoryDataModel`, `DataContext.update`, - `getValue`, `subscribe`, and `bindExternalState`. -- `Component` and `SurfaceDefinition` snapshot/value objects. -- `SurfaceContext.definition`, `SurfaceUpdate.definition`, and `SurfaceController.store` / `DataModelStore`. -- `ActionDelegate.handleEvent`'s existing `SurfaceDefinition` callback shape. -- Catalog widget authoring through `CatalogItemContext.id`, `type`, `data`, - `surfaceId`, `getComponent`, `dataContext`, `buildChild`, `dispatchEvent`, - and `reportError`. -- `UiPart.create(definition: SurfaceDefinition(...))`. - -Most consumers should **not** need to add `a2ui_core` to application or example -packages. Catalog widget authors and apps that use the existing facade API stay -on GenUI types. - -The exception is integrators reaching for the live surface model. A few APIs -that exist for GenUI-internal use cross the boundary and expose -`a2ui_core.SurfaceModel`: `SurfaceAdded.surface`, `ComponentsUpdated.surface`, -`SurfaceRegistry.watchLiveSurface`, and `SurfaceRegistry.getLiveSurface`. -These are marked `@internal`; use `SurfaceUpdate.definition` or -`SurfaceRegistry.getSurface` / `watchSurface` (which still return -`SurfaceDefinition`) unless you specifically need live core access. - -## What changed internally - -The compatibility types above now delegate to, wrap, or snapshot from -`a2ui_core`: - -- `SurfaceController.handleMessage(...)` accepts GenUI facade messages, converts - them to core messages privately, and delegates state mutation to - `a2ui_core.MessageProcessor`. -- `InMemoryDataModel` wraps `a2ui_core.DataModel` while preserving the old - `DataPath`/`ValueListenable` API. -- GenUI's own `SurfaceController` + `Surface` path renders from the live core - `SurfaceModel`, so component updates can rebuild only the affected component - subtree. -- `SurfaceController.store` remains available as a compatibility facade; for - live surfaces it returns wrappers around the surface's core-backed data model. -- Custom/external `SurfaceContext` implementations can still provide only the - legacy `definition` snapshot path. -- `CatalogItemContext` is internally backed by `a2ui_core.ComponentContext`, but - the public authoring surface remains the GenUI shim getters and callbacks. - `getComponent()` returns a GenUI `Component?` snapshot. - -## Remaining behavior changes to watch for - -These are substrate behavior changes, not rename requirements: - -1. **`DataModel` is stricter.** Some writes that previously no-op'd now throw - core data errors, especially type-mismatched intermediate paths and very - large list indices. Sparse list writes are also core-style: skipped entries - are filled with `null`. -2. **Stored containers are mutable copies.** Incoming map/list values are copied - before storage so nested updates work even when callers pass const literals. -3. **Surfaces are live internally.** Public `SurfaceDefinition` snapshots remain - available, but GenUI's built-in renderer uses live component models for - granular rebuilds. -4. **Data reactivity is signal-backed internally.** Public `subscribe(...)` - still returns a `ValueListenable`, and `Bound*` widgets keep their existing - API. Internally, those listenables bridge to `preact_signals` from - `a2ui_core`. -5. **Protocol validation is stricter.** The core parser rejects malformed - messages more consistently, including missing/incorrect versions and - messages with more than one top-level action key. -6. **Duplicate `createSurface` for an active surface id is an error** instead - of silently reusing the existing surface. -7. **JSON Pointer `~0`/`~1` escapes are not interpreted.** Paths split on `/`, - matching the web core behavior. -8. **`SurfaceRegistry.updateSurface(...)` is removed.** Surface lifecycle - updates now flow through `SurfaceController.handleMessage`; the - definition-only push path is no longer supported. `addSurface` and - `notifyUpdated` exist on `SurfaceRegistry` but are marked `@internal`. - -## On the future of the compatibility facades - -The GenUI-named types in this release (`CreateSurface`, `InMemoryDataModel`, -`DataPath`, `SurfaceDefinition`, `Component`, `SurfaceContext.definition`, -`SurfaceController.store`, etc.) are kept as a compatibility API on top of -`a2ui_core`. They're stable; you can write new code against them today. - -A future PR may add `a2ui_core`-shaped aliases or replacements for closer -cross-language parity (`CreateSurfaceMessage`, string paths, `SurfaceModel`, -raw map component payloads, etc.). That PR is not scheduled, and the existing -GenUI names will not be removed without a separate deprecation cycle. Treat -the facade types in this release as the current public API, not as -short-lived shims. + +## What's next + +A follow-up (#801), gated on the upstream Node Layer (A2UI#1282), will unify the +retained GenUI types (`Component`, `SurfaceDefinition`, the `DataModel` API, +`SurfaceContext.definition`) with the `a2ui_core` models so the catalog authoring +API can move onto core types too. Until then, the types under "What stays the +same" are the current public API. From 8c76afa760ef140c4171fc0dfac570bb17483b54 Mon Sep 17 00:00:00 2001 From: Andrew Kolos Date: Thu, 4 Jun 2026 13:21:48 -0700 Subject: [PATCH 23/49] refactor(genui): restore DataPath typing on ExecutionContext/DataContext The data-substrate migration widened the catalog dataContext path parameters (ExecutionContext / DataContext subscribe, subscribeStream, getValue, update, nested, resolvePath, and the constructor) from DataPath to Object, with a runtime _toDataPath('$path') converter, to also accept raw string paths. main typed these as DataPath, every caller already passes DataPath(...), and the widening was used by exactly two test lines. It dropped compile-time checking and loosened the frozen catalog authoring API. Restore DataPath and remove the converter. --- .../genui/lib/src/model/client_function.dart | 12 ++++----- packages/genui/lib/src/model/data_model.dart | 25 +++++++------------ .../test/widgets/widget_utilities_test.dart | 4 +-- 3 files changed, 17 insertions(+), 24 deletions(-) diff --git a/packages/genui/lib/src/model/client_function.dart b/packages/genui/lib/src/model/client_function.dart index e2d9e1984..d025cacf4 100644 --- a/packages/genui/lib/src/model/client_function.dart +++ b/packages/genui/lib/src/model/client_function.dart @@ -20,22 +20,22 @@ abstract interface class ExecutionContext { ClientFunction? getFunction(String name); /// Subscribes to a path, resolving it against the current context. - ValueListenable subscribe(Object path); + ValueListenable subscribe(DataPath path); /// Subscribes to a path and returns a [Stream]. - Stream subscribeStream(Object path); + Stream subscribeStream(DataPath path); /// Gets a value, resolving the path against the current context. - T? getValue(Object path); + T? getValue(DataPath path); /// Updates the data model, resolving the path against the current context. - void update(Object path, Object? contents); + void update(DataPath path, Object? contents); /// Creates a new, nested ExecutionContext for a child widget. - ExecutionContext nested(Object relativePath); + ExecutionContext nested(DataPath relativePath); /// Resolves a path against the current context's path. - DataPath resolvePath(Object pathToResolve); + DataPath resolvePath(DataPath pathToResolve); /// Resolves any dynamic values (bindings or function calls) in the given /// value. diff --git a/packages/genui/lib/src/model/data_model.dart b/packages/genui/lib/src/model/data_model.dart index 70f622bfb..e262a22d0 100644 --- a/packages/genui/lib/src/model/data_model.dart +++ b/packages/genui/lib/src/model/data_model.dart @@ -16,10 +16,6 @@ import 'data_path.dart'; export 'data_path.dart'; -/// Converts either a legacy [DataPath] or a string path into [DataPath]. -DataPath _toDataPath(Object path) => - path is DataPath ? path : DataPath('$path'); - /// Exception thrown when a value in the [DataModel] is not of the expected /// type. class DataModelTypeException implements Exception { @@ -151,10 +147,9 @@ class DataContext implements cf.ExecutionContext { /// Creates a [DataContext] for the given [path]. DataContext( this._dataModel, - Object path, { + this.path, { Iterable? functions, - }) : path = _toDataPath(path), - _functions = { + }) : _functions = { if (functions != null) for (final f in functions) f.name: f, }; @@ -178,14 +173,14 @@ class DataContext implements cf.ExecutionContext { /// Subscribes to a path, resolving it against the current context. @override - ValueListenable subscribe(Object path) { + ValueListenable subscribe(DataPath path) { final DataPath absolutePath = resolvePath(path); return _dataModel.subscribe(absolutePath); } /// Subscribes to a path and returns a [Stream]. @override - Stream subscribeStream(Object path) { + Stream subscribeStream(DataPath path) { late StreamController controller; ValueListenable? notifier; @@ -216,24 +211,22 @@ class DataContext implements cf.ExecutionContext { /// Gets a value, resolving the path against the current context. @override - T? getValue(Object path) => _dataModel.getValue(resolvePath(path)); + T? getValue(DataPath path) => _dataModel.getValue(resolvePath(path)); /// Updates the data model, resolving the path against the current context. @override - void update(Object path, Object? contents) => + void update(DataPath path, Object? contents) => _dataModel.update(resolvePath(path), contents); /// Creates a new, nested DataContext for a child widget. @override - DataContext nested(Object relativePath) => + DataContext nested(DataPath relativePath) => DataContext._(_dataModel, resolvePath(relativePath), _functions); /// Resolves a path against the current context's path. @override - DataPath resolvePath(Object pathToResolve) { - final DataPath path = _toDataPath(pathToResolve); - return path.isAbsolute ? path : this.path.join(path); - } + DataPath resolvePath(DataPath pathToResolve) => + pathToResolve.isAbsolute ? pathToResolve : path.join(pathToResolve); /// Resolves any dynamic values (bindings or function calls) in the given /// value. diff --git a/packages/genui/test/widgets/widget_utilities_test.dart b/packages/genui/test/widgets/widget_utilities_test.dart index 372547a10..e0879e1a5 100644 --- a/packages/genui/test/widgets/widget_utilities_test.dart +++ b/packages/genui/test/widgets/widget_utilities_test.dart @@ -18,7 +18,7 @@ void main() { Directionality( textDirection: TextDirection.ltr, child: BoundObject( - dataContext: DataContext(dataModel, '/'), + dataContext: DataContext(dataModel, DataPath.root), value: const {'path': '/map'}, builder: (context, value) { if (value is Map) { @@ -52,7 +52,7 @@ void main() { Directionality( textDirection: TextDirection.ltr, child: BoundList( - dataContext: DataContext(dataModel, '/'), + dataContext: DataContext(dataModel, DataPath.root), value: const {'path': '/items'}, builder: (context, value) { if (value == null) return const Text('null'); From 44e65f13427398a7f1aca31a87fc03a7b4603668 Mon Sep 17 00:00:00 2001 From: Andrew Kolos Date: Thu, 4 Jun 2026 13:30:14 -0700 Subject: [PATCH 24/49] refactor(genui): remove dead granular-rebuild plumbing The live per-component render path was removed earlier; this drops the scaffolding that only existed to feed it: - LiveSurfaceContext (and its surface getter) and SurfaceRegistry.watchLiveSurface had no remaining readers. - The @internal `surface` fields on SurfaceAdded/ComponentsUpdated were write-only; fromCore still snapshots the definition from the core surface. - SurfaceRegistry._surfaces is now a plain map instead of a ValueNotifier map (nothing watches it; getLiveSurface still reads it for the data model). Migration guide updated to drop the removed @internal live-surface APIs. --- .../migration_genui_onto_a2ui_core.md | 7 +-- .../lib/src/engine/surface_controller.dart | 6 +-- .../lib/src/engine/surface_registry.dart | 43 +++++-------------- packages/genui/lib/src/interfaces.dart | 2 +- .../lib/src/interfaces/surface_context.dart | 10 ----- packages/genui/lib/src/model/ui_models.dart | 41 +++++------------- .../test/engine/surface_controller_test.dart | 2 - 7 files changed, 25 insertions(+), 86 deletions(-) diff --git a/docs/usage/migration/migration_genui_onto_a2ui_core.md b/docs/usage/migration/migration_genui_onto_a2ui_core.md index d11d7b775..5af047f7f 100644 --- a/docs/usage/migration/migration_genui_onto_a2ui_core.md +++ b/docs/usage/migration/migration_genui_onto_a2ui_core.md @@ -87,11 +87,8 @@ Internally these wrap or snapshot from `a2ui_core`: `InMemoryDataModel` wraps `a2ui_core.ComponentContext`; `SurfaceDefinition` is a snapshot of the live `a2ui_core.SurfaceModel`. -The live core model is reachable through a few `@internal` APIs -(`SurfaceAdded.surface`, `ComponentsUpdated.surface`, -`SurfaceRegistry.watchLiveSurface` / `getLiveSurface`). Prefer -`SurfaceUpdate.definition` or `SurfaceRegistry.getSurface` / `watchSurface` -(which return `SurfaceDefinition`) unless you specifically need live core access. +Surface state is read through the `SurfaceDefinition` snapshot: +`SurfaceUpdate.definition`, and `SurfaceRegistry.getSurface` / `watchSurface`. ## Behavior changes to watch for diff --git a/packages/genui/lib/src/engine/surface_controller.dart b/packages/genui/lib/src/engine/surface_controller.dart index 42e7cd24c..eabc0b39a 100644 --- a/packages/genui/lib/src/engine/surface_controller.dart +++ b/packages/genui/lib/src/engine/surface_controller.dart @@ -366,17 +366,13 @@ interface class SurfaceController implements SurfaceHost, A2uiMessageSink { } } -class _ControllerContext implements LiveSurfaceContext { +class _ControllerContext implements SurfaceContext { _ControllerContext(this._controller, this.surfaceId); final SurfaceController _controller; @override final String surfaceId; - @override - ValueListenable get surface => - _controller.registry.watchLiveSurface(surfaceId); - @override ValueListenable get definition => _controller.registry.watchDefinition(surfaceId); diff --git a/packages/genui/lib/src/engine/surface_registry.dart b/packages/genui/lib/src/engine/surface_registry.dart index e255d757c..58aa319e8 100644 --- a/packages/genui/lib/src/engine/surface_registry.dart +++ b/packages/genui/lib/src/engine/surface_registry.dart @@ -75,7 +75,7 @@ class SurfaceUpdated extends RegistryEvent { /// Tracks live [SurfaceModel]s by surface ID and exposes Flutter-friendly /// [ValueListenable]s for them, plus a registry-event stream. class SurfaceRegistry { - final Map> _surfaces = {}; + final Map _surfaces = {}; final Map> _definitions = {}; final List _surfaceOrder = []; @@ -107,27 +107,12 @@ class SurfaceRegistry { ); } - /// Returns a [ValueListenable] tracking the live core surface model for - /// [surfaceId]. Intended for GenUI internals. - @internal - ValueListenable watchLiveSurface(String surfaceId) { - if (!_surfaces.containsKey(surfaceId)) { - genUiLogger.fine('Adding new surface watcher for $surfaceId'); - } - return _surfaces.putIfAbsent( - surfaceId, - () => ValueNotifier(null), - ); - } - /// Registers a new surface, emitting a [SurfaceAdded] event. Intended /// for GenUI internals; external callers should drive surface lifecycle /// through `SurfaceController.handleMessage`. @internal void addSurface(SurfaceModel surface) { - final ValueNotifier?> notifier = _surfaces - .putIfAbsent(surface.id, () => ValueNotifier(null)); - notifier.value = surface; + _surfaces[surface.id] = surface; _definitions .putIfAbsent( surface.id, @@ -163,24 +148,20 @@ class SurfaceRegistry { /// Removes a surface from the registry, emitting a [SurfaceRemoved] event. /// - /// The per-id [ValueNotifier] is intentionally retained so widgets already - /// listening stay connected; a later re-create of the same id updates the - /// existing notifier. The [SurfaceModel] is owned and disposed by the - /// substrate's `core.SurfaceGroupModel`. + /// The per-id definition [ValueNotifier] is intentionally retained (reset to + /// `null`) so widgets already listening stay connected; a later re-create of + /// the same id updates the existing notifier. The [SurfaceModel] is owned and + /// disposed by the substrate's `core.SurfaceGroupModel`. void removeSurface(String surfaceId) { - final ValueNotifier?>? notifier = - _surfaces[surfaceId]; - if (notifier == null || notifier.value == null) return; + if (_surfaces.remove(surfaceId) == null) return; genUiLogger.info('Deleting surface $surfaceId'); - notifier.value = null; _definitions[surfaceId]?.value = null; _surfaceOrder.remove(surfaceId); _eventController.add(SurfaceRemoved(surfaceId)); } - /// Returns true if the registry has a watcher (or live surface) for - /// [surfaceId]. - bool hasSurface(String surfaceId) => _surfaces[surfaceId]?.value != null; + /// Returns true if the registry has a live surface for [surfaceId]. + bool hasSurface(String surfaceId) => _surfaces.containsKey(surfaceId); /// Returns the current [genui_model.SurfaceDefinition] snapshot for the /// given [surfaceId], or `null` if the surface does not exist. @@ -190,17 +171,13 @@ class SurfaceRegistry { /// Returns the live core surface model for [surfaceId], or `null` if the /// surface does not exist. Intended for GenUI internals. @internal - SurfaceModel? getLiveSurface(String surfaceId) => _surfaces[surfaceId]?.value; + SurfaceModel? getLiveSurface(String surfaceId) => _surfaces[surfaceId]; /// Disposes of the registry and all per-surface notifiers. The underlying /// [SurfaceModel]s are owned and disposed by the substrate's /// `core.SurfaceGroupModel`, not by this registry. void dispose() { _eventController.close(); - for (final ValueNotifier?> notifier - in _surfaces.values) { - notifier.dispose(); - } for (final ValueNotifier notifier in _definitions.values) { notifier.dispose(); diff --git a/packages/genui/lib/src/interfaces.dart b/packages/genui/lib/src/interfaces.dart index fccbaeb5d..79e9c4a6f 100644 --- a/packages/genui/lib/src/interfaces.dart +++ b/packages/genui/lib/src/interfaces.dart @@ -7,6 +7,6 @@ library; export 'interfaces/a2ui_message_sink.dart'; -export 'interfaces/surface_context.dart' hide LiveSurfaceContext; +export 'interfaces/surface_context.dart'; export 'interfaces/surface_host.dart'; export 'interfaces/transport.dart'; diff --git a/packages/genui/lib/src/interfaces/surface_context.dart b/packages/genui/lib/src/interfaces/surface_context.dart index 19ca09411..333f24542 100644 --- a/packages/genui/lib/src/interfaces/surface_context.dart +++ b/packages/genui/lib/src/interfaces/surface_context.dart @@ -2,7 +2,6 @@ // 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/foundation.dart'; import '../model/catalog.dart'; @@ -29,12 +28,3 @@ abstract interface class SurfaceContext { /// Reports an error capable of being sent back to the AI. void reportError(Object error, StackTrace? stack); } - -/// GenUI-internal extension of [SurfaceContext] that exposes the live core -/// surface model so the renderer can subscribe to per-component updates for -/// granular rebuilds. External implementations only need to satisfy -/// [SurfaceContext]. -@internal -abstract interface class LiveSurfaceContext implements SurfaceContext { - ValueListenable get surface; -} diff --git a/packages/genui/lib/src/model/ui_models.dart b/packages/genui/lib/src/model/ui_models.dart index e57145e14..189e457ae 100644 --- a/packages/genui/lib/src/model/ui_models.dart +++ b/packages/genui/lib/src/model/ui_models.dart @@ -249,24 +249,14 @@ sealed class SurfaceUpdate { /// Fired when a new surface is created. final class SurfaceAdded extends SurfaceUpdate { - /// Constructs from a [SurfaceDefinition]. The live [surface] is `null` - /// when constructed via this path (intended for tests/mocks); - /// `SurfaceController` uses [SurfaceAdded.fromCore] internally so - /// real-world events have both fields populated. - const SurfaceAdded(super.surfaceId, this.definition) : surface = null; - - /// Internal: constructs from a live core surface, populating both - /// [surface] and [definition]. - @internal - SurfaceAdded.fromCore(super.surfaceId, core.SurfaceModel coreSurface) - : surface = coreSurface, - definition = SurfaceDefinition.fromCore(coreSurface); + /// Constructs from a [SurfaceDefinition]. `SurfaceController` uses + /// [SurfaceAdded.fromCore] internally. + const SurfaceAdded(super.surfaceId, this.definition); - /// Live `a2ui_core` surface model. Null when constructed via the public - /// constructor; populated when emitted by `SurfaceController`. Intended - /// for GenUI internals. + /// Internal: snapshots the definition from a live core surface. @internal - final core.SurfaceModel? surface; + SurfaceAdded.fromCore(super.surfaceId, core.SurfaceModel coreSurface) + : definition = SurfaceDefinition.fromCore(coreSurface); /// Snapshot definition for this surface. final SurfaceDefinition definition; @@ -274,23 +264,14 @@ final class SurfaceAdded extends SurfaceUpdate { /// Fired when an existing surface's component set is modified. final class ComponentsUpdated extends SurfaceUpdate { - /// Constructs from a [SurfaceDefinition]. See [SurfaceAdded] for the - /// relationship between this constructor and - /// [ComponentsUpdated.fromCore]. - const ComponentsUpdated(super.surfaceId, this.definition) : surface = null; + /// Constructs from a [SurfaceDefinition]. `SurfaceController` uses + /// [ComponentsUpdated.fromCore] internally. + const ComponentsUpdated(super.surfaceId, this.definition); - /// Internal: constructs from a live core surface, populating both - /// [surface] and [definition]. + /// Internal: snapshots the definition from a live core surface. @internal ComponentsUpdated.fromCore(super.surfaceId, core.SurfaceModel coreSurface) - : surface = coreSurface, - definition = SurfaceDefinition.fromCore(coreSurface); - - /// Live `a2ui_core` surface model. Null when constructed via the public - /// constructor; populated when emitted by `SurfaceController`. Intended - /// for GenUI internals. - @internal - final core.SurfaceModel? surface; + : definition = SurfaceDefinition.fromCore(coreSurface); /// Snapshot definition for this surface. final SurfaceDefinition definition; diff --git a/packages/genui/test/engine/surface_controller_test.dart b/packages/genui/test/engine/surface_controller_test.dart index a6aa9d649..11f024f51 100644 --- a/packages/genui/test/engine/surface_controller_test.dart +++ b/packages/genui/test/engine/surface_controller_test.dart @@ -147,12 +147,10 @@ void main() { final added = SurfaceAdded('s1', def); expect(added.surfaceId, 's1'); expect(added.definition, same(def)); - expect(added.surface, isNull); final updated = ComponentsUpdated('s1', def); expect(updated.surfaceId, 's1'); expect(updated.definition, same(def)); - expect(updated.surface, isNull); }); test( From 7ebb12b1349ba5426af55c46abd96ae53ea8614f Mon Sep 17 00:00:00 2001 From: Andrew Kolos Date: Thu, 4 Jun 2026 14:34:32 -0700 Subject: [PATCH 25/49] docs(genui): trim migration guide and CHANGELOG to user-facing changes The migration guide documented internals consumers never touch: the controller-only updateSurface, @internal lifecycle hooks, the internal wrap/ snapshot mechanics, signal backing, mutable-copy storage, and the rendering strategy. Cut all of it. The guide now covers only what a consumer changes (message types, store) or observes (stricter DataModel/validation, duplicate- create error, pointer escapes, null-removes-key). Also drop a stale CHANGELOG entry that still described the SurfaceAdded/ ComponentsUpdated @internal `surface` fields, which were removed, and trim the updateSurface entry's internal rationale. --- .../migration_genui_onto_a2ui_core.md | 132 +++++------------- packages/genui/CHANGELOG.md | 12 +- 2 files changed, 36 insertions(+), 108 deletions(-) diff --git a/docs/usage/migration/migration_genui_onto_a2ui_core.md b/docs/usage/migration/migration_genui_onto_a2ui_core.md index 5af047f7f..d350a9ba4 100644 --- a/docs/usage/migration/migration_genui_onto_a2ui_core.md +++ b/docs/usage/migration/migration_genui_onto_a2ui_core.md @@ -1,34 +1,17 @@ # Migration Guide: GenUI on `a2ui_core` -`package:genui`'s runtime substrate now runs on the shared `package:a2ui_core` -implementation: shared protocol parsing, message processing, and -surface/data-model state (#811). +`package:genui` now runs on the shared `package:a2ui_core` runtime (#811). This +changes how you feed A2UI messages to genui. Catalog widgets and data-binding +code are unaffected. -This migration unifies the **message layer** with `a2ui_core` directly. The -catalog-widget authoring API is intentionally left unchanged, pending the -typed-props authoring API (flutter/genui#801) and the upstream Node Layer -(A2UI#1282). So the surface/component snapshot types and the data-model API are -retained for now as GenUI types. - -## Breaking changes +## What you have to change ### A2UI messages are now `a2ui_core` types -The GenUI message classes (`A2uiMessage`, `CreateSurface`, `UpdateComponents`, -`UpdateDataModel`, `DeleteSurface`) are removed. Use the `a2ui_core` types -directly: - -- Add `a2ui_core` as a dependency of any package that builds or inspects A2UI - messages. -- `SurfaceController.handleMessage`, `Transport.incomingMessages`, and - `A2uiMessageEvent.message` now use `core.A2uiMessage`. -- Construct messages with `core.CreateSurfaceMessage(...)`, - `core.UpdateComponentsMessage(...)`, `core.UpdateDataModelMessage(...)` / - `.removeKey(...)`, and `core.DeleteSurfaceMessage(...)`. -- `core.UpdateComponentsMessage.components` is a `List>` of - raw component wire JSON (`{'id': ..., 'component': ..., ...props}`), not - `Component` objects. -- Parse from JSON with `core.A2uiMessage.fromJson(json)`. +The genui message classes (`A2uiMessage`, `CreateSurface`, `UpdateComponents`, +`UpdateDataModel`, `DeleteSurface`) are removed. If you construct or handle +messages directly, switch to the `a2ui_core` types and add `a2ui_core` to your +dependencies. ```dart // Before @@ -52,83 +35,36 @@ controller.handleMessage( ); ``` -### `SurfaceController.store` / `DataModelStore` removed - -Read a surface's data model directly: - -- `SurfaceController.contextFor(id).dataModel` is writable, and usable before the - surface is created (the data is migrated into the live model on creation). -- `SurfaceController.registry.getSurface(id)?.dataModel` once the surface exists. - -### `SurfaceRegistry.updateSurface(...)` removed - -Surface lifecycle updates flow through `SurfaceController.handleMessage`; the -definition-only push path is no longer supported. `addSurface` and -`notifyUpdated` remain on `SurfaceRegistry` but are marked `@internal`. - -## What stays the same (for now) - -These are unchanged by this migration and remain GenUI types, pending #801: - -- **Catalog widget authoring.** `CatalogItemContext.id`, `type`, `data`, - `surfaceId`, `getComponent` (returns a `Component?` snapshot), `dataContext`, - `buildChild`, `dispatchEvent`, and `reportError`. Catalog widget bodies do not - change. -- **Data model API.** `DataPath`, `DataModel`, `InMemoryDataModel`, - `DataContext.update` / `getValue` / `subscribe` / `bindExternalState`, and the - `Bound*` widgets. -- **Surface snapshots.** `Component`, `SurfaceDefinition`, - `SurfaceContext.definition`, `SurfaceUpdate.definition`, - `ActionDelegate.handleEvent`'s `SurfaceDefinition` callback, and - `UiPart.create(definition: SurfaceDefinition(...))`. - -Internally these wrap or snapshot from `a2ui_core`: `InMemoryDataModel` wraps -`a2ui_core.DataModel`; `CatalogItemContext` is backed by -`a2ui_core.ComponentContext`; `SurfaceDefinition` is a snapshot of the live -`a2ui_core.SurfaceModel`. - -Surface state is read through the `SurfaceDefinition` snapshot: -`SurfaceUpdate.definition`, and `SurfaceRegistry.getSurface` / `watchSurface`. +- `handleMessage`, `Transport.incomingMessages`, and `A2uiMessageEvent.message` + now use `core.A2uiMessage`. +- `core.UpdateComponentsMessage` takes raw component JSON maps + (`{'id': ..., 'component': ..., ...props}`), not `Component` objects. +- Parse from JSON with `core.A2uiMessage.fromJson(json)`. -## Behavior changes to watch for +### `SurfaceController.store` is removed -These come from the `a2ui_core` substrate, independent of any rename: +Read a surface's data model via `SurfaceController.contextFor(id).dataModel` +(writable, and usable before the surface is created). -1. **`DataModel` is stricter.** Writes that previously no-op'd now throw core - data errors, especially type-mismatched intermediate paths and very large - list indices. Sparse list writes fill skipped entries with `null`. -2. **Stored containers are mutable copies.** Incoming map/list values are copied - before storage so nested updates work even when callers pass const literals. -3. **Data reactivity is signal-backed internally.** `subscribe(...)` still - returns a `ValueListenable` and the `Bound*` widgets keep their API; - internally those listenables bridge to `preact_signals`. -4. **Protocol validation is stricter.** The core parser rejects malformed - messages more consistently, including missing/incorrect versions and messages - with more than one top-level action key. -5. **Duplicate `createSurface` for an active surface id is an error** instead of - silently reusing the existing surface. -6. **JSON Pointer `~0`/`~1` escapes are not interpreted.** Paths split on `/`, - matching the web core behavior (A2UI#1499 tracks the spec clarification). -7. **The renderer rebuilds from the `SurfaceDefinition` snapshot.** The built-in - `Surface` rebuilds when a surface's snapshot changes; data-bound values update - through the `Bound*` widgets. -8. **`core.UpdateDataModelMessage.hasValue`** distinguishes `value: null` from an - omitted `value` on the wire, but runtime mutation currently treats both as - "remove the key" (flutter/genui#938, A2UI#1504). +## Behavior you may notice -## Still deferred +- **`DataModel` writes are stricter.** Writes that previously did nothing can now + throw, e.g. type-mismatched intermediate paths and out-of-range list indices; + sparse list writes fill the gaps with `null`. +- **Malformed messages are rejected more consistently** (missing or wrong + version, or more than one action key in a single message). +- **A duplicate `createSurface` for an active surface id is now an error** rather + than silently reusing the existing surface. +- **JSON Pointer `~0`/`~1` escapes are no longer interpreted** in data paths; + paths split on `/`. +- **`updateDataModel` with `value: null` removes the key**, the same as omitting + the value. Distinguishing the two is pending flutter/genui#938. -- The catalog widget authoring API stays on `CatalogItemContext`; a typed-props - API (#801) is on hold until A2UI#1282 settles. -- Action dispatch and `sendDataModel` synchronization still flow through the - existing GenUI path rather than `core.SurfaceGroupModel.onAction` or - `MessageProcessor.getClientDataModel()`. -- `GenericBinder` is not exposed as a Flutter-side public API. +## What does not change -## What's next +Your catalog widgets and data-binding code are untouched: `CatalogItemContext`, +`dataContext`, the `DataModel` / `DataPath` API, the `Bound*` widgets, and the +`SurfaceDefinition` / `Component` snapshots all keep their current shape. -A follow-up (#801), gated on the upstream Node Layer (A2UI#1282), will unify the -retained GenUI types (`Component`, `SurfaceDefinition`, the `DataModel` API, -`SurfaceContext.definition`) with the `a2ui_core` models so the catalog authoring -API can move onto core types too. Until then, the types under "What stays the -same" are the current public API. +A follow-up (#801) will unify these with the `a2ui_core` models once the upstream +Node Layer (A2UI#1282) lands. diff --git a/packages/genui/CHANGELOG.md b/packages/genui/CHANGELOG.md index 9dcc7901e..3b3b68389 100644 --- a/packages/genui/CHANGELOG.md +++ b/packages/genui/CHANGELOG.md @@ -19,12 +19,8 @@ removed. Read a surface's data model via `SurfaceController.contextFor(id).dataModel` (writable before the surface is created) or `SurfaceController.registry.getSurface(id)?.dataModel`. -- **BREAKING**: `SurfaceRegistry.updateSurface(...)` is removed. Surface - lifecycle now flows through `SurfaceController.handleMessage`; the - definition-only push path could not be preserved without diverging from - the live `a2ui_core` surface model. `SurfaceRegistry.addSurface` / - `notifyUpdated` exist as internal lifecycle hooks and are marked - `@internal`. +- **BREAKING**: `SurfaceRegistry.updateSurface(...)` is removed; surface + lifecycle now flows through `SurfaceController.handleMessage`. - **Behavior**: `DataModel` writes are stricter (core data errors on type-mismatched intermediate paths and excessively large list indices) and sparse list writes now fill skipped entries with `null` instead of @@ -34,10 +30,6 @@ - **Behavior**: JSON Pointer `~0`/`~1` escapes are not interpreted on `DataPath`; paths split on `/`, matching the web reference implementation (see A2UI#1499 tracking spec clarification). -- **Internal**: The renderer rebuilds each surface from its `SurfaceDefinition` - snapshot. The live `core.SurfaceModel` fields on `SurfaceAdded` / - `ComponentsUpdated` are marked `@internal`; consumers read - `SurfaceUpdate.definition`. - The catalog-widget authoring API is unchanged. `SurfaceDefinition` and `Component` remain GenUI snapshot types, to be unified with the `a2ui_core` models in a follow-up (#801). From 65fab556637c6a1763568f7cb531a55a08eae591 Mon Sep 17 00:00:00 2001 From: Andrew Kolos Date: Thu, 4 Jun 2026 15:37:31 -0700 Subject: [PATCH 26/49] fix(simple_chat,composer): migrate to a2ui_core message types These two genui consumers were missed in the message-layer migration. Add the a2ui_core dependency and use core.A2uiMessage types directly; in composer, replace the removed SurfaceController.store with contextFor(id).dataModel. --- dev_tools/composer/lib/ai_client_transport.dart | 3 ++- dev_tools/composer/lib/sample_parser.dart | 8 ++++---- dev_tools/composer/lib/surface_editor.dart | 11 +++++++---- dev_tools/composer/pubspec.yaml | 1 + examples/simple_chat/lib/a2ui_transport.dart | 3 ++- examples/simple_chat/lib/chat_session.dart | 3 ++- examples/simple_chat/pubspec.yaml | 1 + examples/simple_chat/test/fake_ai_client.dart | 9 +++++---- 8 files changed, 24 insertions(+), 15 deletions(-) diff --git a/dev_tools/composer/lib/ai_client_transport.dart b/dev_tools/composer/lib/ai_client_transport.dart index 32428bf9b..33557c345 100644 --- a/dev_tools/composer/lib/ai_client_transport.dart +++ b/dev_tools/composer/lib/ai_client_transport.dart @@ -4,6 +4,7 @@ import 'dart:async'; +import 'package:a2ui_core/a2ui_core.dart' as core; import 'package:dartantic_ai/dartantic_ai.dart' as dartantic; import 'package:genui/genui.dart'; import 'package:logging/logging.dart'; @@ -20,7 +21,7 @@ class AiClientTransport implements Transport { final Logger _logger = Logger('AiClientTransport'); @override - Stream get incomingMessages => _adapter.incomingMessages; + Stream get incomingMessages => _adapter.incomingMessages; @override Stream get incomingText => _adapter.incomingText; diff --git a/dev_tools/composer/lib/sample_parser.dart b/dev_tools/composer/lib/sample_parser.dart index ac2e85aa5..b75e8a8fb 100644 --- a/dev_tools/composer/lib/sample_parser.dart +++ b/dev_tools/composer/lib/sample_parser.dart @@ -4,7 +4,7 @@ import 'dart:convert'; -import 'package:genui/genui.dart'; +import 'package:a2ui_core/a2ui_core.dart' as core; import 'package:yaml/yaml.dart'; /// A parsed sample containing metadata and a stream of A2UI messages. @@ -12,7 +12,7 @@ class Sample { final String name; final String description; final String rawJsonl; - final Stream messages; + final Stream messages; Sample({ required this.name, @@ -50,14 +50,14 @@ class SampleParser { final String name = header['name'] as String? ?? 'Untitled Sample'; final String description = header['description'] as String? ?? ''; - final Stream messages = Stream.fromIterable( + final Stream messages = Stream.fromIterable( const LineSplitter() .convert(jsonlBody) .where((line) => line.trim().isNotEmpty) .map((line) { final Object? json = jsonDecode(line); if (json is Map) { - return A2uiMessage.fromJson(json); + return core.A2uiMessage.fromJson(json); } throw FormatException('Invalid JSON line: $line'); }), diff --git a/dev_tools/composer/lib/surface_editor.dart b/dev_tools/composer/lib/surface_editor.dart index 3fd885159..7a523ae83 100644 --- a/dev_tools/composer/lib/surface_editor.dart +++ b/dev_tools/composer/lib/surface_editor.dart @@ -5,6 +5,7 @@ import 'dart:async'; import 'dart:convert'; +import 'package:a2ui_core/a2ui_core.dart' as core; import 'package:flutter/material.dart'; import 'package:flutter_code_editor/flutter_code_editor.dart'; import 'package:flutter_highlight/themes/vs.dart'; @@ -150,7 +151,9 @@ class _SurfaceEditorViewState extends State { _dataModelNotifier?.removeListener(_onDataModelChanged); if (_surfaceIds.isEmpty) return; - final dataModel = _surfaceController.store.getDataModel(_surfaceIds.first); + final dataModel = _surfaceController + .contextFor(_surfaceIds.first) + .dataModel; _dataModelNotifier = dataModel.subscribe(DataPath.root); _dataModelNotifier!.addListener(_onDataModelChanged); } @@ -164,7 +167,7 @@ class _SurfaceEditorViewState extends State { if (_surfaceIds.isEmpty) return; final surfaceId = _surfaceIds.first; - final dataModel = _surfaceController.store.getDataModel(surfaceId); + final dataModel = _surfaceController.contextFor(surfaceId).dataModel; final data = dataModel.getValue(DataPath.root); final dataJson = const JsonEncoder.withIndent(' ').convert(data); @@ -202,7 +205,7 @@ class _SurfaceEditorViewState extends State { final obj = jsonDecode(trimmedChunk); if (obj is Map) { - final message = A2uiMessage.fromJson(obj); + final message = core.A2uiMessage.fromJson(obj); _surfaceController.handleMessage(message); } } @@ -227,7 +230,7 @@ class _SurfaceEditorViewState extends State { if (parsed is Map) { final surfaceId = _surfaceIds.first; _surfaceController.handleMessage( - A2uiMessage.fromJson({ + core.A2uiMessage.fromJson({ 'version': kProtocolVersion, 'updateDataModel': { 'surfaceId': surfaceId, diff --git a/dev_tools/composer/pubspec.yaml b/dev_tools/composer/pubspec.yaml index f637117b3..df3a8b6f3 100644 --- a/dev_tools/composer/pubspec.yaml +++ b/dev_tools/composer/pubspec.yaml @@ -12,6 +12,7 @@ environment: resolution: workspace dependencies: + a2ui_core: ^0.0.1-wip002 dartantic_ai: ^3.2.0 flutter: sdk: flutter diff --git a/examples/simple_chat/lib/a2ui_transport.dart b/examples/simple_chat/lib/a2ui_transport.dart index 85bf7f3b9..ab8306f26 100644 --- a/examples/simple_chat/lib/a2ui_transport.dart +++ b/examples/simple_chat/lib/a2ui_transport.dart @@ -4,6 +4,7 @@ import 'dart:async'; +import 'package:a2ui_core/a2ui_core.dart' as core; import 'package:genui/genui.dart'; import 'agent/agent.dart'; @@ -22,7 +23,7 @@ class SimpleChatA2aTransport implements Transport { final A2uiTransportAdapter _adapter = A2uiTransportAdapter(); @override - Stream get incomingMessages => _adapter.incomingMessages; + Stream get incomingMessages => _adapter.incomingMessages; @override Stream get incomingText => _adapter.incomingText; diff --git a/examples/simple_chat/lib/chat_session.dart b/examples/simple_chat/lib/chat_session.dart index 5e7ce9fa0..59e6ed858 100644 --- a/examples/simple_chat/lib/chat_session.dart +++ b/examples/simple_chat/lib/chat_session.dart @@ -4,6 +4,7 @@ import 'dart:async'; +import 'package:a2ui_core/a2ui_core.dart' as core; import 'package:flutter/foundation.dart'; import 'package:genui/genui.dart'; import 'package:logging/logging.dart'; @@ -190,7 +191,7 @@ class A2uiChatSession extends ChatSession { @override SurfaceController get surfaceController => _surfaceController; - late final StreamSubscription _messageSub; + late final StreamSubscription _messageSub; late final StreamSubscription _textSub; late final StreamSubscription _submitSub; late final StreamSubscription _surfaceSub; diff --git a/examples/simple_chat/pubspec.yaml b/examples/simple_chat/pubspec.yaml index de0848b3e..1a60e7c93 100644 --- a/examples/simple_chat/pubspec.yaml +++ b/examples/simple_chat/pubspec.yaml @@ -12,6 +12,7 @@ environment: resolution: workspace dependencies: + a2ui_core: ^0.0.1-wip002 dartantic_ai: ^3.2.0 flutter: sdk: flutter diff --git a/examples/simple_chat/test/fake_ai_client.dart b/examples/simple_chat/test/fake_ai_client.dart index 072f83db2..fefa9dfe2 100644 --- a/examples/simple_chat/test/fake_ai_client.dart +++ b/examples/simple_chat/test/fake_ai_client.dart @@ -4,14 +4,14 @@ import 'dart:async'; +import 'package:a2ui_core/a2ui_core.dart' as core; import 'package:dartantic_ai/dartantic_ai.dart' as dartantic; -import 'package:genui/genui.dart'; import 'package:simple_chat/agent/ai_client.dart'; /// A fake implementation of [AiClient] for testing. class FakeAiClient implements AiClient { - final StreamController _a2uiMessageController = - StreamController.broadcast(); + final StreamController _a2uiMessageController = + StreamController.broadcast(); final StreamController _textResponseController = StreamController.broadcast(); @@ -22,7 +22,8 @@ class FakeAiClient implements AiClient { final List _receivedPrompts = []; List get receivedPrompts => List.unmodifiable(_receivedPrompts); - Stream get a2uiMessageStream => _a2uiMessageController.stream; + Stream get a2uiMessageStream => + _a2uiMessageController.stream; Stream get textResponseStream => _textResponseController.stream; From fa9f0875f0d582f77a66e7412f3c0944ab346b63 Mon Sep 17 00:00:00 2001 From: Andrew Kolos Date: Tue, 16 Jun 2026 03:37:20 -0700 Subject: [PATCH 27/49] docs(genui): tighten a2ui_core migration guide Import message types unprefixed via `show` rather than `as core`; they don't collide with anything genui exports. Scope the message section to custom-transport and direct message construction, and demote the niche build/parse details. Simplify the DataModel strictness note. --- .../migration_genui_onto_a2ui_core.md | 36 ++++++++++--------- 1 file changed, 20 insertions(+), 16 deletions(-) diff --git a/docs/usage/migration/migration_genui_onto_a2ui_core.md b/docs/usage/migration/migration_genui_onto_a2ui_core.md index d350a9ba4..93578807e 100644 --- a/docs/usage/migration/migration_genui_onto_a2ui_core.md +++ b/docs/usage/migration/migration_genui_onto_a2ui_core.md @@ -1,17 +1,19 @@ # Migration Guide: GenUI on `a2ui_core` -`package:genui` now runs on the shared `package:a2ui_core` runtime (#811). This -changes how you feed A2UI messages to genui. Catalog widgets and data-binding -code are unaffected. +`package:genui` now runs on the shared `package:a2ui_core` runtime (#811). The +only customer-facing change is for code that **implements a custom `Transport` or +constructs/parses A2UI messages directly** — those message types moved to +`a2ui_core`. The default AI/transport flow, catalog widgets, and data-binding code +are unaffected. ## What you have to change ### A2UI messages are now `a2ui_core` types The genui message classes (`A2uiMessage`, `CreateSurface`, `UpdateComponents`, -`UpdateDataModel`, `DeleteSurface`) are removed. If you construct or handle -messages directly, switch to the `a2ui_core` types and add `a2ui_core` to your -dependencies. +`UpdateDataModel`, `DeleteSurface`) are removed. Add `a2ui_core` to your +dependencies and use its message types. They don't collide with anything genui +exports, so import them unprefixed with a `show` list: ```dart // Before @@ -23,23 +25,25 @@ controller.handleMessage( controller.handleMessage(CreateSurface(surfaceId: 's', catalogId: 'demo')); // After -import 'package:a2ui_core/a2ui_core.dart' as core; +import 'package:a2ui_core/a2ui_core.dart' + show CreateSurfaceMessage, UpdateComponentsMessage; controller.handleMessage( - core.UpdateComponentsMessage(surfaceId: 's', components: [ + UpdateComponentsMessage(surfaceId: 's', components: [ {'id': 'root', 'component': 'Text', 'text': 'Hi'}, ]), ); controller.handleMessage( - core.CreateSurfaceMessage(surfaceId: 's', catalogId: 'demo'), + CreateSurfaceMessage(surfaceId: 's', catalogId: 'demo'), ); ``` -- `handleMessage`, `Transport.incomingMessages`, and `A2uiMessageEvent.message` - now use `core.A2uiMessage`. -- `core.UpdateComponentsMessage` takes raw component JSON maps +- **Custom transport:** `Transport.incomingMessages` and + `SurfaceController.handleMessage` now use `a2ui_core`'s `A2uiMessage`. Update + those signatures if you implement `Transport` or drive the controller directly. +- **Building messages:** `UpdateComponentsMessage` takes raw component JSON maps (`{'id': ..., 'component': ..., ...props}`), not `Component` objects. -- Parse from JSON with `core.A2uiMessage.fromJson(json)`. +- **Parsing raw JSON:** use `A2uiMessage.fromJson(json)`. ### `SurfaceController.store` is removed @@ -48,9 +52,9 @@ Read a surface's data model via `SurfaceController.contextFor(id).dataModel` ## Behavior you may notice -- **`DataModel` writes are stricter.** Writes that previously did nothing can now - throw, e.g. type-mismatched intermediate paths and out-of-range list indices; - sparse list writes fill the gaps with `null`. +- **`DataModel` writes are stricter.** Some writes that used to silently do + nothing now throw, e.g. writing through a path whose intermediate value isn't a + map or list. - **Malformed messages are rejected more consistently** (missing or wrong version, or more than one action key in a single message). - **A duplicate `createSurface` for an active surface id is now an error** rather From b4cb2cc2ee5f15d278c18893bf6d7fc5a0bb8d11 Mon Sep 17 00:00:00 2001 From: Andrew Kolos Date: Tue, 16 Jun 2026 03:37:25 -0700 Subject: [PATCH 28/49] refactor(a2ui_core): drop unused UpdateDataModelMessage hasValue The hasValue flag and removeKey constructor preserved a wire distinction (explicit value:null vs absent value) that nothing consumes; the processor collapses both to a key removal. fromJson always reads value, toJson always emits it. --- packages/a2ui_core/CHANGELOG.md | 4 --- packages/a2ui_core/lib/src/core/messages.dart | 35 ++++--------------- packages/a2ui_core/test/messages_test.dart | 19 ---------- .../test/test_infra/message_builders.dart | 10 ------ 4 files changed, 6 insertions(+), 62 deletions(-) diff --git a/packages/a2ui_core/CHANGELOG.md b/packages/a2ui_core/CHANGELOG.md index 6efaa5a07..3dcb7e1cc 100644 --- a/packages/a2ui_core/CHANGELOG.md +++ b/packages/a2ui_core/CHANGELOG.md @@ -2,10 +2,6 @@ ## 0.0.1-wip002 -- **Feature**: `UpdateDataModelMessage` carries a `hasValue` field so - parsers can distinguish `value: null` (set to null) from an absent - `value` key (remove the key) per the v0.9 protocol. Runtime mutation - still collapses both to "remove" pending flutter/genui#938. - **Feature**: Re-export preact_signals `effect` and `Effect`. - **Fix**: `DataModel.set` deep-copies map/list payloads so later nested writes work even when callers pass const literals. diff --git a/packages/a2ui_core/lib/src/core/messages.dart b/packages/a2ui_core/lib/src/core/messages.dart index cdb8079f5..4e5a7b0d7 100644 --- a/packages/a2ui_core/lib/src/core/messages.dart +++ b/packages/a2ui_core/lib/src/core/messages.dart @@ -70,18 +70,11 @@ abstract class A2uiMessage { if (json.containsKey('updateDataModel')) { final body = json['updateDataModel'] as Map; - if (body.containsKey('value')) { - return UpdateDataModelMessage( - version: version, - surfaceId: body['surfaceId'] as String, - path: body['path'] as String?, - value: body['value'], - ); - } - return UpdateDataModelMessage.removeKey( + return UpdateDataModelMessage( version: version, surfaceId: body['surfaceId'] as String, path: body['path'] as String?, + value: body['value'], ); } @@ -150,35 +143,19 @@ class UpdateComponentsMessage extends A2uiMessage { /// Updates the data model for an existing surface. /// -/// The wire protocol distinguishes two intents: -/// -/// - `"value": ` (present, possibly `null`): set [path] to that value. -/// - omitted `value` key: remove the key at [path] (sparse-clear for lists). -/// -/// The default constructor builds the first; [UpdateDataModelMessage.removeKey] -/// builds the second. [hasValue] preserves the distinction across parse and -/// serialize. +/// A `null` [value] removes the key at [path]; distinguishing that from +/// explicitly setting a key to `null` is pending flutter/genui#938. class UpdateDataModelMessage extends A2uiMessage { final String surfaceId; final String? path; final Object? value; - /// True if the message carries an explicit `value` on the wire. - final bool hasValue; - UpdateDataModelMessage({ super.version, required this.surfaceId, this.path, this.value, - }) : hasValue = true; - - UpdateDataModelMessage.removeKey({ - super.version, - required this.surfaceId, - this.path, - }) : value = null, - hasValue = false; + }); @override Map toJson() => { @@ -186,7 +163,7 @@ class UpdateDataModelMessage extends A2uiMessage { 'updateDataModel': { 'surfaceId': surfaceId, if (path != null) 'path': path, - if (hasValue) 'value': value, + 'value': value, }, }; } diff --git a/packages/a2ui_core/test/messages_test.dart b/packages/a2ui_core/test/messages_test.dart index 8c090c75c..669567ccd 100644 --- a/packages/a2ui_core/test/messages_test.dart +++ b/packages/a2ui_core/test/messages_test.dart @@ -82,13 +82,9 @@ void main() { final ud = msg as UpdateDataModelMessage; expect(ud.path, isNull); expect(ud.value, isNull); - expect(ud.hasValue, isFalse); }); test('parses updateDataModel with explicit null value', () { - // Per the v0.9 spec, `value: null` and an absent `value` key carry - // different intent (set-to-null vs remove-key). They must round-trip - // distinctly so callers (and senders) preserve the distinction. final msg = A2uiMessage.fromJson({ 'version': 'v0.9', 'updateDataModel': {'surfaceId': 's1', 'path': '/x', 'value': null}, @@ -96,7 +92,6 @@ void main() { final ud = msg as UpdateDataModelMessage; expect(ud.value, isNull); - expect(ud.hasValue, isTrue); }); test('round-trips an explicit-null updateDataModel value', () { @@ -110,23 +105,9 @@ void main() { expect(body['value'], isNull); final reparsed = A2uiMessage.fromJson(json) as UpdateDataModelMessage; - expect(reparsed.hasValue, isTrue); expect(reparsed.value, isNull); }); - test('round-trips an omitted updateDataModel value as omitted', () { - final omitted = UpdateDataModelMessage.removeKey( - surfaceId: 's1', - path: '/x', - ); - final body = omitted.toJson()['updateDataModel'] as Map; - expect(body.containsKey('value'), isFalse); - - final reparsed = - A2uiMessage.fromJson(omitted.toJson()) as UpdateDataModelMessage; - expect(reparsed.hasValue, isFalse); - }); - test('parses deleteSurface', () { final msg = A2uiMessage.fromJson({ 'version': 'v0.9', diff --git a/packages/genui/test/test_infra/message_builders.dart b/packages/genui/test/test_infra/message_builders.dart index 88f657f97..f864edaf5 100644 --- a/packages/genui/test/test_infra/message_builders.dart +++ b/packages/genui/test/test_infra/message_builders.dart @@ -54,16 +54,6 @@ core.UpdateDataModelMessage updateDataModel({ value: value, ); -core.UpdateDataModelMessage updateDataModelRemoveKey({ - String version = 'v0.9', - required String surfaceId, - DataPath path = DataPath.root, -}) => core.UpdateDataModelMessage.removeKey( - version: version, - surfaceId: surfaceId, - path: path.toString(), -); - core.DeleteSurfaceMessage deleteSurface({ String version = 'v0.9', required String surfaceId, From 7a3c5689782d431360d802242f4226bf7df31d91 Mon Sep 17 00:00:00 2001 From: Andrew Kolos Date: Tue, 16 Jun 2026 03:45:59 -0700 Subject: [PATCH 29/49] docs: trim changelogs to consumer-facing changes Drop implementation detail, roadmap notes, and spec cross-references that belong in a PR description rather than a library changelog. --- packages/a2ui_core/CHANGELOG.md | 11 ++++---- packages/genui/CHANGELOG.md | 49 +++++++++++++-------------------- packages/genui_a2a/CHANGELOG.md | 3 +- 3 files changed, 25 insertions(+), 38 deletions(-) diff --git a/packages/a2ui_core/CHANGELOG.md b/packages/a2ui_core/CHANGELOG.md index 3dcb7e1cc..2ff274c31 100644 --- a/packages/a2ui_core/CHANGELOG.md +++ b/packages/a2ui_core/CHANGELOG.md @@ -2,12 +2,11 @@ ## 0.0.1-wip002 -- **Feature**: Re-export preact_signals `effect` and `Effect`. -- **Fix**: `DataModel.set` deep-copies map/list payloads so later nested - writes work even when callers pass const literals. -- **Behavior**: `DataPath` no longer interprets RFC 6901 `~0`/`~1` - escapes; paths split on `/` only, matching the TypeScript reference - implementation (see A2UI#1499 tracking spec clarification). +- **Feature**: Export `effect` and `Effect`. +- **Fix**: `DataModel.set` copies map and list values so later writes into + nested paths succeed. +- **Behavior**: `DataPath` no longer interprets `~0`/`~1` escapes; paths + split on `/` only. ## 0.0.1-dev002 diff --git a/packages/genui/CHANGELOG.md b/packages/genui/CHANGELOG.md index 3b3b68389..4bbdbfb03 100644 --- a/packages/genui/CHANGELOG.md +++ b/packages/genui/CHANGELOG.md @@ -2,37 +2,26 @@ ## 0.10.0 (in progress) -- **Refactor**: Migrate the runtime substrate onto `package:a2ui_core`. - `SurfaceController` delegates to `a2ui_core.MessageProcessor`, and surface, - component, and data-model state are backed by the live `a2ui_core` models. - See +- **Refactor**: genui now runs on `package:a2ui_core`. See [the migration guide](../../docs/usage/migration/migration_genui_onto_a2ui_core.md). -- **BREAKING**: A2UI message types are now `package:a2ui_core` types directly. - The GenUI message classes (`A2uiMessage`, `CreateSurface`, `UpdateComponents`, - `UpdateDataModel`, `DeleteSurface`) are removed. `SurfaceController.handleMessage`, - `Transport.incomingMessages`, and `A2uiMessageEvent.message` now use - `core.A2uiMessage`. Construct messages with `core.CreateSurfaceMessage(...)` - etc.; `core.UpdateComponentsMessage` carries raw component JSON maps rather - than `Component` objects. Message-handling consumers should depend on - `a2ui_core` directly. -- **BREAKING**: `SurfaceController.store` and the `DataModelStore` class are - removed. Read a surface's data model via - `SurfaceController.contextFor(id).dataModel` (writable before the surface is - created) or `SurfaceController.registry.getSurface(id)?.dataModel`. -- **BREAKING**: `SurfaceRegistry.updateSurface(...)` is removed; surface - lifecycle now flows through `SurfaceController.handleMessage`. -- **Behavior**: `DataModel` writes are stricter (core data errors on - type-mismatched intermediate paths and excessively large list indices) - and sparse list writes now fill skipped entries with `null` instead of - silently dropping them. -- **Behavior**: A duplicate `createSurface` for an already-active surface - id is now an error. -- **Behavior**: JSON Pointer `~0`/`~1` escapes are not interpreted on - `DataPath`; paths split on `/`, matching the web reference implementation - (see A2UI#1499 tracking spec clarification). -- The catalog-widget authoring API is unchanged. `SurfaceDefinition` and - `Component` remain GenUI snapshot types, to be unified with the `a2ui_core` - models in a follow-up (#801). +- **BREAKING**: A2UI message types are now `package:a2ui_core` types. The GenUI + message classes (`A2uiMessage`, `CreateSurface`, `UpdateComponents`, + `UpdateDataModel`, `DeleteSurface`) are removed; `SurfaceController.handleMessage` + and `Transport.incomingMessages` take `a2ui_core` messages, and + `UpdateComponentsMessage` carries raw component JSON maps rather than `Component` + objects. Depend on `a2ui_core` directly. +- **BREAKING**: `SurfaceController.store` and `DataModelStore` are removed. Read a + surface's data model via `SurfaceController.contextFor(id).dataModel`. +- **BREAKING**: `SurfaceRegistry.updateSurface(...)` is removed; drive surfaces + through `SurfaceController.handleMessage`. +- **Behavior**: `DataModel` writes are stricter; some writes that previously did + nothing now throw, and sparse list writes fill skipped entries with `null`. +- **Behavior**: A duplicate `createSurface` for an already-active surface id is now + an error. +- **Behavior**: `~0`/`~1` escapes are no longer interpreted in `DataPath`; paths + split on `/`. +- The catalog-widget authoring API is unchanged; `SurfaceDefinition` and + `Component` remain genui types. ## 0.9.1 diff --git a/packages/genui_a2a/CHANGELOG.md b/packages/genui_a2a/CHANGELOG.md index 3ffed97e3..8198638f0 100644 --- a/packages/genui_a2a/CHANGELOG.md +++ b/packages/genui_a2a/CHANGELOG.md @@ -3,8 +3,7 @@ ## 0.10.0 (in progress) - **BREAKING**: `A2uiAgentConnector.stream` now emits `package:a2ui_core` - message types (`core.A2uiMessage`) rather than the removed GenUI message - facades. Depend on `a2ui_core` directly to consume them. + message types. Depend on `a2ui_core` directly to consume them. ## 0.9.0 From e10ac9719be5f26a3389e7e6aa47c8146ecf3348 Mon Sep 17 00:00:00 2001 From: Andrew Kolos Date: Tue, 16 Jun 2026 04:10:27 -0700 Subject: [PATCH 30/49] docs(genui): name the a2ui_core migration guide by version Match the migration__to_.md convention of the existing guides; retitle to "0.9.1 to 0.10.0" and update the CHANGELOG link. --- ...ion_genui_onto_a2ui_core.md => migration_0.9.1_to_0.10.0.md} | 2 +- packages/genui/CHANGELOG.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) rename docs/usage/migration/{migration_genui_onto_a2ui_core.md => migration_0.9.1_to_0.10.0.md} (98%) diff --git a/docs/usage/migration/migration_genui_onto_a2ui_core.md b/docs/usage/migration/migration_0.9.1_to_0.10.0.md similarity index 98% rename from docs/usage/migration/migration_genui_onto_a2ui_core.md rename to docs/usage/migration/migration_0.9.1_to_0.10.0.md index 93578807e..b2a93f267 100644 --- a/docs/usage/migration/migration_genui_onto_a2ui_core.md +++ b/docs/usage/migration/migration_0.9.1_to_0.10.0.md @@ -1,4 +1,4 @@ -# Migration Guide: GenUI on `a2ui_core` +# Migration Guide: 0.9.1 to 0.10.0 `package:genui` now runs on the shared `package:a2ui_core` runtime (#811). The only customer-facing change is for code that **implements a custom `Transport` or diff --git a/packages/genui/CHANGELOG.md b/packages/genui/CHANGELOG.md index 4bbdbfb03..09c5a7a95 100644 --- a/packages/genui/CHANGELOG.md +++ b/packages/genui/CHANGELOG.md @@ -3,7 +3,7 @@ ## 0.10.0 (in progress) - **Refactor**: genui now runs on `package:a2ui_core`. See - [the migration guide](../../docs/usage/migration/migration_genui_onto_a2ui_core.md). + [the migration guide](../../docs/usage/migration/migration_0.9.1_to_0.10.0.md). - **BREAKING**: A2UI message types are now `package:a2ui_core` types. The GenUI message classes (`A2uiMessage`, `CreateSurface`, `UpdateComponents`, `UpdateDataModel`, `DeleteSurface`) are removed; `SurfaceController.handleMessage` From a13a6606c0cceaf6e4def75efda7a70193e3b0f7 Mon Sep 17 00:00:00 2001 From: Andrew Kolos Date: Tue, 16 Jun 2026 11:33:30 -0700 Subject: [PATCH 31/49] refactor(genui): drop the signal layer from BoundValue Rebuild bound values via ValueListenableBuilder over the ValueListenable that subscribe() already returns, instead of bridging it into a preact_signals signal and a hand-rolled _SignalBuilder. The signal layer was residue of the deprioritized granular-rebuild work; the in-place mutation requirement is already satisfied by _SignalNotifier in the data layer. --- .../lib/src/widgets/widget_utilities.dart | 131 ++++-------------- 1 file changed, 26 insertions(+), 105 deletions(-) diff --git a/packages/genui/lib/src/widgets/widget_utilities.dart b/packages/genui/lib/src/widgets/widget_utilities.dart index d84950d86..77fddccf6 100644 --- a/packages/genui/lib/src/widgets/widget_utilities.dart +++ b/packages/genui/lib/src/widgets/widget_utilities.dart @@ -4,7 +4,6 @@ import 'dart:async'; -import 'package:a2ui_core/a2ui_core.dart' as core; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; @@ -70,18 +69,18 @@ abstract class BoundValue extends StatefulWidget { State> createState(); } -/// Backing state for [BoundValue]. Manages a preact_signals signal that -/// mirrors the resolved value. Function-call values (`{call: ...}`) are -/// driven by a [StreamSubscription] that pushes into the signal. +/// Backing state for [BoundValue]. Resolves the value definition to a +/// [ValueListenable] — a data-model subscription for `{path: ...}`, a stream +/// adapter for `{call: ...}`, or a constant otherwise — and rebuilds via +/// [ValueListenableBuilder], applying [convert] to each value. abstract class BoundValueState> extends State { - late core.ReadonlySignal _signal; + ValueListenable? _listenable; StreamSubscription? _streamSub; - void Function()? _disposeBridge; @override void initState() { super.initState(); - _setupSignal(); + _setup(); } @override @@ -89,65 +88,47 @@ abstract class BoundValueState> extends State { super.didUpdateWidget(oldWidget); if (widget.value != oldWidget.value || widget.dataContext != oldWidget.dataContext) { - _disposeSignal(); - _setupSignal(); + _teardown(); + _setup(); } } @override void dispose() { - _disposeSignal(); + _teardown(); super.dispose(); } - void _setupSignal() { + void _setup() { final Object? raw = widget.value; - if (raw is Map && raw.containsKey('path')) { - final path = DataPath(raw['path'] as String); - final ValueListenable source = widget.dataContext - .subscribe(path); - // Bridge the legacy ValueListenable facade to a signal so the - // signal-backed BoundValue implementation can stay granular. - final core.Signal bridge = core.signal(convert(source.value)); - - void listener() { - bridge.set(convert(source.value), force: true); - } - - source.addListener(listener); - _disposeBridge = () { - source.removeListener(listener); - final currentSource = source; - if (currentSource is ChangeNotifier) { - (currentSource as ChangeNotifier).dispose(); - } - }; - _signal = bridge; + _listenable = widget.dataContext.subscribe( + DataPath(raw['path'] as String), + ); } else if (raw is Map && raw.containsKey('call')) { - // Function-call resolution stays Stream-based for now; bridge to signal. - final core.Signal s = core.signal(null); + final notifier = ValueNotifier(null); _streamSub = widget.dataContext .resolve(raw) .listen( - (Object? v) => s.value = convert(v), + (Object? value) => notifier.value = value, onError: (Object error) { genUiLogger.warning('Error in Bound stream', error); }, ); - _signal = s; + _listenable = notifier; } else { - _signal = core.signal(convert(raw)); + _listenable = ValueNotifier(raw); } } - void _disposeSignal() { + void _teardown() { _streamSub?.cancel(); _streamSub = null; - _disposeBridge?.call(); - _disposeBridge = null; - // preact_signals don't require explicit disposal; subscriptions are torn - // down when the consuming Effect (in _SignalBuilder) is disposed. + final ValueListenable? listenable = _listenable; + if (listenable is ChangeNotifier) { + (listenable as ChangeNotifier).dispose(); + } + _listenable = null; } /// Converts a raw resolved value into the typed [T?]. @@ -155,73 +136,13 @@ abstract class BoundValueState> extends State { @override Widget build(BuildContext context) { - return _SignalBuilder( - signal: _signal, - builder: (ctx, value) => widget.builder(ctx, value), + return ValueListenableBuilder( + valueListenable: _listenable!, + builder: (context, raw, _) => widget.builder(context, convert(raw)), ); } } -/// Subscribes to a preact_signals [core.ReadonlySignal] and rebuilds when it -/// changes. Stand-in for `Watch` from a signals-Flutter package, since -/// `signals_flutter` is built on a different (incompatible) signals library. -class _SignalBuilder extends StatefulWidget { - const _SignalBuilder({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()? _disposeEffect; - bool _initialRun = true; - - @override - void initState() { - super.initState(); - _value = widget.signal.peek(); - _subscribe(); - } - - @override - void didUpdateWidget(_SignalBuilder oldWidget) { - super.didUpdateWidget(oldWidget); - if (widget.signal != oldWidget.signal) { - _disposeEffect?.call(); - _initialRun = true; - _value = widget.signal.peek(); - _subscribe(); - } - } - - void _subscribe() { - _disposeEffect = core.effect(() { - final T newValue = widget.signal.value; - // First run is the dependency-tracking pass; value already set from peek. - if (_initialRun) { - _initialRun = false; - return; - } - if (mounted) { - setState(() => _value = newValue); - } - }); - } - - @override - void dispose() { - _disposeEffect?.call(); - super.dispose(); - } - - @override - Widget build(BuildContext context) => widget.builder(context, _value); -} - /// Binds to a [String] value. class BoundString extends BoundValue { /// Creates a [BoundString]. From 46e6731f2c0a310280cacce7ae2911f0bd57d96c Mon Sep 17 00:00:00 2001 From: Andrew Kolos Date: Tue, 16 Jun 2026 11:33:30 -0700 Subject: [PATCH 32/49] refactor(genui): revert CatalogItemContext to a flat field bag Restore the frozen authoring-API shape (getComponent as a field, data as Object) and drop the synthesized core ComponentContext backing plus the unused fromCore/withOverrides/forTesting members. Catalog.buildWidget reconstructs the context via the public constructor. --- packages/genui/lib/src/model/catalog.dart | 13 +- .../genui/lib/src/model/catalog_item.dart | 167 +++--------------- 2 files changed, 33 insertions(+), 147 deletions(-) diff --git a/packages/genui/lib/src/model/catalog.dart b/packages/genui/lib/src/model/catalog.dart index afee37062..ffc4cb990 100644 --- a/packages/genui/lib/src/model/catalog.dart +++ b/packages/genui/lib/src/model/catalog.dart @@ -136,17 +136,24 @@ interface class Catalog { } genUiLogger.info('Building widget ${item.name} with id ${itemContext.id}'); - // No KeyedSubtree: per-id identity comes from the Surface widget's - // _ComponentBuilder wrapper. return item.widgetBuilder( - itemContext.withOverrides( + CatalogItemContext( + data: itemContext.data, + id: itemContext.id, + type: itemContext.type, buildChild: (String childId, [DataContext? childDataContext]) => itemContext.buildChild( childId, childDataContext ?? itemContext.dataContext, ), + dispatchEvent: itemContext.dispatchEvent, + buildContext: itemContext.buildContext, + dataContext: itemContext.dataContext, + getComponent: itemContext.getComponent, getCatalogItem: (String type) => items.firstWhereOrNull((item) => item.name == type), + surfaceId: itemContext.surfaceId, + reportError: itemContext.reportError, ), ); } diff --git a/packages/genui/lib/src/model/catalog_item.dart b/packages/genui/lib/src/model/catalog_item.dart index 07a993043..715f335eb 100644 --- a/packages/genui/lib/src/model/catalog_item.dart +++ b/packages/genui/lib/src/model/catalog_item.dart @@ -2,10 +2,8 @@ // 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:json_schema_builder/json_schema_builder.dart'; -import 'package:meta/meta.dart' show internal; import 'data_model.dart'; import 'ui_models.dart'; @@ -28,81 +26,33 @@ typedef ExampleBuilderCallback = String Function(); typedef CatalogWidgetBuilder = Widget Function(CatalogItemContext itemContext); /// Context provided to a [CatalogItem]'s widget builder. -/// -/// Backed by a substrate [core.ComponentContext] plus Flutter-specific -/// extras: [buildContext], [buildChild], [dispatchEvent], [dataContext], -/// [reportError]. final class CatalogItemContext { - /// Creates a [CatalogItemContext] from raw fields. Synthesizes a - /// stand-alone substrate context internally. + /// Creates a [CatalogItemContext] with the required parameters. + /// + /// All parameters are required to ensure the widget builder has complete + /// context for rendering and interaction. CatalogItemContext({ - required String id, - required String type, - required Map data, - required this.buildChild, - required this.dispatchEvent, - required this.buildContext, - required this.dataContext, - required GetComponentCallback getComponent, - required this.getCatalogItem, - required String surfaceId, - required this.reportError, - }) : _componentContext = _standaloneContext( - id: id, - type: type, - data: data, - surfaceId: surfaceId, - ), - _getComponentOverride = getComponent; - - /// Creates a [CatalogItemContext] from a substrate [core.ComponentContext]. - /// Renderer-internal; tests should use the public constructor or - /// [CatalogItemContext.forTesting]. - @internal - CatalogItemContext.fromCore({ - required core.ComponentContext componentContext, + required this.data, + required this.id, + required this.type, required this.buildChild, required this.dispatchEvent, required this.buildContext, required this.dataContext, + required this.getComponent, required this.getCatalogItem, + required this.surfaceId, required this.reportError, - }) : _componentContext = componentContext, - _getComponentOverride = null; + }); - /// Test-only convenience: builds a stand-alone substrate context so tests - /// don't need to wire up a full surface. - @visibleForTesting - factory CatalogItemContext.forTesting({ - required String id, - required String type, - required Map data, - required ChildBuilderCallback buildChild, - required DispatchEventCallback dispatchEvent, - required BuildContext buildContext, - required DataContext dataContext, - required CatalogItem? Function(String type) getCatalogItem, - required String surfaceId, - required void Function(Object error, StackTrace? stack) reportError, - }) { - return CatalogItemContext( - id: id, - type: type, - data: data, - buildChild: buildChild, - dispatchEvent: dispatchEvent, - buildContext: buildContext, - dataContext: dataContext, - getComponent: (_) => null, - getCatalogItem: getCatalogItem, - surfaceId: surfaceId, - reportError: reportError, - ); - } + /// The parsed data for this component from the AI-generated definition. + final Object data; - final core.ComponentContext _componentContext; + /// The unique identifier for this component instance. + final String id; - final GetComponentCallback? _getComponentOverride; + /// The type of this component. + final String type; /// Callback to build a child widget by its component ID. final ChildBuilderCallback buildChild; @@ -113,91 +63,20 @@ final class CatalogItemContext { /// The Flutter [BuildContext] for this widget. final BuildContext buildContext; - /// The [DataContext] for accessing the data model, dispatching catalog - /// functions, and subscribing to dynamic-value streams. + /// The [DataContext] for accessing and modifying the data model. final DataContext dataContext; + /// Callback to retrieve a component definition by its ID. + final GetComponentCallback getComponent; + /// Callback to retrieve a catalog item definition by its type name. final CatalogItem? Function(String type) getCatalogItem; - /// Callback to report an error that occurred within this component. - final void Function(Object error, StackTrace? stack) reportError; - - CatalogItemContext._copy({ - required core.ComponentContext componentContext, - required GetComponentCallback? getComponentOverride, - required this.buildChild, - required this.dispatchEvent, - required this.buildContext, - required this.dataContext, - required this.getCatalogItem, - required this.reportError, - }) : _componentContext = componentContext, - _getComponentOverride = getComponentOverride; - - /// Returns a copy of this context with selected callbacks replaced. The - /// substrate context is preserved. - @internal - CatalogItemContext withOverrides({ - ChildBuilderCallback? buildChild, - DispatchEventCallback? dispatchEvent, - BuildContext? buildContext, - DataContext? dataContext, - CatalogItem? Function(String type)? getCatalogItem, - void Function(Object error, StackTrace? stack)? reportError, - }) { - return CatalogItemContext._copy( - componentContext: _componentContext, - getComponentOverride: _getComponentOverride, - buildChild: buildChild ?? this.buildChild, - dispatchEvent: dispatchEvent ?? this.dispatchEvent, - buildContext: buildContext ?? this.buildContext, - dataContext: dataContext ?? this.dataContext, - getCatalogItem: getCatalogItem ?? this.getCatalogItem, - reportError: reportError ?? this.reportError, - ); - } - - /// The parsed data for this component from the AI-generated definition. - Object get data => _componentContext.componentModel.properties; - - /// The unique identifier for this component instance. - String get id => _componentContext.componentModel.id; - - /// The type of this component. - String get type => _componentContext.componentModel.type; - /// The ID of the surface this component belongs to. - String get surfaceId => _componentContext.surface.id; + final String surfaceId; - /// Retrieves a component on the surface by its ID, or `null` if absent. - Component? getComponent(String componentId) { - final Component? override = _getComponentOverride?.call(componentId); - if (override != null) return override; - final core.ComponentModel? component = _componentContext - .surface - .componentsModel - .get(componentId); - return component == null ? null : Component.fromCore(component); - } - - static core.ComponentContext _standaloneContext({ - required String id, - required String type, - required Map data, - required String surfaceId, - }) { - final surface = core.SurfaceModel( - surfaceId, - catalog: core.Catalog( - id: 'catalog', - components: const [], - ), - ); - final component = core.ComponentModel(id, type, data); - surface.componentsModel.addComponent(component); - return core.ComponentContext(surface, component); - } + /// Callback to report an error that occurred within this component. + final void Function(Object error, StackTrace? stack) reportError; } /// Defines a UI layout type, its schema, and how to build its widget. From a703c4045ecd1c56d101a23a232023488956e42d Mon Sep 17 00:00:00 2001 From: Andrew Kolos Date: Tue, 16 Jun 2026 11:33:30 -0700 Subject: [PATCH 33/49] refactor(genui): render Surface via ValueListenableBuilder Replace the hand-rolled definition listener lifecycle with a ValueListenableBuilder over SurfaceContext.definition, which is still a plain ValueListenable. --- packages/genui/lib/src/widgets/surface.dart | 29 +++------------------ 1 file changed, 4 insertions(+), 25 deletions(-) diff --git a/packages/genui/lib/src/widgets/surface.dart b/packages/genui/lib/src/widgets/surface.dart index b714f48ca..514caa2be 100644 --- a/packages/genui/lib/src/widgets/surface.dart +++ b/packages/genui/lib/src/widgets/surface.dart @@ -44,33 +44,12 @@ class Surface extends StatefulWidget { } class _SurfaceState extends State { - @override - void initState() { - super.initState(); - widget.surfaceContext.definition.addListener(_onDefinitionChanged); - } - - @override - void didUpdateWidget(Surface oldWidget) { - super.didUpdateWidget(oldWidget); - if (oldWidget.surfaceContext == widget.surfaceContext) return; - oldWidget.surfaceContext.definition.removeListener(_onDefinitionChanged); - widget.surfaceContext.definition.addListener(_onDefinitionChanged); - } - - @override - void dispose() { - widget.surfaceContext.definition.removeListener(_onDefinitionChanged); - super.dispose(); - } - - void _onDefinitionChanged() { - if (mounted) setState(() {}); - } - @override Widget build(BuildContext context) { - return _buildDefinitionSurface(widget.surfaceContext.definition.value); + return ValueListenableBuilder( + valueListenable: widget.surfaceContext.definition, + builder: (context, definition, _) => _buildDefinitionSurface(definition), + ); } Widget _buildDefinitionSurface(SurfaceDefinition? definition) { From fc1d9d1569793269d4f9f7e6cf348a5899aa70bb Mon Sep 17 00:00:00 2001 From: Andrew Kolos Date: Tue, 16 Jun 2026 11:33:30 -0700 Subject: [PATCH 34/49] refactor(genui): registry events carry only the surface model Drop the dead SurfaceDefinition payload and the fromCore constructors from SurfaceAdded/SurfaceUpdated; the sole consumer reads the surface and re-derives the definition itself. --- .../lib/src/engine/surface_controller.dart | 6 +-- .../lib/src/engine/surface_registry.dart | 51 +++---------------- 2 files changed, 8 insertions(+), 49 deletions(-) diff --git a/packages/genui/lib/src/engine/surface_controller.dart b/packages/genui/lib/src/engine/surface_controller.dart index eabc0b39a..a4fed7e4b 100644 --- a/packages/genui/lib/src/engine/surface_controller.dart +++ b/packages/genui/lib/src/engine/surface_controller.dart @@ -62,12 +62,10 @@ interface class SurfaceController implements SurfaceHost, A2uiMessageSink { @override Stream get surfaceUpdates => _registry.events.map( (e) => switch (e) { - // Registry-emitted events always populate `surface` via - // `SurfaceAdded.fromCore` / `SurfaceUpdated.fromCore`. surface_reg.SurfaceAdded(:final surfaceId, :final surface) => - SurfaceAdded.fromCore(surfaceId, surface!), + SurfaceAdded.fromCore(surfaceId, surface), surface_reg.SurfaceUpdated(:final surfaceId, :final surface) => - ComponentsUpdated.fromCore(surfaceId, surface!), + ComponentsUpdated.fromCore(surfaceId, surface), surface_reg.SurfaceRemoved(:final surfaceId) => SurfaceRemoved(surfaceId), }, ); diff --git a/packages/genui/lib/src/engine/surface_registry.dart b/packages/genui/lib/src/engine/surface_registry.dart index 58aa319e8..df1717620 100644 --- a/packages/genui/lib/src/engine/surface_registry.dart +++ b/packages/genui/lib/src/engine/surface_registry.dart @@ -15,29 +15,9 @@ sealed class RegistryEvent {} /// An event indicating that a new surface has been added. class SurfaceAdded extends RegistryEvent { - /// Constructs from a [genui_model.SurfaceDefinition]. The live [surface] - /// is `null` when constructed via this path (intended for tests/mocks); - /// the [SurfaceRegistry] uses [SurfaceAdded.fromCore] internally so - /// real-world events have both fields populated. - SurfaceAdded(this.surfaceId, this.definition) : surface = null; - - /// Internal: constructs from a live core surface, populating both - /// [surface] and [definition]. - @internal - SurfaceAdded.fromCore(this.surfaceId, SurfaceModel coreSurface) - : definition = genui_model.SurfaceDefinition.fromCore(coreSurface), - surface = coreSurface; - + SurfaceAdded(this.surfaceId, this.surface); final String surfaceId; - - /// Snapshot definition for this surface. - final genui_model.SurfaceDefinition definition; - - /// Live `a2ui_core` surface model. Null when constructed via the public - /// constructor; populated when emitted by [SurfaceRegistry]. Intended for - /// GenUI internals. - @internal - final SurfaceModel? surface; + final SurfaceModel surface; } /// An event indicating that a surface has been removed. @@ -48,28 +28,9 @@ class SurfaceRemoved extends RegistryEvent { /// An event indicating that a surface's components were updated. class SurfaceUpdated extends RegistryEvent { - /// Constructs from a [genui_model.SurfaceDefinition]. See [SurfaceAdded] - /// for the relationship between this constructor and - /// [SurfaceUpdated.fromCore]. - SurfaceUpdated(this.surfaceId, this.definition) : surface = null; - - /// Internal: constructs from a live core surface, populating both - /// [surface] and [definition]. - @internal - SurfaceUpdated.fromCore(this.surfaceId, SurfaceModel coreSurface) - : definition = genui_model.SurfaceDefinition.fromCore(coreSurface), - surface = coreSurface; - + SurfaceUpdated(this.surfaceId, this.surface); final String surfaceId; - - /// Snapshot definition for this surface. - final genui_model.SurfaceDefinition definition; - - /// Live `a2ui_core` surface model. Null when constructed via the public - /// constructor; populated when emitted by [SurfaceRegistry]. Intended for - /// GenUI internals. - @internal - final SurfaceModel? surface; + final SurfaceModel surface; } /// Tracks live [SurfaceModel]s by surface ID and exposes Flutter-friendly @@ -125,7 +86,7 @@ class SurfaceRegistry { ..remove(surface.id) ..add(surface.id); genUiLogger.info('Created new surface ${surface.id}'); - _eventController.add(SurfaceAdded.fromCore(surface.id, surface)); + _eventController.add(SurfaceAdded(surface.id, surface)); } /// Signals that the components of a surface have changed. Intended for @@ -143,7 +104,7 @@ class SurfaceRegistry { .value = genui_model.SurfaceDefinition.fromCore( surface, ); - _eventController.add(SurfaceUpdated.fromCore(surface.id, surface)); + _eventController.add(SurfaceUpdated(surface.id, surface)); } /// Removes a surface from the registry, emitting a [SurfaceRemoved] event. From 9626aab63e197be97dd8aaa2e312412883c518cd Mon Sep 17 00:00:00 2001 From: Andrew Kolos Date: Tue, 16 Jun 2026 11:33:44 -0700 Subject: [PATCH 35/49] docs: drop migration-narrative comments left in the code Remove the leftover UpdateDataModelMessage doc that described removed hasValue machinery, and reword the unknown-catalog stub comment to state the behavior instead of narrating the migration. --- packages/a2ui_core/lib/src/core/messages.dart | 3 --- packages/genui/lib/src/engine/surface_controller.dart | 4 ++-- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/packages/a2ui_core/lib/src/core/messages.dart b/packages/a2ui_core/lib/src/core/messages.dart index 4e5a7b0d7..87df130f2 100644 --- a/packages/a2ui_core/lib/src/core/messages.dart +++ b/packages/a2ui_core/lib/src/core/messages.dart @@ -142,9 +142,6 @@ class UpdateComponentsMessage extends A2uiMessage { } /// Updates the data model for an existing surface. -/// -/// A `null` [value] removes the key at [path]; distinguishing that from -/// explicitly setting a key to `null` is pending flutter/genui#938. class UpdateDataModelMessage extends A2uiMessage { final String surfaceId; final String? path; diff --git a/packages/genui/lib/src/engine/surface_controller.dart b/packages/genui/lib/src/engine/surface_controller.dart index a4fed7e4b..c893e8f45 100644 --- a/packages/genui/lib/src/engine/surface_controller.dart +++ b/packages/genui/lib/src/engine/surface_controller.dart @@ -133,8 +133,8 @@ interface class SurfaceController implements SurfaceHost, A2uiMessageSink { return; } - // Register an empty stub for unknown catalogIds. Mirrors the lenient - // pre-migration behavior tests and demos relied on. + // Tolerate unknown catalogIds by registering an empty stub rather than + // rejecting the surface. if (coreMessage is core.CreateSurfaceMessage) { final core.CreateSurfaceMessage createMessage = coreMessage; if (!_processor.catalogs.any((c) => c.id == createMessage.catalogId)) { From 1379e2f1ce5873c8bade40a30a9e1e6e0ac84d74 Mon Sep 17 00:00:00 2001 From: Andrew Kolos Date: Tue, 16 Jun 2026 11:38:01 -0700 Subject: [PATCH 36/49] refactor(genui): remove dead Component.toCoreJson --- packages/genui/lib/src/model/ui_models.dart | 3 --- 1 file changed, 3 deletions(-) diff --git a/packages/genui/lib/src/model/ui_models.dart b/packages/genui/lib/src/model/ui_models.dart index 189e457ae..95fccae3b 100644 --- a/packages/genui/lib/src/model/ui_models.dart +++ b/packages/genui/lib/src/model/ui_models.dart @@ -226,9 +226,6 @@ final class Component { return {'id': id, 'component': type, ...properties}; } - /// Converts this snapshot to the core component wire JSON shape. - Map toCoreJson() => Map.from(toJson()); - @override bool operator ==(Object other) => other is Component && From 2da7493a8a3ff492d4647ae4ec3f240a8a500937 Mon Sep 17 00:00:00 2001 From: Andrew Kolos Date: Tue, 16 Jun 2026 11:38:01 -0700 Subject: [PATCH 37/49] refactor(genui): restore _buildWidget method name --- packages/genui/lib/src/widgets/surface.dart | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/genui/lib/src/widgets/surface.dart b/packages/genui/lib/src/widgets/surface.dart index 514caa2be..e326497d9 100644 --- a/packages/genui/lib/src/widgets/surface.dart +++ b/packages/genui/lib/src/widgets/surface.dart @@ -79,7 +79,7 @@ class _SurfaceState extends State { return FallbackWidget(error: error); } - return _buildWidgetFromDefinition( + return _buildWidget( definition, catalog, rootId, @@ -91,7 +91,7 @@ class _SurfaceState extends State { ); } - Widget _buildWidgetFromDefinition( + Widget _buildWidget( SurfaceDefinition definition, Catalog catalog, String widgetId, @@ -116,7 +116,7 @@ class _SurfaceState extends State { data: widgetData, type: data.type, buildChild: (String childId, [DataContext? childDataContext]) => - _buildWidgetFromDefinition( + _buildWidget( definition, catalog, childId, @@ -150,7 +150,7 @@ class _SurfaceState extends State { context, event, widget.surfaceContext, - _buildWidgetFromDefinition, + _buildWidget, )) { return; } From c223688dac6f5eae02ea2223bfb11e1f2bcb3361 Mon Sep 17 00:00:00 2001 From: Andrew Kolos Date: Tue, 16 Jun 2026 11:38:01 -0700 Subject: [PATCH 38/49] refactor(genui): drop redundant watchDefinition alias, keep watchSurface --- packages/genui/lib/src/engine/surface_controller.dart | 2 +- packages/genui/lib/src/engine/surface_registry.dart | 6 ------ packages/genui/test/engine/surface_controller_test.dart | 6 +++--- 3 files changed, 4 insertions(+), 10 deletions(-) diff --git a/packages/genui/lib/src/engine/surface_controller.dart b/packages/genui/lib/src/engine/surface_controller.dart index c893e8f45..e58f5a69c 100644 --- a/packages/genui/lib/src/engine/surface_controller.dart +++ b/packages/genui/lib/src/engine/surface_controller.dart @@ -373,7 +373,7 @@ class _ControllerContext implements SurfaceContext { @override ValueListenable get definition => - _controller.registry.watchDefinition(surfaceId); + _controller.registry.watchSurface(surfaceId); @override DataModel get dataModel => _controller._dataModelFor(surfaceId); diff --git a/packages/genui/lib/src/engine/surface_registry.dart b/packages/genui/lib/src/engine/surface_registry.dart index df1717620..6909d64b9 100644 --- a/packages/genui/lib/src/engine/surface_registry.dart +++ b/packages/genui/lib/src/engine/surface_registry.dart @@ -55,12 +55,6 @@ class SurfaceRegistry { /// it is removed. ValueListenable watchSurface( String surfaceId, - ) => watchDefinition(surfaceId); - - /// Returns a [ValueListenable] tracking the - /// [genui_model.SurfaceDefinition] snapshot for [surfaceId]. - ValueListenable watchDefinition( - String surfaceId, ) { return _definitions.putIfAbsent( surfaceId, diff --git a/packages/genui/test/engine/surface_controller_test.dart b/packages/genui/test/engine/surface_controller_test.dart index 11f024f51..64915f73d 100644 --- a/packages/genui/test/engine/surface_controller_test.dart +++ b/packages/genui/test/engine/surface_controller_test.dart @@ -134,9 +134,9 @@ void main() { test('surface() creates a new ValueNotifier if one does not exist', () { final ValueListenable notifier1 = controller.registry - .watchDefinition('s1'); + .watchSurface('s1'); final ValueListenable notifier2 = controller.registry - .watchDefinition('s1'); + .watchSurface('s1'); expect(notifier1, same(notifier2)); expect(notifier1.value, isNull); }); @@ -409,7 +409,7 @@ void main() { await Future.delayed(Duration.zero); final SurfaceDefinition? surface = shortTimeoutController.registry - .watchDefinition(surfaceId) + .watchSurface(surfaceId) .value; expect(surface, isNotNull); // Updates NOT applied, so components should be empty (or default) From 327042c4f51beddc6dc62bee369a4f62fca96fc9 Mon Sep 17 00:00:00 2001 From: Andrew Kolos Date: Tue, 16 Jun 2026 11:44:30 -0700 Subject: [PATCH 39/49] refactor(genui): restore data_model.dart layout, inline bindExternalState Move DataContext/resolveContext back above the model classes to match main (top-level order is behavior-neutral) and inline the single-caller _bindExternalState helper into InMemoryDataModel. Cuts the file's diff vs main from 542 to 283 lines. --- packages/genui/lib/src/model/data_model.dart | 319 +++++++++---------- 1 file changed, 153 insertions(+), 166 deletions(-) diff --git a/packages/genui/lib/src/model/data_model.dart b/packages/genui/lib/src/model/data_model.dart index e262a22d0..b8e531f70 100644 --- a/packages/genui/lib/src/model/data_model.dart +++ b/packages/genui/lib/src/model/data_model.dart @@ -16,131 +16,6 @@ import 'data_path.dart'; export 'data_path.dart'; -/// Exception thrown when a value in the [DataModel] is not of the expected -/// type. -class DataModelTypeException implements Exception { - /// Creates a [DataModelTypeException]. - DataModelTypeException({ - required this.path, - required this.expectedType, - required this.actualType, - }); - - /// The path where the type mismatch occurred. - final DataPath path; - - /// The expected type. - final Type expectedType; - - /// The actual type found. - final Type actualType; - - @override - String toString() { - return 'DataModelTypeException: Expected $expectedType at $path, ' - 'but found $actualType'; - } -} - -/// Manages the application's data model and provides a subscription-based -/// mechanism for reactive UI updates. -abstract interface class DataModel { - /// Updates the data model at a specific absolute path and notifies all - /// relevant subscribers. - void update(DataPath absolutePath, Object? contents); - - /// Subscribes to a specific absolute path in the data model. - ValueNotifier subscribe(DataPath absolutePath); - - /// Binds an external state [source] to a [path] in the DataModel. - void Function() bindExternalState({ - required DataPath path, - required ValueListenable source, - bool twoWay = false, - }); - - /// Disposes resources and bindings. - void dispose(); - - /// Retrieves a static, one-time value from the data model at the - /// specified absolute path without creating a subscription. - T? getValue(DataPath absolutePath); -} - -/// Standard in-memory implementation of [DataModel]. Facade over -/// `a2ui_core.DataModel`. -class InMemoryDataModel implements DataModel { - /// Creates an empty in-memory data model. - InMemoryDataModel() : _core = core.DataModel(), _ownsCore = true; - - /// Wraps an existing core data model. - @internal - InMemoryDataModel.wrap(core.DataModel coreDataModel) - : _core = coreDataModel, - _ownsCore = false; - - final core.DataModel _core; - final bool _ownsCore; - final List _externalSubscriptions = []; - - /// The wrapped core data model. Intended for GenUI internals only. - @internal - core.DataModel get coreDataModel => _core; - - @override - void update(DataPath absolutePath, Object? contents) { - _core.set(absolutePath.toString(), contents); - } - - @override - ValueNotifier subscribe(DataPath absolutePath) { - return _SignalNotifier(_core.watch(absolutePath.toString())); - } - - @override - void Function() bindExternalState({ - required DataPath path, - required ValueListenable source, - bool twoWay = false, - }) { - final VoidCallback cleanup = _bindExternalState( - dataModel: this, - path: path, - source: source, - twoWay: twoWay, - ); - _externalSubscriptions.add(cleanup); - return () { - cleanup(); - _externalSubscriptions.remove(cleanup); - }; - } - - @override - void dispose() { - for (final callback in List.of(_externalSubscriptions)) { - callback(); - } - _externalSubscriptions.clear(); - if (_ownsCore) { - _core.dispose(); - } - } - - @override - T? getValue(DataPath absolutePath) { - final Object? value = _core.get(absolutePath.toString()); - if (value != null && value is! T) { - throw DataModelTypeException( - path: absolutePath, - expectedType: T, - actualType: value.runtimeType, - ); - } - return value as T?; - } -} - /// A contextual view of the main DataModel, used by widgets to resolve /// relative and absolute paths. class DataContext implements cf.ExecutionContext { @@ -317,56 +192,168 @@ Future resolveContext( return resolved; } -VoidCallback _bindExternalState({ - required DataModel dataModel, - required DataPath path, - required ValueListenable source, - bool twoWay = false, -}) { - dataModel.update(path, source.value); - - void onSourceChanged() { - final T newValue = source.value; - final T? currentValue = dataModel.getValue(path); - if (currentValue != newValue) { - dataModel.update(path, newValue); - } +/// Exception thrown when a value in the [DataModel] is not of the expected +/// type. +class DataModelTypeException implements Exception { + /// Creates a [DataModelTypeException]. + DataModelTypeException({ + required this.path, + required this.expectedType, + required this.actualType, + }); + + /// The path where the type mismatch occurred. + final DataPath path; + + /// The expected type. + final Type expectedType; + + /// The actual type found. + final Type actualType; + + @override + String toString() { + return 'DataModelTypeException: Expected $expectedType at $path, ' + 'but found $actualType'; } +} - source.addListener(onSourceChanged); +/// Manages the application's data model and provides a subscription-based +/// mechanism for reactive UI updates. +abstract interface class DataModel { + /// Updates the data model at a specific absolute path and notifies all + /// relevant subscribers. + void update(DataPath absolutePath, Object? contents); - VoidCallback? removeModelListener; - if (twoWay) { - if (source is! ValueNotifier) { - genUiLogger.warning( - 'bindExternalState: twoWay is true but source is not a ValueNotifier.', - ); - } else { - final ValueNotifier notifier = source; - final ValueListenable subscription = dataModel.subscribe(path); - - void onModelChanged() { - final T? modelValue = subscription.value; - if (modelValue != null && modelValue != notifier.value) { - notifier.value = modelValue; - } + /// Subscribes to a specific absolute path in the data model. + ValueNotifier subscribe(DataPath absolutePath); + + /// Binds an external state [source] to a [path] in the DataModel. + void Function() bindExternalState({ + required DataPath path, + required ValueListenable source, + bool twoWay = false, + }); + + /// Disposes resources and bindings. + void dispose(); + + /// Retrieves a static, one-time value from the data model at the + /// specified absolute path without creating a subscription. + T? getValue(DataPath absolutePath); +} + +/// Standard in-memory implementation of [DataModel]. Facade over +/// `a2ui_core.DataModel`. +class InMemoryDataModel implements DataModel { + /// Creates an empty in-memory data model. + InMemoryDataModel() : _core = core.DataModel(), _ownsCore = true; + + /// Wraps an existing core data model. + @internal + InMemoryDataModel.wrap(core.DataModel coreDataModel) + : _core = coreDataModel, + _ownsCore = false; + + final core.DataModel _core; + final bool _ownsCore; + final List _externalSubscriptions = []; + + /// The wrapped core data model. Intended for GenUI internals only. + @internal + core.DataModel get coreDataModel => _core; + + @override + void update(DataPath absolutePath, Object? contents) { + _core.set(absolutePath.toString(), contents); + } + + @override + ValueNotifier subscribe(DataPath absolutePath) { + return _SignalNotifier(_core.watch(absolutePath.toString())); + } + + @override + void Function() bindExternalState({ + required DataPath path, + required ValueListenable source, + bool twoWay = false, + }) { + update(path, source.value); + + void onSourceChanged() { + final T newValue = source.value; + final T? currentValue = getValue(path); + if (currentValue != newValue) { + update(path, newValue); } + } - subscription.addListener(onModelChanged); - removeModelListener = () { - subscription.removeListener(onModelChanged); - final currentSubscription = subscription; - if (currentSubscription is ChangeNotifier) { - (currentSubscription as ChangeNotifier).dispose(); + source.addListener(onSourceChanged); + void removeSourceListener() => source.removeListener(onSourceChanged); + _externalSubscriptions.add(removeSourceListener); + + VoidCallback? removeModelListener; + if (twoWay) { + if (source is! ValueNotifier) { + genUiLogger.warning( + 'bindExternalState: twoWay is true but source is not a ' + 'ValueNotifier.', + ); + } else { + final ValueNotifier notifier = source; + final ValueNotifier subscription = subscribe(path); + + void onModelChanged() { + final T? modelValue = subscription.value; + if (modelValue != null && modelValue != notifier.value) { + notifier.value = modelValue; + } } - }; + + subscription.addListener(onModelChanged); + removeModelListener = () { + subscription.removeListener(onModelChanged); + subscription.dispose(); + }; + _externalSubscriptions.add(removeModelListener); + } } + + return () { + removeSourceListener(); + _externalSubscriptions.remove(removeSourceListener); + + if (removeModelListener != null) { + removeModelListener(); + _externalSubscriptions.remove(removeModelListener); + } + }; } - return () { - source.removeListener(onSourceChanged); - removeModelListener?.call(); - }; + @override + void dispose() { + for (final callback in List.of(_externalSubscriptions)) { + callback(); + } + _externalSubscriptions.clear(); + if (_ownsCore) { + _core.dispose(); + } + } + + @override + T? getValue(DataPath absolutePath) { + final Object? value = _core.get(absolutePath.toString()); + if (value != null && value is! T) { + throw DataModelTypeException( + path: absolutePath, + expectedType: T, + actualType: value.runtimeType, + ); + } + return value as T?; + } } /// Bridges a preact_signals [core.ReadonlySignal] to a Flutter From 4e85e6d22adf986ceb23589b8b3d1414fa667c17 Mon Sep 17 00:00:00 2001 From: Andrew Kolos Date: Tue, 16 Jun 2026 12:09:07 -0700 Subject: [PATCH 40/49] refactor(genui): dispose the per-id notifier on removeSurface Restore main's behavior; the retain-on-remove was not necessitated by the migration. --- packages/genui/lib/src/engine/surface_registry.dart | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/packages/genui/lib/src/engine/surface_registry.dart b/packages/genui/lib/src/engine/surface_registry.dart index 6909d64b9..84aac71df 100644 --- a/packages/genui/lib/src/engine/surface_registry.dart +++ b/packages/genui/lib/src/engine/surface_registry.dart @@ -102,15 +102,12 @@ class SurfaceRegistry { } /// Removes a surface from the registry, emitting a [SurfaceRemoved] event. - /// - /// The per-id definition [ValueNotifier] is intentionally retained (reset to - /// `null`) so widgets already listening stay connected; a later re-create of - /// the same id updates the existing notifier. The [SurfaceModel] is owned and - /// disposed by the substrate's `core.SurfaceGroupModel`. + /// The [SurfaceModel] itself is owned and disposed by the substrate's + /// `core.SurfaceGroupModel`. void removeSurface(String surfaceId) { if (_surfaces.remove(surfaceId) == null) return; genUiLogger.info('Deleting surface $surfaceId'); - _definitions[surfaceId]?.value = null; + _definitions.remove(surfaceId)?.dispose(); _surfaceOrder.remove(surfaceId); _eventController.add(SurfaceRemoved(surfaceId)); } From bb50a983c49216348a0de91751585f60e6cbcd6b Mon Sep 17 00:00:00 2001 From: Andrew Kolos Date: Tue, 16 Jun 2026 12:09:07 -0700 Subject: [PATCH 41/49] refactor(genui): key built widgets in catalog.buildWidget Move the KeyedSubtree back to where main had it (behavior-equivalent), removing the duplicate keying from Surface. --- packages/genui/lib/src/model/catalog.dart | 39 +++++++++--------- packages/genui/lib/src/widgets/surface.dart | 45 ++++++++++----------- 2 files changed, 42 insertions(+), 42 deletions(-) diff --git a/packages/genui/lib/src/model/catalog.dart b/packages/genui/lib/src/model/catalog.dart index ffc4cb990..e4a697776 100644 --- a/packages/genui/lib/src/model/catalog.dart +++ b/packages/genui/lib/src/model/catalog.dart @@ -136,24 +136,27 @@ interface class Catalog { } genUiLogger.info('Building widget ${item.name} with id ${itemContext.id}'); - return item.widgetBuilder( - CatalogItemContext( - data: itemContext.data, - id: itemContext.id, - type: itemContext.type, - buildChild: (String childId, [DataContext? childDataContext]) => - itemContext.buildChild( - childId, - childDataContext ?? itemContext.dataContext, - ), - dispatchEvent: itemContext.dispatchEvent, - buildContext: itemContext.buildContext, - dataContext: itemContext.dataContext, - getComponent: itemContext.getComponent, - getCatalogItem: (String type) => - items.firstWhereOrNull((item) => item.name == type), - surfaceId: itemContext.surfaceId, - reportError: itemContext.reportError, + return KeyedSubtree( + key: ValueKey(itemContext.id), + child: item.widgetBuilder( + CatalogItemContext( + data: itemContext.data, + id: itemContext.id, + type: itemContext.type, + buildChild: (String childId, [DataContext? childDataContext]) => + itemContext.buildChild( + childId, + childDataContext ?? itemContext.dataContext, + ), + dispatchEvent: itemContext.dispatchEvent, + buildContext: itemContext.buildContext, + dataContext: itemContext.dataContext, + getComponent: itemContext.getComponent, + getCatalogItem: (String type) => + items.firstWhereOrNull((item) => item.name == type), + surfaceId: itemContext.surfaceId, + reportError: itemContext.reportError, + ), ), ); } diff --git a/packages/genui/lib/src/widgets/surface.dart b/packages/genui/lib/src/widgets/surface.dart index e326497d9..9c0886236 100644 --- a/packages/genui/lib/src/widgets/surface.dart +++ b/packages/genui/lib/src/widgets/surface.dart @@ -108,30 +108,27 @@ class _SurfaceState extends State { final JsonMap widgetData = data.properties; genUiLogger.finest('Building widget $widgetId'); - return KeyedSubtree( - key: ValueKey(widgetId), - child: catalog.buildWidget( - CatalogItemContext( - id: widgetId, - data: widgetData, - type: data.type, - buildChild: (String childId, [DataContext? childDataContext]) => - _buildWidget( - definition, - catalog, - childId, - childDataContext ?? dataContext, - ), - dispatchEvent: _dispatchEvent, - buildContext: context, - dataContext: dataContext, - getComponent: (String componentId) => - definition.components[componentId], - getCatalogItem: (String type) => - catalog.items.firstWhereOrNull((item) => item.name == type), - surfaceId: widget.surfaceContext.surfaceId, - reportError: widget.surfaceContext.reportError, - ), + return catalog.buildWidget( + CatalogItemContext( + id: widgetId, + data: widgetData, + type: data.type, + buildChild: (String childId, [DataContext? childDataContext]) => + _buildWidget( + definition, + catalog, + childId, + childDataContext ?? dataContext, + ), + dispatchEvent: _dispatchEvent, + buildContext: context, + dataContext: dataContext, + getComponent: (String componentId) => + definition.components[componentId], + getCatalogItem: (String type) => + catalog.items.firstWhereOrNull((item) => item.name == type), + surfaceId: widget.surfaceContext.surfaceId, + reportError: widget.surfaceContext.reportError, ), ); } catch (exception, stackTrace) { From 014397b8d48224d7a57cf5cf6e6723b9f121e88d Mon Sep 17 00:00:00 2001 From: Andrew Kolos Date: Tue, 16 Jun 2026 12:30:13 -0700 Subject: [PATCH 42/49] chore(a2ui_core): drop changes not required by the genui migration The RFC 6901 ~0/~1 DataPath change and the DataModel.set deep-copy are independent a2ui_core changes the migration does not need: genui never uses ~0/~1 escapes, and the deep-copy is unnecessary (verified by neutralizing it and running genui's suite, 275/275). Revert both to main and drop their CHANGELOG / migration-guide mentions; they belong in a separate a2ui_core PR. --- .../migration/migration_0.9.1_to_0.10.0.md | 2 -- packages/a2ui_core/CHANGELOG.md | 4 ---- .../a2ui_core/lib/src/core/data_model.dart | 24 ++++--------------- .../lib/src/primitives/data_path.dart | 12 ++++------ packages/a2ui_core/test/data_model_test.dart | 17 ------------- packages/a2ui_core/test/data_path_test.dart | 17 +++++++++++-- packages/genui/CHANGELOG.md | 2 -- 7 files changed, 24 insertions(+), 54 deletions(-) diff --git a/docs/usage/migration/migration_0.9.1_to_0.10.0.md b/docs/usage/migration/migration_0.9.1_to_0.10.0.md index b2a93f267..7f10d8af5 100644 --- a/docs/usage/migration/migration_0.9.1_to_0.10.0.md +++ b/docs/usage/migration/migration_0.9.1_to_0.10.0.md @@ -59,8 +59,6 @@ Read a surface's data model via `SurfaceController.contextFor(id).dataModel` version, or more than one action key in a single message). - **A duplicate `createSurface` for an active surface id is now an error** rather than silently reusing the existing surface. -- **JSON Pointer `~0`/`~1` escapes are no longer interpreted** in data paths; - paths split on `/`. - **`updateDataModel` with `value: null` removes the key**, the same as omitting the value. Distinguishing the two is pending flutter/genui#938. diff --git a/packages/a2ui_core/CHANGELOG.md b/packages/a2ui_core/CHANGELOG.md index 2ff274c31..b4ee9bf3d 100644 --- a/packages/a2ui_core/CHANGELOG.md +++ b/packages/a2ui_core/CHANGELOG.md @@ -3,10 +3,6 @@ ## 0.0.1-wip002 - **Feature**: Export `effect` and `Effect`. -- **Fix**: `DataModel.set` copies map and list values so later writes into - nested paths succeed. -- **Behavior**: `DataPath` no longer interprets `~0`/`~1` escapes; paths - split on `/` only. ## 0.0.1-dev002 diff --git a/packages/a2ui_core/lib/src/core/data_model.dart b/packages/a2ui_core/lib/src/core/data_model.dart index 198aeb6cb..7dd5f9252 100644 --- a/packages/a2ui_core/lib/src/core/data_model.dart +++ b/packages/a2ui_core/lib/src/core/data_model.dart @@ -12,29 +12,13 @@ import '../primitives/reactivity.dart'; /// allocate a billion-element list. const int maxAutoVivifyIndex = 10000; -/// Returns a mutable copy of JSON-like maps/lists so later nested writes do -/// not fail when callers pass const or otherwise unmodifiable literals. -Object? _mutableJsonLike(Object? value) { - if (value is Map) { - return { - for (final entry in value.entries) - entry.key.toString(): _mutableJsonLike(entry.value), - }; - } - if (value is List) { - return [for (final item in value) _mutableJsonLike(item)]; - } - return value; -} - /// A standalone, observable data store representing the client-side state. /// It handles JSON Pointer path resolution and reactive signal management. class DataModel { Object? _data; final Map>> _signals = {}; - DataModel([Object? initialData]) - : _data = _mutableJsonLike(initialData ?? {}); + DataModel([Object? initialData]) : _data = initialData ?? {}; /// Synchronously gets data at a specific JSON pointer path. Object? get(String path) { @@ -65,7 +49,7 @@ class DataModel { batch(() { if (dataPath.isEmpty) { - _data = _mutableJsonLike(value); + _data = value; } else { _data ??= {}; Object? current = _data; @@ -118,7 +102,7 @@ class DataModel { if (value == null) { current.remove(lastSegment); } else { - current[lastSegment] = _mutableJsonLike(value); + current[lastSegment] = value; } } else if (current is List) { final int? index = int.tryParse(lastSegment); @@ -137,7 +121,7 @@ class DataModel { while (current.length <= index) { current.add(null); } - current[index] = _mutableJsonLike(value); + current[index] = value; } } diff --git a/packages/a2ui_core/lib/src/primitives/data_path.dart b/packages/a2ui_core/lib/src/primitives/data_path.dart index 4d8bd9cc7..bb5b3a269 100644 --- a/packages/a2ui_core/lib/src/primitives/data_path.dart +++ b/packages/a2ui_core/lib/src/primitives/data_path.dart @@ -4,11 +4,7 @@ import 'package:collection/collection.dart'; -/// A class for handling JSON Pointer-style paths. -/// -/// Splits paths on `/` only; does not implement RFC 6901 `~0`/`~1` -/// escaping for `~`/`/` within segments. This matches the web_core -/// reference implementation, despite the v0.9 spec citing RFC 6901. +/// A class for handling JSON Pointer (RFC 6901) paths. class DataPath { final List segments; @@ -33,7 +29,9 @@ class DataPath { return DataPath([]); } - final List segments = normalized.split('/'); + final List segments = normalized.split('/').map((s) { + return s.replaceAll('~1', '/').replaceAll('~0', '~'); + }).toList(); return DataPath(segments); } @@ -70,7 +68,7 @@ class DataPath { @override String toString() { if (segments.isEmpty) return '/'; - return '/${segments.join('/')}'; + return '/${segments.map((s) => s.replaceAll('~', '~0').replaceAll('/', '~1')).join('/')}'; } @override diff --git a/packages/a2ui_core/test/data_model_test.dart b/packages/a2ui_core/test/data_model_test.dart index 1012d0a19..698a9896e 100644 --- a/packages/a2ui_core/test/data_model_test.dart +++ b/packages/a2ui_core/test/data_model_test.dart @@ -145,23 +145,6 @@ void main() { expect(model.get('/'), isEmpty); }); - test('copies immutable containers before nested writes', () { - final model = DataModel(); - model.set('/', const { - 'experience': '2-5', - 'nested': {'count': 1}, - 'items': ['a'], - }); - - model.set('/experience', '5+'); - model.set('/nested/count', 2); - model.set('/items/1', 'b'); - - expect(model.get('/experience'), '5+'); - expect(model.get('/nested'), {'count': 2}); - expect(model.get('/items'), ['a', 'b']); - }); - test('rejects excessively large list indices to prevent OOM', () { final model = DataModel(); expect( diff --git a/packages/a2ui_core/test/data_path_test.dart b/packages/a2ui_core/test/data_path_test.dart index c1fa7e1d4..0d2e232d8 100644 --- a/packages/a2ui_core/test/data_path_test.dart +++ b/packages/a2ui_core/test/data_path_test.dart @@ -19,9 +19,9 @@ void main() { expect(path.toString(), '/foo/bar'); }); - test('preserves ~ characters literally (no RFC 6901 escaping)', () { + test('parses escaped segments', () { final path = DataPath.parse('/foo~1bar/baz~0qux'); - expect(path.segments, ['foo~1bar', 'baz~0qux']); + expect(path.segments, ['foo/bar', 'baz~qux']); expect(path.toString(), '/foo~1bar/baz~0qux'); }); @@ -48,5 +48,18 @@ void main() { expect(DataPath.parse('/a/b'), equals(DataPath.parse('/a/b'))); expect(DataPath.parse('/a/b'), isNot(equals(DataPath.parse('/a/c')))); }); + + test('hashCode distinguishes segments from slashes in keys', () { + // Per RFC 6901 section 3, '~1' escapes a literal '/' within a key name. + // DataPath(['a', 'b']) represents JSON Pointer "/a/b" (two keys). + // DataPath(['a/b']) represents JSON Pointer "/a~1b" (one key: "a/b"). + // These are semantically different pointers and must have different + // hash codes for correctness in hash-based collections. + final twoSegments = DataPath(['a', 'b']); + final oneSegment = DataPath(['a/b']); + + expect(twoSegments, isNot(equals(oneSegment))); + expect(twoSegments.hashCode, isNot(equals(oneSegment.hashCode))); + }); }); } diff --git a/packages/genui/CHANGELOG.md b/packages/genui/CHANGELOG.md index 09c5a7a95..cd8f84d5c 100644 --- a/packages/genui/CHANGELOG.md +++ b/packages/genui/CHANGELOG.md @@ -18,8 +18,6 @@ nothing now throw, and sparse list writes fill skipped entries with `null`. - **Behavior**: A duplicate `createSurface` for an already-active surface id is now an error. -- **Behavior**: `~0`/`~1` escapes are no longer interpreted in `DataPath`; paths - split on `/`. - The catalog-widget authoring API is unchanged; `SurfaceDefinition` and `Component` remain genui types. From c8bb06f8365cb2b12ecc1bc220dc8e7b9b4e05df Mon Sep 17 00:00:00 2001 From: Andrew Kolos Date: Tue, 16 Jun 2026 12:35:41 -0700 Subject: [PATCH 43/49] chore(a2ui_core): revert messages.dart to main (toJson/doc not needed by the migration) --- packages/a2ui_core/lib/src/core/messages.dart | 9 ++----- packages/a2ui_core/test/messages_test.dart | 24 ------------------- 2 files changed, 2 insertions(+), 31 deletions(-) diff --git a/packages/a2ui_core/lib/src/core/messages.dart b/packages/a2ui_core/lib/src/core/messages.dart index 87df130f2..923c52695 100644 --- a/packages/a2ui_core/lib/src/core/messages.dart +++ b/packages/a2ui_core/lib/src/core/messages.dart @@ -9,12 +9,7 @@ abstract class A2uiMessage { final String version; A2uiMessage({this.version = 'v0.9'}); - /// Deserializes a JSON message into a typed [A2uiMessage]. - /// - /// Throws [A2uiValidationError] if the `version` field is missing or is - /// not exactly `'v0.9'`, or if the message does not contain exactly one - /// of `createSurface`, `updateComponents`, `updateDataModel`, - /// `deleteSurface`. + /// Deserializes a JSON envelope into a typed [A2uiMessage]. factory A2uiMessage.fromJson(Map json) { final Object? rawVersion = json['version']; if (rawVersion is! String) { @@ -160,7 +155,7 @@ class UpdateDataModelMessage extends A2uiMessage { 'updateDataModel': { 'surfaceId': surfaceId, if (path != null) 'path': path, - 'value': value, + if (value != null) 'value': value, }, }; } diff --git a/packages/a2ui_core/test/messages_test.dart b/packages/a2ui_core/test/messages_test.dart index 669567ccd..d6438f57c 100644 --- a/packages/a2ui_core/test/messages_test.dart +++ b/packages/a2ui_core/test/messages_test.dart @@ -84,30 +84,6 @@ void main() { expect(ud.value, isNull); }); - test('parses updateDataModel with explicit null value', () { - final msg = A2uiMessage.fromJson({ - 'version': 'v0.9', - 'updateDataModel': {'surfaceId': 's1', 'path': '/x', 'value': null}, - }); - - final ud = msg as UpdateDataModelMessage; - expect(ud.value, isNull); - }); - - test('round-trips an explicit-null updateDataModel value', () { - final original = A2uiMessage.fromJson({ - 'version': 'v0.9', - 'updateDataModel': {'surfaceId': 's1', 'path': '/x', 'value': null}, - }); - final Map json = original.toJson(); - final body = json['updateDataModel'] as Map; - expect(body.containsKey('value'), isTrue); - expect(body['value'], isNull); - - final reparsed = A2uiMessage.fromJson(json) as UpdateDataModelMessage; - expect(reparsed.value, isNull); - }); - test('parses deleteSurface', () { final msg = A2uiMessage.fromJson({ 'version': 'v0.9', From 419fcadfe4eefe65acbf2dff30b63ffcfc6ad07a Mon Sep 17 00:00:00 2001 From: Andrew Kolos Date: Tue, 16 Jun 2026 12:38:16 -0700 Subject: [PATCH 44/49] chore(genui): revert format_string to main (gratuitous dependency-set type churn) --- .../genui/lib/src/functions/format_string.dart | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/packages/genui/lib/src/functions/format_string.dart b/packages/genui/lib/src/functions/format_string.dart index ccdd8aa2f..c966ec01f 100644 --- a/packages/genui/lib/src/functions/format_string.dart +++ b/packages/genui/lib/src/functions/format_string.dart @@ -7,7 +7,7 @@ import 'package:meta/meta.dart'; import 'package:stream_transform/stream_transform.dart'; import '../model/client_function.dart'; -import '../model/data_path.dart'; +import '../model/data_model.dart'; import '../primitives/logging.dart'; import '../primitives/simple_items.dart'; import '../utils/stream_extensions.dart'; @@ -92,7 +92,7 @@ class ExpressionParser { /// any data path dependencies within the arguments will be added to the set. Stream evaluateFunctionCall( JsonMap callDefinition, { - Set? dependencies, + Set? dependencies, int depth = 0, }) { if (depth > _maxRecursionDepth) { @@ -184,7 +184,7 @@ class ExpressionParser { Stream _parseStringWithInterpolations( String input, - Set? dependencies, { + Set? dependencies, { int depth = 0, }) { if (depth > _maxRecursionDepth) { @@ -284,7 +284,7 @@ class ExpressionParser { Object? _evaluateExpression( String content, int depth, - Set? dependencies, + Set? dependencies, ) { if (depth > _maxRecursionDepth) { throw RecursionExpectedException( @@ -320,7 +320,7 @@ class ExpressionParser { Map _parseNamedArgs( String argsStr, int depth, - Set? dependencies, + Set? dependencies, ) { final args = {}; var i = 0; @@ -379,7 +379,7 @@ class ExpressionParser { String input, int start, int depth, - Set? dependencies, + Set? dependencies, ) { if (start >= input.length) return (null, start); @@ -440,10 +440,10 @@ class ExpressionParser { return (_resolvePath(token, dependencies), i); } - Stream _resolvePath(String pathStr, Set? dependencies) { + Stream _resolvePath(String pathStr, Set? dependencies) { pathStr = pathStr.trim(); if (dependencies != null) { - dependencies.add(context.resolvePath(DataPath(pathStr)).toString()); + dependencies.add(context.resolvePath(DataPath(pathStr))); return Stream.value(null); } return context.subscribeStream(DataPath(pathStr)); From a9c31dafae7ef11be2f17e13e30dfbfc7402cb92 Mon Sep 17 00:00:00 2001 From: Andrew Kolos Date: Tue, 16 Jun 2026 12:40:24 -0700 Subject: [PATCH 45/49] chore(genui): revert UiPart.create to a SurfaceDefinition param (Object/JsonMap path is dead) --- packages/genui/lib/src/model/parts/ui.dart | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/packages/genui/lib/src/model/parts/ui.dart b/packages/genui/lib/src/model/parts/ui.dart index 1b107487d..e291dc808 100644 --- a/packages/genui/lib/src/model/parts/ui.dart +++ b/packages/genui/lib/src/model/parts/ui.dart @@ -66,11 +66,12 @@ extension UiPartListExtension on Iterable { @immutable final class UiPart { /// Creates a [DataPart] compatible with GenUI. - static DataPart create({required Object definition, String? surfaceId}) { + static DataPart create({ + required SurfaceDefinition definition, + String? surfaceId, + }) { final Map json = { - _Json.definition: definition is SurfaceDefinition - ? definition.toJson() - : definition as JsonMap, + _Json.definition: definition.toJson(), _Json.surfaceId: surfaceId ?? generateId(), }; return DataPart( From af31e9cbc414547e8e47a54b443448ce3cee2f31 Mon Sep 17 00:00:00 2001 From: Andrew Kolos Date: Tue, 16 Jun 2026 12:52:34 -0700 Subject: [PATCH 46/49] docs(genui): drop "substrate" jargon from comments --- packages/genui/lib/src/engine/surface_registry.dart | 6 +++--- packages/genui/lib/src/model/catalog.dart | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/genui/lib/src/engine/surface_registry.dart b/packages/genui/lib/src/engine/surface_registry.dart index 84aac71df..aaf66246b 100644 --- a/packages/genui/lib/src/engine/surface_registry.dart +++ b/packages/genui/lib/src/engine/surface_registry.dart @@ -102,7 +102,7 @@ class SurfaceRegistry { } /// Removes a surface from the registry, emitting a [SurfaceRemoved] event. - /// The [SurfaceModel] itself is owned and disposed by the substrate's + /// The [SurfaceModel] itself is owned and disposed by /// `core.SurfaceGroupModel`. void removeSurface(String surfaceId) { if (_surfaces.remove(surfaceId) == null) return; @@ -126,8 +126,8 @@ class SurfaceRegistry { SurfaceModel? getLiveSurface(String surfaceId) => _surfaces[surfaceId]; /// Disposes of the registry and all per-surface notifiers. The underlying - /// [SurfaceModel]s are owned and disposed by the substrate's - /// `core.SurfaceGroupModel`, not by this registry. + /// [SurfaceModel]s are owned and disposed by `core.SurfaceGroupModel`, + /// not by this registry. void dispose() { _eventController.close(); for (final ValueNotifier notifier diff --git a/packages/genui/lib/src/model/catalog.dart b/packages/genui/lib/src/model/catalog.dart index e4a697776..d88cf57ba 100644 --- a/packages/genui/lib/src/model/catalog.dart +++ b/packages/genui/lib/src/model/catalog.dart @@ -275,10 +275,10 @@ class _CatalogItemComponentApi implements core.ComponentApi { Schema get schema => _item.dataSchema; } -/// Substrate-facing [core.Catalog] view of a genui [Catalog]. +/// Adapts a genui [Catalog] to the `a2ui_core` [core.Catalog] type. extension CatalogCoreView on Catalog { /// Returns a [core.Catalog] populated from this catalog's items, used when - /// constructing a [core.SurfaceModel] so substrate-side lookups see real + /// constructing a [core.SurfaceModel] so `a2ui_core` lookups see real /// component metadata instead of an empty stub. core.Catalog get coreCatalog => core.Catalog( From 860a86737b18487f8d005efdf534a5adf3e3662b Mon Sep 17 00:00:00 2001 From: Andrew Kolos Date: Tue, 16 Jun 2026 13:30:04 -0700 Subject: [PATCH 47/49] docs(genui): "migrate"->"copy" in surface_controller data-model comments --- packages/genui/lib/src/engine/surface_controller.dart | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/genui/lib/src/engine/surface_controller.dart b/packages/genui/lib/src/engine/surface_controller.dart index e58f5a69c..7558fac47 100644 --- a/packages/genui/lib/src/engine/surface_controller.dart +++ b/packages/genui/lib/src/engine/surface_controller.dart @@ -51,7 +51,7 @@ interface class SurfaceController implements SurfaceHost, A2uiMessageSink { late final surface_reg.SurfaceRegistry _registry = surface_reg.SurfaceRegistry(); // Writable data models handed out by `contextFor(id).dataModel` before the - // surface exists; migrated into the live core model on surface creation. + // surface exists; copied into the live core model on surface creation. final Map _preCreateDataModels = {}; final Map _liveDataModels = {}; @@ -232,7 +232,7 @@ interface class SurfaceController implements SurfaceHost, A2uiMessageSink { }; void _onCoreSurfaceCreated(core.SurfaceModel surface) { - // Migrate pre-create fallback data into the live model BEFORE notifying + // Copy pre-create fallback data into the live model BEFORE notifying // registry listeners; otherwise a synchronous listener could call // contextFor(...).dataModel and cache an empty live wrapper before the // fallback's data is copied in. From 77fd2cb8ee3e90ab15bb2d522e3aff150e67a595 Mon Sep 17 00:00:00 2001 From: Andrew Kolos Date: Tue, 16 Jun 2026 13:39:12 -0700 Subject: [PATCH 48/49] fix(genui): only treat {path: } as a binding in BoundValue A non-String path (e.g. malformed AI output) reached `as String` and threw; match main's {'path': String} guard and fall through to the literal case. --- packages/genui/lib/src/widgets/widget_utilities.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/genui/lib/src/widgets/widget_utilities.dart b/packages/genui/lib/src/widgets/widget_utilities.dart index 77fddccf6..fa3dee44a 100644 --- a/packages/genui/lib/src/widgets/widget_utilities.dart +++ b/packages/genui/lib/src/widgets/widget_utilities.dart @@ -101,7 +101,7 @@ abstract class BoundValueState> extends State { void _setup() { final Object? raw = widget.value; - if (raw is Map && raw.containsKey('path')) { + if (raw is Map && raw['path'] is String) { _listenable = widget.dataContext.subscribe( DataPath(raw['path'] as String), ); From a1301dc666d6d5b4ddfcd83ee9cda5f4a6c269f4 Mon Sep 17 00:00:00 2001 From: Andrew Kolos Date: Tue, 16 Jun 2026 13:39:12 -0700 Subject: [PATCH 49/49] fix(genui): report the bound path in DataModelTypeException _SignalNotifier hardcoded DataPath.root; thread the subscribed path so a type mismatch points at the right location (parity with main). --- packages/genui/lib/src/model/data_model.dart | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/packages/genui/lib/src/model/data_model.dart b/packages/genui/lib/src/model/data_model.dart index b8e531f70..c504c9e59 100644 --- a/packages/genui/lib/src/model/data_model.dart +++ b/packages/genui/lib/src/model/data_model.dart @@ -270,7 +270,10 @@ class InMemoryDataModel implements DataModel { @override ValueNotifier subscribe(DataPath absolutePath) { - return _SignalNotifier(_core.watch(absolutePath.toString())); + return _SignalNotifier( + _core.watch(absolutePath.toString()), + absolutePath, + ); } @override @@ -359,9 +362,10 @@ class InMemoryDataModel implements DataModel { /// Bridges a preact_signals [core.ReadonlySignal] to a Flutter /// [ValueNotifier]. class _SignalNotifier extends ValueNotifier { - _SignalNotifier(this._signal) : super(_cast(_signal.peek())) { + _SignalNotifier(this._signal, this._path) + : super(_cast(_signal.peek(), _path)) { _disposeEffect = core.effect(() { - final T? newValue = _cast(_signal.value); + final T? newValue = _cast(_signal.value, _path); if (newValue == value) { notifyListeners(); } else { @@ -371,13 +375,14 @@ class _SignalNotifier extends ValueNotifier { } final core.ReadonlySignal _signal; + final DataPath _path; late final void Function() _disposeEffect; bool _isDisposed = false; - static T? _cast(Object? v) { + static T? _cast(Object? v, DataPath path) { if (v != null && v is! T) { throw DataModelTypeException( - path: DataPath.root, + path: path, expectedType: T, actualType: v.runtimeType, );