Skip to content

Implement Node Layer Architecture across Core and Renderers #1282

@jacobsimionato

Description

@jacobsimionato

This is a proposal based on a comment that @yjbanov made on the original design for web_core. After thinking about it a bit more and seeing how the libraries evolved, I think he was right!

Description and Rationale

We are introducing a "Node Layer" architecture into the A2UI core libraries and refactoring the existing rendering frameworks (React, Angular, Lit) to consume it.

Currently, frameworks are responsible for complex tasks: traversing the flat SurfaceModel adjacency list, resolving data bindings using GenericBinder, handling template expansions (e.g., ChildList), and managing subscription lifecycles. This causes duplicated logic across frameworks, risks memory leaks due to forgotten binder disposals, and makes adding new framework adapters difficult.

The Node Layer centralizes this "business logic" into the core library. It transforms the flat component map into a living, reactive view hierarchy. Renderers will simply receive fully-resolved, type-safe Node instances and focus solely on mapping them to native UI primitives (pixels).

Detailed Architecture & Design

The Node Layer represents a shift from frameworks directly managing components and data to having the SurfaceModel manage the entire view hierarchy lifecycle.

1. Core Data Structures: The Node

The new architecture introduces a Node<TProps> interface. A Node represents a living, fully resolved component instance in the view hierarchy. By utilizing generics, the Node interface provides type-safe property access to the rendering frameworks.

  • instanceId: A stable, unique identifier for this instance in the rendered tree. For templated nodes, this incorporates the data path (e.g., 'item-card-[/users/0]') to ensure React/Flutter keys remain stable.
  • props: A reactive Signal<TProps>. Dynamic values are primitive strings/numbers/booleans, Actions are callable () => void closures, and Structural props (e.g., child or children) contain actual child Node references (not string IDs).
  • Lifecycle: onDestroyed event and a dispose() method.

2. Internal Node Engine inside SurfaceModel

The management of the Node tree is an implementation detail inside SurfaceModel. Because SurfaceModel encapsulates both the DataModel and the SurfaceComponentsModel, it is perfectly positioned to handle this.

  • Public API: SurfaceModel exposes a single, reactive rootNode: Signal<Node | undefined>. Frameworks simply subscribe to this signal.
  • Internal Engine: The SurfaceModel internally tracks SurfaceComponentsModel changes and DataModel mutations. It manages a private cache of active nodes, automatically instantiating and destroying child nodes as requested by layout containers.

3. 1-to-N Mapping for Templates (ChildLists)

A primary responsibility of the internal Node engine is managing the 1-to-N mapping between a single ComponentModel and multiple rendered Node instances.
When an A2UI payload contains a ChildList acting as a template (e.g., a Card with ID "user-card" iterating over /users), it will be rendered multiple times.

  • The internal engine handles subscribing to the DataModel array path (e.g., /users).
  • If the array has 3 items, the engine spawns 3 unique Node instances for the same componentId.
  • Each spawned node receives its own instanceId, scoped dataPath, and an isolated GenericBinder to resolve its properties.
  • If the array length changes (e.g., a 4th user is added), the parent node detects the array mutation and spawns a 4th Node, emitting an updated ChildList prop containing the 4 nodes.
  • When destroyed (e.g., a user is deleted), the Node fires onDestroyed, disposes its GenericBinder, and destroys its children.

4. Soft Transition for Adapters

Framework adapters will expose Node objects for structural properties, but will maintain backward-compatible shims so component authors don't have to rewrite all their UI code immediately.

  • buildChild Overload: The buildChild helper will accept child: string | Node. If it receives a Node, it renders it directly.
  • Node.toString(): Implement Node.prototype.toString() to return its componentId, preventing breakage for logic using ID comparisons.

Metadata

Metadata

Assignees

Type

No type
No fields configured for issues without a type.

Projects

Status

Todo

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions