Repository navigation
feat(a2ui_flutter): add the framework adapter core - #3084
Conversation
There was a problem hiding this comment.
Code Review
This pull request introduces the a2ui_flutter package, a Flutter framework adapter for A2UI that renders a2ui_core surfaces as widget trees and manages reactivity via signals. It includes core components like A2uiSurface, ComponentProps, and NodeView, along with comprehensive tests. Feedback on the changes suggests a more robust implementation for listenAfterFirst in signal_state.dart to handle potential asynchronous signal subscriptions safely.
A2uiSurface renders a SurfaceModel through a NodeResolver, and NodeView renders each ComponentNode through its catalog entry, rebuilding when the node's props change. ComponentImplementation pairs a component schema with a widget builder, WidgetCatalog is a catalog of them, and ComponentProps reads a node's resolved properties, bindings and actions. A component that has not arrived shows a small progress indicator.
fd6bc67 to
74a8af4
Compare
| /// [props] is the node's current resolved properties. Render child nodes | ||
| /// through [buildChild]. A builder that wraps each child in another widget | ||
| /// puts `KeyedSubtree(key: NodeKey(child.instanceId), ...)` outermost. | ||
| typedef ComponentWidgetBuilder = |
There was a problem hiding this comment.
I had this idea, but if you like it, I think you should merge this and the basic catalog PR, then do this as a followup. It shouldn't actually disrupt the basic catalog that much, and in the meantime, we can ship this as a preview.
Builders get two ways to read the same data (props and node.props.peek()), and none of the basic builders in #3088 read their own node. Could we drop node and make ComponentProps the builder's view of both itself and its children? Before/after below. I'd keep it in this package for now and consider moving it to core later.
Additional details
Before / after
Simple component (most of #3088): only the node parameter goes away.
// Before
(context, node, props, buildChild) => Text(props.string('text') ?? '')
// After
(context, props, buildChild) => Text(props.string('text') ?? '')Container that wraps and measures its children (layout.dart):
// Before: two types for "a component", a manual key rule, an unexported import
import '../../signal_state.dart'; // apps can't do this
...
(int, bool) _slotOf(ComponentNode<ComponentImplementation> child) {
final props = ComponentProps(child.props.peek());
return (_flexOf(props), _ownWidth(child.type, props));
}
// + a StatefulWidget using listenAfterFirst / signalChanged on each child
KeyedSubtree(
key: NodeKey(children[i].instanceId),
child: Flexible(flex: factor, child: widget.buildChild(children[i])),
)
// After: a child reads like the component itself, all public API
final children = props.children('children'); // List<ComponentProps>
return ComponentPropsBuilder(
props: children, // re-lays out when a child's weight changes
builder: (context) => Flex(direction: direction, children: [
for (final child in children)
KeyedSubtree(
key: child.key,
child: Flexible(
flex: _flexOf(child.number('weight')),
child: buildChild(child),
),
),
]),
);Other spots in #3088:
// modal.dart
if (!ComponentProps(widget.trigger.props.peek()).isValid) return; // before
if (!widget.trigger.isValid) return; // after
// tabs.dart, choice_picker.dart
ComponentProps.asString(item['title']) // before
PropsMap(item).string('title') // afterWhat changes in the public API
- Builders take three parameters instead of four, and
buildChildandprops.childrenuseComponentPropsinstead ofComponentNode. So there's one type for "a component" and one way to read a prop. - Builders can no longer reach the resolver's node lifecycle (
impl,state,addCleanup,onDestroyed,toJson). Core owns that, per §6 of the blueprint. ComponentPropsdropsimplements NodeProps, soprops['child']can't hand out a rawComponentNode.rawstays as an escape hatch.child.keyreplaces the "putKeyedSubtree(key: NodeKey(...))outermost" rule in the docs and README.ComponentPropsBuildermakes watching children public, so an app's own catalog can build a container like the basicRow.
Every task builders do today stays possible. Child identity is still there through instanceId, componentId and componentType.
Sketch
// lib/src/component_props.dart: imports only a2ui_core
extension type const ComponentProps(ComponentNode<ComponentApi> _node) {
String get componentId;
String get instanceId;
String get componentType; // not `type`, which reads like a prop
// Same accessors as today, forwarded to PropsMap over the node's current props:
// string, number, boolean, value, list, writable, action, isValid, validationErrors
ComponentProps? child(String key);
List<ComponentProps> children(String key);
NodeProps get raw; // escape hatch
void Function() listen(void Function() onChange);
}
// The same reads over a plain map (objects in list props) and the static
// conversions (asString, asNumber, asBoolean, unwrap).
extension type const PropsMap(Map<Object?, Object?> _map) { ... }
// Flutter-only
extension FlutterComponentProps on ComponentProps {
Key get key => NodeKey(instanceId);
}
class ComponentPropsBuilder extends StatefulWidget {
// props: List<ComponentProps>, builder: WidgetBuilder.
// Schedules a mid-frame change for after the frame, as SignalState does.
}
typedef ChildWidgetBuilder = Widget Function(ComponentProps child);
typedef ComponentWidgetBuilder = Widget Function(
BuildContext context, ComponentProps props, ChildWidgetBuilder buildChild);The choices that make this work:
- An extension type. At runtime it's just the node, so there's no allocation per build, and
listEqualson children compares nodes as it does today. - No type parameter.
NodeViewcasts children back toComponentNode<ComponentImplementation>internally, as React'snode-view.tsxdoes. - Forwarding methods rather than a
.propsgetter. An extension type over a node can't implement a map-based type, so forwarding is what keeps the common caseprops.string(...).
Trade-offs
- Reads return current values: during a build that's the same snapshot
NodeViewtakes today, and insideonPressedit's the value at tap time. - §4.2 of the blueprint sketches
build(node, props, buildChild), so this would be a "Local Technical Decision" incodebase.blueprint.md. fixture_catalog.dartwould cast back toComponentNodein tests to assertdisposed.
Moving to core later
I'd keep this in a2ui_flutter for now, with no Flutter imports in component_props.dart, a public constructor and no type parameter. If another Dart adapter appears (Jaspr, for example), ComponentProps and PropsMap can move to a2ui_core and be shared. key, ComponentPropsBuilder and the builder typedefs stay per adapter. a2ui_flutter is unpublished, so changing the API now costs nothing outside this repo.
🤖 Agent-generated
…r-core # Conflicts: # blueprints/modules/a2ui_framework_adapter.blueprint.md
A component whose catalogId changes is recreated with the same instance id, like one whose type changes. Keying the subtree by the type name kept the old implementation's state when only the catalog changed. Keying by the resolved implementation resets it in both cases.
Pairs it with CatalogApi in a2ui_core, as ComponentImplementation and FunctionImplementation pair with ComponentApi and FunctionApi. 🤖 Agent-generated
…iption NodeView wraps every resolved component in a Semantics that carries the component's accessibility label as its label and its description as its hint, so basic and custom components both get them. Without either attribute the Semantics adds no node, and it stays in the tree so that adding one later keeps the component's state. 🤖 Agent-generated
…r-basic-catalog Main now has the adapter core (#3084, squash-merged) and a2ui_core's generated basic catalogs (#2998, #3129) and renderer renames (#3194). - Drop this branch's a2ui_core change, BasicCatalog.v0_9Api(): main's BasicCatalog.v0_9() now carries the components, so the basic catalog reads its schemas from there. - Pass the catalog's shared definitions along with its components. The generated component schemas reference #/$defs/CatalogComponentCommon, which a catalog without them cannot resolve. The README's custom catalog sample needs the same line. - Adopt the #3194 renames in write_back.dart, the test harness and the README. - Read Icon.name from the allOf envelope in icon_test.⚠️ Agent-generated
#3205 standardized LICENSE files on http://www.apache.org/licenses/ and made the license check enforce it. a2ui_flutter's LICENSE, added in #3084, used https, so the static-checks job fails on main.⚠️ Agent-generated
* fix(a2ui_flutter): adopt a2ui_core's renderer renames #3194 renamed A2uiClientAction and A2uiClientError to A2uiRendererAction and A2uiRendererError, MessageProcessor.groupModel to model and getClientDataModel to getRendererDataModel, and replaced EventSource.addListener with subscribe. The adapter's tests and README still used the old names, so the package's tests no longer compiled on main.⚠️ Agent-generated * fix(a2ui_flutter): use the http Apache license URL #3205 standardized LICENSE files on http://www.apache.org/licenses/ and made the license check enforce it. a2ui_flutter's LICENSE, added in #3084, used https, so the static-checks job fails on main.⚠️ Agent-generated
* feat(a2ui_flutter): add the framework adapter core A2uiSurface renders a SurfaceModel through a NodeResolver, and NodeView renders each ComponentNode through its catalog entry, rebuilding when the node's props change. ComponentImplementation pairs a component schema with a widget builder, WidgetCatalog is a catalog of them, and ComponentProps reads a node's resolved properties, bindings and actions. A component that has not arrived shows a small progress indicator. * feat(a2ui_core): expose the v0.9 basic catalog's API BasicCatalog.v0_9Api() returns the published v0.9 basic catalog document parsed with Catalog.fromJson: its components, function signatures and theme schema, so a framework adapter can read the component schemas without embedding its own copy of the document. * feat(a2ui_flutter): add the v0.9 basic catalog package:a2ui_flutter/basic_catalog.dart implements the 18 components of the v0.9 basic catalog with Material widgets, with their schemas from a2ui_core's BasicCatalog.v0_9Api() and the functions of BasicCatalog.v0_9(). basicCatalog() builds the whole catalog under the basic catalog's id; BasicComponents holds each component for a catalog of the app's own. The components apply the surface theme's primaryColor as the primary color, and every specification example renders in a test. * fix(a2ui_flutter): key a node's subtree by its implementation A component whose catalogId changes is recreated with the same instance id, like one whose type changes. Keying the subtree by the type name kept the old implementation's state when only the catalog changed. Keying by the resolved implementation resets it in both cases. * test(a2ui_flutter): cover A2uiSurface.of and maybeOf * fix(a2ui_flutter): forward the basic catalog's protocol version and instructions * refactor(a2ui_flutter): rename WidgetCatalog to CatalogImplementation Pairs it with CatalogApi in a2ui_core, as ComponentImplementation and FunctionImplementation pair with ComponentApi and FunctionApi. 🤖 Agent-generated * refactor(a2ui_flutter): use CatalogImplementation in the basic catalog 🤖 Agent-generated * feat(a2ui_flutter): apply a component's accessibility label and description NodeView wraps every resolved component in a Semantics that carries the component's accessibility label as its label and its description as its hint, so basic and custom components both get them. Without either attribute the Semantics adds no node, and it stays in the tree so that adding one later keeps the component's state. 🤖 Agent-generated * fix(a2ui_flutter): adopt a2ui_core's renderer renames #3194 renamed A2uiClientAction and A2uiClientError to A2uiRendererAction and A2uiRendererError, MessageProcessor.groupModel to model and getClientDataModel to getRendererDataModel, and replaced EventSource.addListener with subscribe. The adapter's tests and README still used the old names, so the package's tests no longer compiled on main.⚠️ Agent-generated * fix(a2ui_flutter): use the http Apache license URL #3205 standardized LICENSE files on http://www.apache.org/licenses/ and made the license check enforce it. a2ui_flutter's LICENSE, added in #3084, used https, so the static-checks job fails on main.⚠️ Agent-generated * docs(a2ui_flutter): match the README's a2ui_core constraint to the pubspec The package now depends on a2ui_core ^0.3.0 (#3192), but the install sample still showed ^0.2.2.⚠️ Agent-generated * docs(a2ui_flutter): drop accessibility from the README's unsupported list NodeView applies a component's accessibility label and description, the two attributes v0.9 defines, since effca52.⚠️ Agent-generated
Adds
dart/a2ui_flutter, the Flutter renderer for protocol v0.9, built ona2ui_core's node layer. It follows the framework adapter blueprint and the TypeScript adapters, and replaces #2904.Stuff added
A2uiSurface, which renders aSurfaceModel. Each component renders through its catalog entry and rebuilds when its resolved properties change. A root or child that has not arrived shows a small progress indicator.ComponentImplementation, aComponentApiwith a widget builder.WidgetCatalogis a catalog of them, andComponentPropsreads a node's resolved properties, bindings and actions.The README shows how to host surfaces and write custom components.
Differences from the blueprint
Pre-launch Checklist
One time:
For this PR:
If my branch is on a fork, I have verified that scripts/e2e_test.sh passes.