Skip to content

feat(a2ui_flutter): add the framework adapter core - #3084

Merged
andrewkolos merged 6 commits into
mainfrom
feat/flutter-adapter-core
Oct 14, 2026
Merged

andrewkolos merged 6 commits into
mainfrom
feat/flutter-adapter-core

Conversation

@andrewkolos

Copy link
Copy Markdown
Collaborator

Adds dart/a2ui_flutter, the Flutter renderer for protocol v0.9, built on a2ui_core's node layer. It follows the framework adapter blueprint and the TypeScript adapters, and replaces #2904.

Stuff added

  • A2uiSurface, which renders a SurfaceModel. 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, a ComponentApi with a widget builder. WidgetCatalog is a catalog of them, and ComponentProps reads a node's resolved properties, bindings and actions.

The README shows how to host surfaces and write custom components.

Differences from the blueprint

  • Folders follow the React adapter rather than section 2 of the blueprint. None of the TypeScript adapters uses section 2's five folders, and this core is six files. The rule section 2 does set, that the basic catalog stays apart from the surface and node view, still holds: #PR2 adds the catalog as its own library, which the core never imports.

Pre-launch Checklist

One time:

For this PR:

  • I have updated the relevant CHANGELOG.md file.
  • I updated/added relevant documentation.
  • My code changes (if any) have tests.
  • If my branch is on a fork, I have verified that scripts/e2e_test.sh passes.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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.
@andrewkolos
andrewkolos force-pushed the feat/flutter-adapter-core branch from fd6bc67 to 74a8af4 Compare October 8, 2026 19:51
@andrewkolos
andrewkolos added this pull request to stack #3089 October 8, 2026 19:55
@andrewkolos
andrewkolos marked this pull request as ready for review October 8, 2026 20:25
/// [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 =

@jacobsimionato jacobsimionato Oct 9, 2026 •

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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')         // after

What changes in the public API

  • Builders take three parameters instead of four, and buildChild and props.children use ComponentProps instead of ComponentNode. 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.
  • ComponentProps drops implements NodeProps, so props['child'] can't hand out a raw ComponentNode. raw stays as an escape hatch.
  • child.key replaces the "put KeyedSubtree(key: NodeKey(...)) outermost" rule in the docs and README.
  • ComponentPropsBuilder makes watching children public, so an app's own catalog can build a container like the basic Row.

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 listEquals on children compares nodes as it does today.
  • No type parameter. NodeView casts children back to ComponentNode<ComponentImplementation> internally, as React's node-view.tsx does.
  • Forwarding methods rather than a .props getter. An extension type over a node can't implement a map-based type, so forwarding is what keeps the common case props.string(...).

Trade-offs

  • Reads return current values: during a build that's the same snapshot NodeView takes today, and inside onPressed it's the value at tap time.
  • §4.2 of the blueprint sketches build(node, props, buildChild), so this would be a "Local Technical Decision" in codebase.blueprint.md.
  • fixture_catalog.dart would cast back to ComponentNode in tests to assert disposed.

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

@jacobsimionato jacobsimionato left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Love this!!

…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
@andrewkolos
andrewkolos merged commit b8d7001 into main Oct 14, 2026
37 checks passed
@andrewkolos
andrewkolos deleted the feat/flutter-adapter-core branch October 14, 2026 22:17
@github-project-automation github-project-automation Bot moved this from Todo to Done in A2UI Oct 14, 2026
andrewkolos added a commit that referenced this pull request Oct 14, 2026
…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
andrewkolos added a commit that referenced this pull request Oct 14, 2026
#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
nan-yu pushed a commit that referenced this pull request Oct 14, 2026
* 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
andrewkolos added a commit that referenced this pull request Oct 15, 2026
* 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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants