Skip to content

Node layer for web_core + React - #2077

Open
andrewkolos wants to merge 17 commits into
a2ui-project:mainfrom
andrewkolos:node-layer-experimental
Open

Node layer for web_core + React#2077
andrewkolos wants to merge 17 commits into
a2ui-project:mainfrom
andrewkolos:node-layer-experimental

Conversation

@andrewkolos

@andrewkolos andrewkolos commented Jul 23, 2026

Copy link
Copy Markdown
Collaborator

Implements #1282 for TypeScript,

Adds an experimental layer to @a2ui/web_core that resolves a surface's components and data into a live tree, plus a React surface that renders from that tree.

Today, A2uiSurface renders by recursing over component ids: each component wrapper runs its own GenericBinder against raw definitions, child references stay strings until a wrapper renders them, and any renderer must reimplement the same resolution. The new NodeResolver moves that work into web_core: it turns a surface's flat component map into a live tree of ComponentNodes, exposed through a reactive rootNode signal. In a resolved node's props:

  • child-reference properties hold live ComponentNode objects (template ChildLists spawn one node per array item);
  • every dynamic value is a ResolvedBinding: a snapshot plus a set function present only when the payload bound a data path, so writing to a literal-valued property is a type error instead of a silent no-op;
  • actions are ready-to-call closures, with context resolved at dispatch time.

A node's props signal emits only when that node's own resolved properties change; child-internal changes stay on the child's signal. Missing components and unknown catalog types render as placeholder nodes that are upgraded in place when resolvable; cyclic references render as placeholders with a dispatched CYCLIC_REFERENCE error. Node identity is parent-scoped, so one component id mounted in two positions yields two nodes and dropping one never tears down the other.

A2uiNodeSurface (in @a2ui/react) is a drop-in sibling of A2uiSurface that renders this tree. Each component subscribes only to its own node's props, so a data change re-renders exactly the affected component. Existing view code runs unchanged: a generated per-implementation view converts node props back to the value + set<Prop> and string-id shapes current views expect, and child references the catalog schema does not declare fall back to the existing DeferredChild recursion, so third-party catalogs render as-is.

Behavioral changes

  • Client capabilities output changes for the basic catalog. Per-usage .describe() calls on ComponentIdSchema/ChildListSchema were dropping the REF: pointer, so the basic catalog's eight child-reference properties (across Card, Row, Column, List, Tabs, Modal, and Button) were emitted as inline string schemas. They now emit $ref: common_types.json#/$defs/ComponentId (or ChildList) as intended. This fix stands on its own, independent of the node layer, and addresses the REF:-handling part of [Proposal] Resolve child components based on schema and not on hardcoded 'child'/'children' keywords. Also improve REF: handling #1528 (the issue's larger ask, schema-driven child resolution in the existing binders, is untouched here).
  • Invoking a catalog function that has no implementation now throws an A2uiExpressionError naming the function as schema-only. On the rendering path this surfaces as the same EXPRESSION_ERROR client error as before, with a clear message where a TypeError's used to be.
  • New exports, all experimental: the node layer (ComponentNode, NodeResolver, ResolvedBinding, and support types) from @a2ui/web_core/v0_9, and A2uiNodeSurface with its view types from @a2ui/react/v0_9. The two index.ts diffs are the full list.
  • The React shell sample renders through A2uiNodeSurface when ?nodes is in the URL; default rendering is unchanged.
  • No breaking API changes: the new type parameters default to current behavior, existing implementations without a view render through the fallback, and A2uiSurface is untouched.

Known limitation: a template-spawned node's instanceId names a list position, not a data item, so it is not stable across array insertions or reorders. Data-derived child keys depend on #1745.

