Skip to content

feat(react): render A2uiSurface through the node layer - #2393

Merged
andrewkolos merged 48 commits into
a2ui-project:mainfrom
andrewkolos:node-layer-surface-flip
Aug 27, 2026
Merged

andrewkolos merged 48 commits into
a2ui-project:mainfrom
andrewkolos:node-layer-surface-flip

Conversation

@andrewkolos

@andrewkolos andrewkolos commented Aug 25, 2026

Copy link
Copy Markdown
Collaborator

Follow-up to #2077.

Flips A2uiSurface over to the node layer that PR added to web_core, and deletes A2uiNodeSurface. The surface builds a single NodeResolver, which turns the flat component map and the data model into a live tree of ComponentNodes, and each component subscribes to the props of its own node. A data write should then only re-render the component whose resolved props changed, rather than recursing from the root over component ids. A2uiNodeSurface only existed so the two paths could be compared, and it never shipped in a release, so deleting it outright seemed fine.

Eager resolution

Eager resolution is the riskiest part of the change, though I doubt it matters much for the way A2UI actually gets used. The existing A2uiSurface resolves a child only when something renders it, so a closed Modal's content and the inactive children of Tabs sit unresolved until they get revealed. Building the whole tree up front means those catalog functions all run when the resolver builds or reconciles the tree, and any errors they hit get reported at that point too, well before the user has touched the widget that would have shown them. Modal and Tabs are the only ones affected in the basic catalog, because they decide whether to render a child from their own React state. Card and Button decide whether to render the child from the property value, which the resolver reads anyway. Any component that decides from its own state behaves the same way, so this is a question about the node layer's contract, not about those two components. Making it lazy would mean the resolver has to know which subtrees are actually mounted, which I'd rather not build here, and whatever this PR settles on is what the other node-layer renderers inherit.

Behavior

  • A populated surface still shows its loading state on the first committed render. Its content appears after the resolver's initial update. Tests that assert immediately after mounting need to wait for that update.
  • When a child arrives after its parent, replacing the placeholder causes the parent to render again. The old renderer kept that update local to the child. adapter.test.tsx has a test for the new expectation.
  • Unknown component types and cyclic references are now reported through onError as UNKNOWN_COMPONENT_TYPE (its message reads Unknown component type: <declared type>) and CYCLIC_REFERENCE. Each is reported once per component and data path, and reported again if the condition is fixed and then reintroduced.
  • UNRESOLVED_CHILD_REFERENCE is reported when buildChild cannot find the requested instance.

API changes

The first three changes affect the node-layer API. The final change affects views that call buildChild.

  • Repeated references to the same component receive distinct instanceIds, with #n for later occurrences (a, a#2) and the data scope appended for template instances (a-[/items/0]). The characters those forms are built from are escaped in the component id and data path, so an authored id cannot collide with them; ordinary ids keep their existing form.
  • ComponentNode.type retains an unknown component's declared type, including in toJSON. Its state, rather than a sentinel type name, identifies it as a placeholder.
  • WritableBinding now exposes the authored data path as path.
  • buildChild(id, basePath) selects an instance created by the payload. It no longer creates an instance at a caller-chosen path, and a scoped template child must be requested with its {id, basePath} pair rather than resolving through a bare-id fallback. An unresolved notice lists the paths where matching instances exist.

A2uiSurface now constructs a NodeResolver and renders the resolved tree
with the node-view machinery A2uiNodeSurface carried; A2uiNodeSurface is
deleted, and the react shell sample's ?nodes toggle with it. DeferredChild
recursion over raw definitions remains the fallback for child references
the schema does not classify, so catalogs without REF: markers keep
rendering, and implementations without a view still render through their
self-binding render.

Behavior change: a placeholder upgrade replaces the parent's child
reference, so the parent view re-renders once when a late child arrives;
the id-walking path localized that update to the child. The adapter test
pins the new contract.
Model events deliver to listeners asynchronously, so a resolver
constructed between a removal and its delivery receives events for
operations that preceded it, and a removal followed by a re-add left
nodes bound to the replaced ComponentModel instance. Handlers now
reconcile against the current model state: a deletion whose component
exists again refreshes instead of disposing, root replacement rebinds
the root, and child reuse requires the record's model instance to be
current.
…he payload

The binder guaranteed a set<Prop> for every schema-dynamic property even
when the payload omitted it, and shipped input views call those setters
unguarded; the node-view conversion only re-created setters for properties
present in resolved props, so typing into e.g. a TextField without a value
prop threw instead of no-opping. The test fixture calls its setter
unguarded like the shipped TextField, so removing the synthesis fails
the suite.
sameBinding compared only writability and snapshot value, so a component
resend that re-bound a prop to a different data path while both paths held
equal values kept the previous binding, whose set closure wrote to the old
path. WritableBinding now carries the bound path and sameBinding compares
it.
An unknown-type node stored the placeholder sentinel as its type, so the
surface could only print the component id where the offending type name
belongs; state already discriminates unresolved nodes, so the declared
type survives to the render and to toJSON.
Unknown-type and cyclic errors fired per referencing edge and per
re-materialization, so one bad component referenced from several places
spammed surface.onError. Dispatched (code, component, path) entries are
recorded in maps nested by component id, making the deletion reset exact
for arbitrary ids and O(1). Entries also clear when a node for the pair
resolves and when a cyclic stand-in's edge is dropped, so a condition
that is fixed and later reintroduced (for example by a properties-only
update, which emits no deletion) reports again.
…ommit

ComponentContext's constructor throws when the component model is missing;
a component removed between the resolver's update and React committing a
freshly mounting view hit that throw and unmounted the tree at the nearest
error boundary. All three construction sites now treat the missing model
as a loading state, matching what the raw-definition path rendered.
The child index keyed entries by component id, so a parent referencing the
same id twice collapsed both positions onto the first node and duplicated
React keys; duplicate registrations now get per-position tokens and
lookups are fragment-keyed per call site.
…s replaced

The up-to-date check for reusing an unknown-type node compared neither
the declared type nor the model instance, so a deletion delivered after
its component was re-added kept a node with the replaced type and
swallowed the new type's error: the deletion had cleared the dispatch
record, but node creation was never re-entered.

Unknown-type records now carry their component model, and reuse requires
it to be current, matching the resolved arm. Adds delayed-delivery tests
for this and for the resolved-arm rebind, which had no test where its
model-identity clause was the deciding condition.
Two references to one component at the same data scope produced sibling
nodes sharing an instanceId, breaking the field's documented contract
that sibling keys are distinct; renderers keying children by instanceId
dropped or mispaired one subtree. Each further occurrence under the same
parent now gains a '#n' suffix, and reuse requires the ordinal to still
match so instance ids stay distinct after list edits.
The conversion minted its own per-position child tokens, kept a bare-token
index entry that was first-wins across data scopes, and stripped a '#n'
suffix from any id that missed the index, which mangled real component
ids on the schema-unclassified fallback path. The resolver's instanceId
now carries position distinctness, so the token is simply the component
id (or the instanceId for a repeated reference), lookups use one scoped
key, and an index miss passes the id through unchanged.
…havior changes

The react changelog covered the surface flip but not its other
consumer-visible changes: the initial loading frame, eager resolution of
previously reveal-time subtrees, and the new error reporting. The
web_core changelog gains the node layer's instance-id and error
reporting contracts.
…ommit

The vanish-before-commit guard had no test: nothing in the react suite
called removeComponent. Renders a generated view for a node whose
component was removed with the resolver's deletion delivery still
pending, the window the guard exists for.
Comments narrated the previous implementation in past tense or coined
terms with no referent; several docs went stale against this branch's
own changes (PLACEHOLDER_TYPE no longer covers unknown-type nodes, error
dispatch is deduplicated, stabilize delegates binding equality to
sameBinding). Rewrites them to state present-tense facts, names the
colon-id test by its observable behavior, drops a comment restating its
code and one left empty by the sample toggle removal, and restores the
license-header indentation carried in with the surface body.
…g surface

The inline loading div appeared at six call sites across two files, and
hasSurface existed only so both generated views could distinguish a
missing provider from a component that vanished before commit, each
repeating the same throw. A LoadingPlaceholder component covers the
first, and useNodeView throws for the missing provider itself, which
leaves an undefined context meaning exactly one thing.
DeferredChild and ResolvedChild resolved a component id by subscribing
to the components model and recursing over raw definitions. The resolver
does that work now, and no test reached either component: making
DeferredChild throw on entry left the whole suite passing.

They survived the flip as a fallback for child references the resolver
cannot identify, which is a single component id whose schema lost its
REF: marker; child lists are recognized by shape and never needed it.
That kept a second render path, its public export, and its subscription
bookkeeping alive for a case nothing exercises and no catalog in the
repo produces. buildChild now reports such a reference in place of
rendering it, so the schema bug is visible rather than papered over.
Removing DeferredChild left RenderFallback handing an implementation's
render function the NodeView buildChild, whose non-node arm reports an
unresolved reference. A render function reads child ids from the
component model, so every child it built rendered that error, including
when the schema marked the property correctly.

RenderFallback now takes its buildChild from useNodeView, which maps an
id back to the node the resolver already built, matching what the
generated views do. That also drops its hand-copied context memo. The
path had no test; one now covers it.
renderA2uiComponent rendered impl.render directly and walked children
with its own buildChild, so the 31 catalog-component cases and the 10
weight cases exercised a path A2uiSurface does not take: instrumenting
the surface showed zero of them reached it. They could not fail for any
node-layer regression.

The harness now mounts the component under test as the surface root and
lets the resolver build the tree. A child a test references but does not
define is stubbed from the component's own schema classification, so the
existing child-<id> assertions keep working. Neutering the node path's
setter conversion now fails six of these tests; before it failed none.

The component id argument is gone: the resolver renders from 'root', and
no test asserted on it. The buildChild mock is gone with it; the four
assertions on call arguments now assert on what rendered.
gemini-code-assist[bot]

This comment was marked as resolved.

It took NodeProps and returned NodeProps, which reads as a pure
transform, then mutated the object it was given. Returning void and
calling it as a statement says what it does.
extractRefFields recognized a list only by the ChildList union shape or a
REF: pointer on the property itself, so a catalog declaring children as
z.array(ComponentIdSchema), where the marker sits on the elements, was
not classified. Its children rendered through the raw-definition fallback
before that fallback was removed, and rendered unresolved-reference
errors after. The classifier now treats an array whose element carries
the ComponentId pointer as a list; the resolver already handles the
resulting string arrays.
…nError

The unresolved-reference notice blamed a missing schema marker for every
miss, including a buildChild call for a data path the payload never
created, where the schema is marked and the instance exists elsewhere.
The notice now names the actual cause: instances that exist at other
paths, a component that exists but whose referencing property carries no
marker (naming componentId()/childList() as the fix), or an id that
names no component.

Each case also dispatches UNRESOLVED_CHILD_REFERENCE through the
surface's error channel, once per reference, matching how the resolver
reports unknown types and cycles; previously the condition was visible
only in the DOM.
The console.error stub suppressed nothing: React dev re-throws the
render error through a window error event, and jsdom reports the
unhandled event straight to stderr, so every run printed two full stack
traces for a passing test. An error boundary plus marking the window
event handled silences it; the assertion moves to the boundary's
rendered message.
The view doc said an absent view falls back to render over the raw
definitions; that recursion is gone. render receives a buildChild that
resolves ids through the node layer like everything else.
…reused

A delayed onCreated delivery removes the waiting registration before
refreshing parents. When the component was already removed again, the
refresh reuses the pending placeholder without re-entering createNode,
the only writer of the registration, so a later legitimate add of that
component refreshed nothing and the child stayed a placeholder until its
parent changed for some other reason. Reusing a pending placeholder now
re-registers the parent.
Edge keys concatenated slot names, component ids, and data paths with
the same delimiters those strings may legally contain, so a field 'a'
referencing component 'b>c' and a field 'a>b' referencing 'c' composed
identical keys: resolving the second disposed the first node, which
stayed referenced from the parent's props and never updated again. The
occurrence key had the same ambiguity. Both now compose from
escaped parts, using the same escaper as instance ids, extended to
cover '>' and '@'.
Keying the child index by instanceId alone broke buildChild for
implementations that read raw component ids from the model (render-only
and binderless): a template-scoped or occurrence-suffixed instanceId no
longer matches the raw id, so marked references fell through to the
deprecated unmarked-reference fallback and reported a false deprecation.

One lookup cannot serve both callers, since the token and raw namespaces
can claim the same string for different nodes. The index now holds two
maps, keyed by id then data path: views resolve tokens, render-only and
binderless implementations resolve raw ids, first occurrence winning.
The useMemo factory reads nothing; the dependency resets the box when
the surface is swapped, which the exhaustive-deps rule flags as
unnecessary.
…builder changes

The memo comparison covered props and context identity only. Binder
props don't change when a marked child arrives, leaves, or is replaced;
the rebuilt child index reaches the component only through buildChild's
identity, so the suppressed render left a placeholder or stale child in
the DOM.
Both once-per-reference report caches keyed on id and path joined with
'@', so distinct pairs like ('a@b', '/c') and ('a', 'b@/c') collapsed to
one report. The key is now the JSON encoding of the pair.
Two notice sites joined id and path with '-', so pairs like ('a', '/b-/c')
and ('a-/b', '/c') produced the same sibling key; duplicate keys transfer
state across component ids on reorder.
…nt model

The root arm of onComponentCreated compared the bound model against the
event's payload. A delayed creation event for a replaced root then
disposed and rebuilt the already-current tree, resetting subtree
identity; delivered after the root's final removal, it built a pending
stand-in for a root the model no longer has. The handler now reads the
model's current root, matching how the deletion handler already
reconciles.
@andrewkolos
andrewkolos force-pushed the node-layer-surface-flip branch from 88ba7ba to 1d51c96 Compare August 27, 2026 01:29
@andrewkolos

Copy link
Copy Markdown
Collaborator Author

/gemini review

@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 integrates the experimental node-layer-driven rendering directly into the main A2uiSurface component, replacing the previous surface renderer. It introduces robust handling for duplicate child references using escaped instance IDs, compatibility fallbacks for unmarked child references with deprecation reporting, and improved resilience against stale asynchronous model events. The review feedback highlights opportunities to optimize the ID escaping function using a single-pass regex, simplify redundant checks in sameBinding, and add a cleanup function to the UnresolvedChildReference effect to ensure accurate error reporting across component lifecycles.

Comment thread renderers/web_core/src/v0_9/nodes/node-resolver.ts
Comment on lines 57 to 65
export function sameBinding(a: ResolvedBinding<unknown>, b: ResolvedBinding<unknown>): boolean {
return isWritable(a) === isWritable(b) && valueEquals(a.value, b.value);
if (isWritable(a) !== isWritable(b)) {
return false;
}
if (isWritable(a) && isWritable(b) && a.path !== b.path) {
return false;
}
return valueEquals(a.value, b.value);
}

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.

medium

The logic in sameBinding contains redundant checks. We can simplify and streamline the implementation to make it more concise and readable.

export function sameBinding(a: ResolvedBinding<unknown>, b: ResolvedBinding<unknown>): boolean {
  if (isWritable(a)) {
    return isWritable(b) && a.path === b.path && valueEquals(a.value, b.value);
  }
  return !isWritable(b) && valueEquals(a.value, b.value);
}

Comment on lines +74 to +97
export const UnresolvedChildReference: React.FC<{
surface: SurfaceModel<ReactComponentImplementation> | null;
id: string;
requestedPath: string;
detail: string;
}> = ({surface, id, requestedPath, detail}) => {
const message = `Unresolved child reference '${id}' at '${requestedPath}': ${detail}`;
useEffect(() => {
if (!surface) {
return;
}
let seen = reportedUnresolved.get(surface);
if (!seen) {
seen = new Set();
reportedUnresolved.set(surface, seen);
}
const key = JSON.stringify([id, requestedPath]);
if (!seen.has(key)) {
seen.add(key);
void surface.dispatchError({code: 'UNRESOLVED_CHILD_REFERENCE', message});
}
}, [surface, id, requestedPath, message]);
return <div style={{color: 'red'}}>{message}</div>;
};

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.

medium

The reportedUnresolved WeakMap tracks reported unresolved references, but the reported key is never cleared when the UnresolvedChildReference component unmounts. If an unresolved reference is resolved and later reintroduced, it will not be reported again. Adding a cleanup function to the useEffect hook ensures accurate error reporting across the component's lifecycle.

export const UnresolvedChildReference: React.FC<{
  surface: SurfaceModel<ReactComponentImplementation> | null;
  id: string;
  requestedPath: string;
  detail: string;
}> = ({surface, id, requestedPath, detail}) => {
  const message = "Unresolved child reference '" + id + "' at '" + requestedPath + "': " + detail;
  useEffect(() => {
    if (!surface) {
      return;
    }
    let seen = reportedUnresolved.get(surface);
    if (!seen) {
      seen = new Set();
      reportedUnresolved.set(surface, seen);
    }
    const key = JSON.stringify([id, requestedPath]);
    if (!seen.has(key)) {
      seen.add(key);
      void surface.dispatchError({code: 'UNRESOLVED_CHILD_REFERENCE', message});
    }
    return () => {
      seen.delete(key);
    };
  }, [surface, id, requestedPath, message]);
  return <div style={{color: 'red'}}>{message}</div>;
};

The sequential replacements were correct only because the tilde escape
ran first; a single pass over one character class removes that ordering
dependence.
The marker was only the REF: prefix of the zod description, so calling
.describe() on ComponentIdSchema or ChildListSchema silently destroyed
it and the property stopped classifying as a child reference. The kind
now also lives in the schema's zod metadata, which .describe(),
.optional(), and other schema-rebuilding methods carry forward.
Classification reads the metadata first and still recognizes
hand-authored REF: descriptions; the description keeps its wire-facing
role in the capabilities generator.
With the child-reference marker carried in schema metadata, a schema
that derives from ComponentIdSchema or ChildListSchema cannot lose it,
so the population the fallback protected no longer exists. An unmarked
reference was never a valid one: it now renders the diagnosing notice
naming the property and the componentId()/childList() fix, and reports
UNRESOLVED_CHILD_REFERENCE. The deprecated DeferredChild export is
removed with it.
@andrewkolos
andrewkolos marked this pull request as ready for review August 27, 2026 16:27

@josemontespg josemontespg 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 looks good! Nice to see the Node architecture being used in React. Looking forward to the support in the other web renderers!

I left some comments, but I'm giving approval anyway because they can be addressed as follow up changes if you want. The PR in the current state is good 👏

expect(screen.queryByText('never reached')).toBeNull();
const reported = errors.filter(e => e.code === 'UNRESOLVED_CHILD_REFERENCE');
expect(reported).toHaveLength(1);
expect(String(reported[0]?.message)).toContain('componentId()');

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 be nice to expect the entire error message, to make it easier for readers of this test to understand the expected behavior. component() on its own is a bit cryptic.

Same for the other tests that check on error messages to be displayed in the UI

// node, so a leftover id was never classified. Distinguish the two
// causes a catalog author can actually have.
const requested = basePath ?? node.dataPath;
const detail = surface.componentsModel.get(child)

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.

