Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
0b297fd
feat(a2ui_core): tighten protocol parsing, expose effect, copy mutabl…
andrewkolos May 26, 2026
667f51c
refactor(genui): rebase data substrate on a2ui_core.DataModel (compat…
andrewkolos May 26, 2026
37d8e73
refactor(genui): live a2ui_core surface state behind SurfaceDefinitio…
andrewkolos May 26, 2026
7854ca2
refactor(genui): delegate SurfaceController to a2ui_core.MessageProce…
andrewkolos May 26, 2026
8e81d1a
docs(migration): add migration guide for genui -> a2ui_core swap
andrewkolos May 26, 2026
f126f96
chore: trim comments
andrewkolos May 28, 2026
8c17e6f
refactor(genui): preserve pre-create dataModel access; reduce core le…
andrewkolos May 28, 2026
0560733
refactor(genui): address reviewer pass on substrate migration
andrewkolos May 28, 2026
2205552
refactor(genui): preserve pre-migration SurfaceRegistry public API
andrewkolos May 28, 2026
cb92e32
refactor(genui): preserve registry event public constructors
andrewkolos May 28, 2026
edb46a3
refactor(genui): preserve public SurfaceUpdate constructor signatures
andrewkolos May 28, 2026
d81afb1
chore(genui): const on public SurfaceUpdate ctors; CHANGELOG note for…
andrewkolos May 28, 2026
a4ff747
docs(genui): clarify CHANGELOG wording on addSurface/notifyUpdated
andrewkolos May 28, 2026
b257c98
refactor(genui): break ui_models/schema_validation import cycle
andrewkolos Jun 4, 2026
9fad0e4
fix(genui): correct user-action detection and empty-surfaceId rejection
andrewkolos Jun 4, 2026
a795799
test(genui): cover live rebuild + duplicate-create, re-baseline coverage
andrewkolos Jun 4, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion coverage_baseline.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
122 changes: 122 additions & 0 deletions docs/usage/migration/migration_genui_onto_a2ui_core.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
# 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.
- 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
`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.
13 changes: 13 additions & 0 deletions packages/a2ui_core/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
24 changes: 20 additions & 4 deletions packages/a2ui_core/lib/src/core/data_model.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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 <String, Object?>{
for (final entry in value.entries)
entry.key.toString(): _mutableJsonLike(entry.value),
};
}
if (value is List) {
return <Object?>[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<String, WeakReference<Signal<Object?>>> _signals = {};

DataModel([Object? initialData]) : _data = initialData ?? <String, Object?>{};
DataModel([Object? initialData])
: _data = _mutableJsonLike(initialData ?? <String, Object?>{});

/// Synchronously gets data at a specific JSON pointer path.
Object? get(String path) {
Expand Down Expand Up @@ -49,7 +65,7 @@ class DataModel {

batch(() {
if (dataPath.isEmpty) {
_data = value;
_data = _mutableJsonLike(value);
} else {
_data ??= <String, Object?>{};
Object? current = _data;
Expand Down Expand Up @@ -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<Object?>) {
final int? index = int.tryParse(lastSegment);
Expand All @@ -121,7 +137,7 @@ class DataModel {
while (current.length <= index) {
current.add(null);
}
current[index] = value;
current[index] = _mutableJsonLike(value);
}
}

Expand Down
41 changes: 36 additions & 5 deletions packages/a2ui_core/lib/src/core/messages.dart
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +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 message does not contain exactly one
/// of `createSurface`, `updateComponents`, `updateDataModel`,
/// `deleteSurface`.
factory A2uiMessage.fromJson(Map<String, dynamic> json) {
final Object? rawVersion = json['version'];
if (rawVersion is! String) {
Expand Down Expand Up @@ -65,11 +70,18 @@ abstract class A2uiMessage {

if (json.containsKey('updateDataModel')) {
final body = json['updateDataModel'] as Map<String, dynamic>;
return 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'],
);
}

Expand Down Expand Up @@ -137,25 +149,44 @@ class UpdateComponentsMessage extends A2uiMessage {
}

/// Updates the data model for an existing surface.
///
/// The wire protocol distinguishes two intents:
///
/// - `"value": <x>` (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.
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<String, dynamic> toJson() => {
'version': version,
'updateDataModel': {
'surfaceId': surfaceId,
if (path != null) 'path': path,
if (value != null) 'value': value,
if (hasValue) 'value': value,
},
};
}
Expand Down
12 changes: 7 additions & 5 deletions packages/a2ui_core/lib/src/primitives/data_path.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> segments;

Expand All @@ -29,9 +33,7 @@ class DataPath {
return DataPath([]);
}

final List<String> segments = normalized.split('/').map((s) {
return s.replaceAll('~1', '/').replaceAll('~0', '~');
}).toList();
final List<String> segments = normalized.split('/');

return DataPath(segments);
}
Expand Down Expand Up @@ -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
Expand Down
10 changes: 9 additions & 1 deletion packages/a2ui_core/lib/src/primitives/reactivity.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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;
17 changes: 17 additions & 0 deletions packages/a2ui_core/test/data_model_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
17 changes: 2 additions & 15 deletions packages/a2ui_core/test/data_path_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});

Expand All @@ -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)));
});
});
}
Loading
Loading