If this PR is adopted, A2uiSurface can take these internals under its existing name and A2uiNodeSurface is deleted; the conversion in node-view.tsx exists only so current views render unchanged while the two are compared. Later, once catalog components are authored against resolved values directly (the portable web components proposal, #931, could be the place to do this), that conversion and GenericBinder's synthesized set<Prop> shape lose their last consumer.

@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 an experimental React surface renderer (A2uiNodeSurface) driven by a new node layer (NodeResolver and ComponentNode) that resolves component references and templates into a live tree of nodes for fine-grained re-renders. It also updates the Catalog interface to distinguish schema-only catalogs from those with executable function implementations, and adds comprehensive unit tests. The review feedback highlights several key improvement opportunities, including restricting object traversal in toViewValue to plain objects to prevent breaking types like Date or Map, unwrapping ZodEffects in schema parsing to support refined schemas, removing a redundant synchronous onChange call to avoid duplicate React state updates, using public Zod APIs instead of internal _def properties, and cleaning up empty sets in pendingParents to prevent minor memory leaks.

Comment thread renderers/react/src/v0_9/A2uiNodeSurface.tsx Outdated
Comment thread renderers/web_core/src/v0_9/nodes/ref-fields.ts
Comment thread renderers/react/src/v0_9/A2uiNodeSurface.tsx
Comment thread renderers/web_core/src/v0_9/nodes/ref-fields.ts Outdated
Comment thread renderers/web_core/src/v0_9/nodes/node-resolver.ts
Comment thread renderers/web_core/src/v0_9/nodes/node-resolver.ts Outdated

@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.

This is great! Neat way to prototype it!

}