Could we handle this case as an error type in web_core? This problem seems common to all renderers

const path = specificPath || context.dataContext.path;
const buildChild = useCallback<NodeBuildChild>(
(child, basePath) => {
if (isComponentNode(child)) {

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 be nice to also have type narrowing for the other node cases, to help with exhaustiveness.

if (isComponentNode(child)) {}
else if (isErrorNode(child)) {}
else if (isPlaceholderNode(child)) {}
...
else {
  checkExhaustive(child) // Fails at compile time when child is not narrowed to `never`
}

I'm not sure if this is possible with the current type signature of child. Maybe we will need an enum node type to make exhaustiveness checks work correctly.

return <LoadingPlaceholder componentId={node.componentId} />;
}
const impl = node.impl;
if (!impl) {

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.

Same here, I think this should also be an error type calculated in web_core, as this seems to be common for all renderers.

};

const ResolvedChild = memo(
const NodeView = memo(

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 extract this NodeView component into its own file? It would be nice if all the logic for rendering a node correctly could be abstracted away from the surface component. Then the surface would only need to get the root and instantiate the root node.

I think this pattern would port well to other frameworks too

## Unreleased

- (v0_9) Add `A2uiNodeSurface`, a surface renderer driven by `NodeResolver` from `@a2ui/web_core`; component implementations gain an optional node-driven `view` (see `NodeViewProps` and `useSignalValue`) ([#2077](https://github.com/a2ui-project/a2ui/pull/2077)).
- (v0_9) Component implementations may supply a `view` that renders from a resolved `ComponentNode` (see `NodeViewProps` and `useSignalValue`) ([#2077](https://github.com/a2ui-project/a2ui/pull/2077)).

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.

Is the rewrite of this changelog entry intentional?

- (v0_9) Add the node layer: `NodeResolver` resolves a surface's components and data into a live tree of read-only `ComponentNode`s, with dynamic properties resolved to `ResolvedBinding`/`WritableBinding` and distinct pending, unknown-type, and cyclic placeholder states ([#2077](https://github.com/a2ui-project/a2ui/pull/2077)).
- (v0_9) The child-reference marker on `ComponentIdSchema` and `ChildListSchema` now lives in the schema's metadata, so `.describe()` and other schema-rebuilding methods no longer drop it. Hand-authored `REF:` descriptions are still recognized ([#2393](https://github.com/a2ui-project/a2ui/pull/2393)).

- (v0_9) Add the node layer: `NodeResolver` resolves a surface's components and data into a live tree of read-only `ComponentNode`s, with dynamic properties resolved to `ResolvedBinding`/`WritableBinding` and distinct pending, unknown-type, and cyclic placeholder states. Sibling instance ids are always distinct, unresolvable and cyclic references are reported through `onError` once per component and data path while the condition persists, model events delivered late reconcile against current model state, and child-reference detection covers `ChildList` unions and plain arrays of component ids ([#2077](https://github.com/a2ui-project/a2ui/pull/2077), [#2393](https://github.com/a2ui-project/a2ui/pull/2393)).

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.

Same here, is this rewrite intentional?

@andrewkolos
andrewkolos enabled auto-merge (squash) August 27, 2026 20:25
@andrewkolos
andrewkolos disabled auto-merge August 27, 2026 20:33
@andrewkolos
andrewkolos enabled auto-merge (squash) August 27, 2026 20:33
@andrewkolos
andrewkolos merged commit 5851b3f into a2ui-project:main Aug 27, 2026
30 checks passed
@github-project-automation github-project-automation Bot moved this from Todo to Done in A2UI Aug 27, 2026
@andrewkolos

Copy link
Copy Markdown
Collaborator Author

There are some really good review comments here. That being said, I'm settling on just giving a pinky promise to follow up on these so I can merge this ASAP in interest of facilitating @josemontespg's work on universal web components.

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