feat(react): render A2uiSurface through the node layer - #2393
Conversation
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.
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.
88ba7ba to
1d51c96
Compare
|
/gemini review |
There was a problem hiding this comment.
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.
| 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); | ||
| } |
There was a problem hiding this comment.
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);
}| 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>; | ||
| }; |
There was a problem hiding this comment.
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.
…de-layer-surface-flip
josemontespg
left a comment
There was a problem hiding this comment.
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()'); |
There was a problem hiding this comment.
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) |
There was a problem hiding this comment.
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)) { |
There was a problem hiding this comment.
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) { |
There was a problem hiding this comment.
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( |
There was a problem hiding this comment.
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)). |
There was a problem hiding this comment.
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)). |
There was a problem hiding this comment.
Same here, is this rewrite intentional?
|
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. |
Follow-up to #2077.
Flips
A2uiSurfaceover to the node layer that PR added to web_core, and deletesA2uiNodeSurface. The surface builds a singleNodeResolver, which turns the flat component map and the data model into a live tree ofComponentNodes, 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.A2uiNodeSurfaceonly 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
A2uiSurfaceresolves a child only when something renders it, so a closedModal's content and the inactive children ofTabssit 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.ModalandTabsare the only ones affected in the basic catalog, because they decide whether to render a child from their own React state.CardandButtondecide 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
adapter.test.tsxhas a test for the new expectation.onErrorasUNKNOWN_COMPONENT_TYPE(its message readsUnknown component type: <declared type>) andCYCLIC_REFERENCE. Each is reported once per component and data path, and reported again if the condition is fixed and then reintroduced.UNRESOLVED_CHILD_REFERENCEis reported whenbuildChildcannot find the requested instance.API changes
The first three changes affect the node-layer API. The final change affects views that call
buildChild.instanceIds, with#nfor 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.typeretains an unknown component's declared type, including intoJSON. Itsstate, rather than a sentinel type name, identifies it as a placeholder.WritableBindingnow exposes the authored data path aspath.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.