get isPlaceholder(): boolean {
return this.type === PLACEHOLDER_TYPE;

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.

What does this mean again? Could you add a doc comment about it?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Done in 1193855

* come from the resolver's cache; untouched arrays keep their identity), so
* shallow comparison is exact rather than heuristic.
*/
setProps(next: TProps): void {

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 think we don't want application code to be able to call this. Can we provide a readonly public interface for Node, so that the NodeResolver has write access, but the rest of the application code can only read values and update the data model via the bindings in the node, rather than the node props directly?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Done in 160a82b. ComponentNode is now read-only with a protected constructor: identity fields, props, onDestroyed, addCleanup, toJSON. setProps, dispose are moved to MutableComponentNode, which only the resolver uses and the package does not export, so application code reads props and writes data only through a binding's set.

There is a caveat here in that node.props is still a Signal, so the low-level setValue(node.props, ...) remains a bypass for a determined client. We would need to add a read-only signal type to close that. I haven't explored this, but I'm assuming it would be a breaking change.

Comment thread renderers/web_core/src/v0_9/nodes/component-node.ts
* unchanged props keep reference identity across rebuilds. Child
* `ComponentNode`s and action closures compare by identity.
*/
function stabilize(prev: unknown, next: unknown): unknown {

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.

Can this be private?

Comment thread renderers/web_core/src/v0_9/nodes/node-resolver.ts
* Use in place of a bare string id so the node layer can resolve the child.
*/
export function componentReference(): z.ZodTypeAny {
return ComponentIdSchema.describe(`${SINGLE_REF_MARKER}|${ComponentIdSchema.description ?? ''}`);

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.

We already do a hack like this in our schemas here:

.describe('REF:common_types.json#/$defs/ComponentId|The unique identifier for a component.');

We needed it so that when we convert an in-memory Zod catalog to a wire-format catalog, we can replace the subschemas for DynamicString etc with references to common_types.json.

Seems like you needed basically the same thing here - to be able to identify specific special subschemas to understand the meaning of different strings etc.

Could you use the same magic references, e.g. centralize some definition in common-types?

BTW I wrote a proposal at go/a2ui-catalogs-without-json-schema to design our own schema format that would make this less hacky. I'm not a fan of this description hack, but I don't know a way around it without abandoning JSON schema!

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Done in 58c3036. Deleted my A2UI_COMPONENT_REF marker and switched to the same magic references you linked--the node layer now finds child refs by the REF:common_types.json#/... descriptions, so components just declare them with ComponentIdSchema / ChildListSchema. I didn't do that originally because .describe() at a call site wipes the whole description, magic reference included. That's also why generated capabilities were losing their $refs (#1528). Added componentIdWithDescription / childListWithDescription that append instead of replace, moved the basic catalog onto them, and added a test that the generated $refs persist.

/**
* One resolved component instance in the rendered tree.
*
* A node's `props` hold fully resolved values: primitives for dynamic values,

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.

Can we add some way to update the value of two-way bound variables from the node? E.g. for ChoicePicker, it should be possible to somehow update the bound "value" property of the node. I'm not sure the best way to do this. Some ideas:

A. props['value'] returns a BoundValue object which exposes get and set. If we do this, then I think we should probably always return a BoundValue for properties that are DynamicValues etc in the schema, even if they happen to be populated with a static value at runtime. That way, component implementations can always access the value in the same way e.g. props['value'].get() and props['value'].set('new value').

B. Expose setters as additional keys in props e.g. props['setValue'](updatedValue). I think we basically did this in the generic binder.

C. Or expose some other parallel map of setters that is separate to props. E.g. boundValues['value']('updated value').

Vercel did something between A and C I think: https://json-render.dev/docs/registry#component-props

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Done in 3135360. Every dynamic property now resolves to a ResolvedBinding in props, shaped { value, set? }, which is your option A with two changes. First, set is absent when the payload supplied a literal or a function call: there's nothing to write to, and the old synthesized setters silently swallowed those writes, so now an unchecked write doesn't compile.

Second, value is a snapshot rather than a get() closure. A live get() reads the data model at whatever moment the component happens to look, so under React's concurrent rendering two components can disagree about one value within a single frame; that's the tearing problem useSyncExternalStore exists to prevent, and the node surface already uses it for rootNode. A snapshot can't disagree with the emission that delivered it, and the same property means serialized trees and headless reads always describe a state that actually existed. It also keeps toJSON useful: a snapshot serializes as the actual value, while a closure can't appear in JSON at all, so it would have to serialize as a stand-in string the way action closures come out as '<Action>', and serialized trees would lose their two-way values. Writes go through the node's data scope, so a component inside a template row writes to that row's item without knowing it's templated.

You remembered right that option B is what the generic binder already does: it puts extra setValue-style keys in props. That code is untouched in this PR, so nothing views consume changes shape; bindings only exist in node props, which are new here. I didn't want option B for node props because the set<Name> keys can only be typed with mapped types, which no other target language has. Moving the binder itself onto bindings would break A2uiSurface's view contract, so that waits for a major version.

I also read the json-render docs you linked, and you had it right, it's between A and C. They put the current value in props as a plain value and the state path it was bound to in a separate bindings map, and their useBoundProp hook combines the two into a [value, setValue] pair, same shape as useState, with the setter writing to that path; when the prop was a literal there's no path, and the setter it hands back does nothing. That's a fine deal in an all-React library, because a React input displays whatever its state variable holds: if the setter does nothing, the variable never changes, the input visibly freezes, and the developer notices immediately. It doesn't carry over to us for two reasons. Flutter disables its selection controls by passing onChanged: null (TextField does it with enabled: false, decided the same way), so a view has to be able to tell "no write destination" apart from "has one", and a no-op function looks identical to a real one; an absent set is the signal a view can map onto that (and headless consumers render nothing that could visibly freeze). And the [value, setValue] hook idiom is React-specific; Dart and Swift have nothing like it, while a bundled value-plus-set object works as-is in every language I'm aware of.

Comment thread renderers/react/src/v0_9/adapter.tsx Outdated
* without `render`'s own binder. Absent on binderless implementations,
* which the node surface falls back to rendering through `render`.
*/
view?: React.FC<ReactA2uiComponentProps<any>>;

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.

Would it make sense for this to just accept a node and buildChild as the parameter? Then we can have extra sugar in createComponentImplementation that exposes props, in a typesafe way?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Yes, I made this change in cf09709. view now takes {node, buildChild}, and both factories generate it. The generated wrapper subscribes to the node's props, converts them to the ReactA2uiComponentProps shape authors already write against, and resolves child ids through its own index. The author's function stays typed per component through ResolveA2uiProps, so the typesafe part of your suggestion is covered. Component code is unchanged, and the node never reaches it. A2uiNodeSurface is left as a dispatcher, plus a fallback that renders from raw definitions when an implementation has no view.

One can still write a view by hand without the factories, and that one takes the node directly. That's fine now that nodes are read-only. I exported useSignalValue in 6e4f06e so that's actually doable from outside the package.

Down the road I want a mapped type where dynamic props come through as ResolvedBinding<T>, so views consume bindings directly and the wrapper stops converting. I didn't add it here because nothing would use it yet. The components that replace the current catalog are the natural first users. Happy to go deeper on any of this.

… in types

Catalog and SurfaceModel gain a function-kind type parameter defaulting
to FunctionImplementation, so existing code is unchanged. A schema-only
catalog types its functions as FunctionApi (signatures without code),
matching the Python SDK's two-parameter Catalog. The catalog invoker
reports a schema-only function as an expression error instead of
failing on a missing execute method.
NodeResolver turns a surface's flat component map into a live tree of
resolved ComponentNodes exposed through a reactive rootNode. Child
references resolve to node objects, ChildList templates spawn one node
per array item with per-position reuse, missing components render as
placeholder nodes that are upgraded in place, and disposal follows
parent-scoped ownership. Malformed payloads degrade safely: cyclic
references and unknown catalog types render as placeholders with
dispatched errors. Construction requires a catalog typed with
FunctionImplementation, so resolving a tree without executable
functions is a compile error.

A node's props signal emits only when its own resolved properties
change; child-internal changes stay on the child's signal. Action
context resolves at dispatch time. The test suite ports
test_node_graph.py from the Python SDK and adds coverage for late
action resolution, shared-child disposal, template key stability,
cycles, and emission granularity.
A2uiNodeSurface renders a surface from a NodeResolver instead of the
per-component id recursion in A2uiSurface. One resolver per surface
owns binding and tree maintenance; the resolver is constructed inside
the store subscribe callback so renders discarded by concurrent
features cannot leak it. Each node's view subscribes only to that
node's props signal, so a data change re-renders exactly the affected
component.

Existing view code is reused unchanged: createComponentImplementation
now also exposes the unwrapped view, node props are converted back to
the string and {id, basePath} shapes views expect, and buildChild maps
those refs to live nodes. Refs the node layer cannot identify fall
back to DeferredChild, so the shipped basic catalog renders as-is.
Tests cover render isolation, template stability, StrictMode, action
dispatch, and input components writing scoped values through
synthesized setters.
Default rendering is unchanged; adding ?nodes to the URL renders every
surface through A2uiNodeSurface so the two paths can be compared in the
same app.
Every position the schema classifies as dynamic now resolves to a
ResolvedBinding in node props: a snapshot value plus a set function
present only when the payload bound a data path. The binder's
synthesized set<Prop> siblings no longer appear in node props, so a
write to a literal-valued property is a type error instead of a silent
no-op. A2uiNodeSurface unwraps bindings back to the value + set<Prop>
pair the existing views consume.
Remove wording that only made sense relative to the design discussions
("legacy", "binding contract", "payload spelling"), coined terms with no
in-code anchor ("emission gate", "prop-pair shape"), and comments that
restated adjacent assertions. Point change-detection docs at the concrete
mechanism, the shallow comparison in ComponentNode.setProps, and state
setProps's reference-identity requirement as a caller obligation rather
than a description of current callers.
@andrewkolos
andrewkolos force-pushed the node-layer-experimental branch from 9a83ab2 to 7115148 Compare July 31, 2026 17:10
Removing the last waiting parent, or disposing the last waiting node,
previously left an empty set behind, so the map grew with every
component id that ever had a placeholder.
A component schema refined or transformed at the top level, or on a
property, previously hid its child references from classification.
effect runs its body synchronously on creation, so the explicit call
notified React twice for the same state.
Mutation moves to MutableComponentNode, which the package barrel does
not export: application code reads props and writes data only through a
binding's set. The base constructor is protected, so node construction
is not public API either.
… them

Per-usage .describe() on ComponentIdSchema and ChildListSchema replaced
the whole description, silently dropping the REF: pointer the
capabilities generator turns into wire $refs. Compose prose with the
pointer via componentIdWithDescription and childListWithDescription
instead, and sweep the basic catalog call sites.

The node layer's ref-field classification now reads the surviving
pointers directly, replacing its placeholder marker helpers, so the
unmodified basic catalog resolves single-child references node-side.
view now takes {node, buildChild}; both factories generate it. The
generated wrapper subscribes to its node's props, converts them to the
ReactA2uiComponentProps shape existing views implement, and resolves
child ids through the conversion's index, work the surface previously
did once, untyped, for all components. A2uiNodeSurface shrinks to a
dispatcher that renders each implementation's view, with a
raw-definition fallback for implementations that lack one.
A view written directly against NodeViewProps needs a way to subscribe
to node.props; only the generated wrappers could reach the hook.
…ntal

# Conflicts:
#	renderers/web_core/src/v0_9/basic_catalog/components/basic_components.ts
#	renderers/web_core/src/v0_9/processing/message-processor.test.ts
@andrewkolos
andrewkolos marked this pull request as ready for review August 2, 2026 03:42
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