mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-05-03 20:51:58 +00:00
## Summary
Architecture documentation proposing an Entity Component System for the
litegraph layer.
```mermaid
graph LR
subgraph Today["Today: Spaghetti"]
God["🍝 God Objects"]
Circ["🔄 Circular Deps"]
Mut["💥 Render Mutations"]
end
subgraph Tomorrow["Tomorrow: ECS"]
ID["🏷️ Branded IDs"]
Comp["📦 Components"]
Sys["⚙️ Systems"]
World["🌍 World"]
end
God -->|"decompose"| Comp
Circ -->|"flatten"| ID
Mut -->|"separate"| Sys
Comp --> World
ID --> World
Sys -->|"query"| World
```
## Changes
- **What**: ADR 0008 + 4 architecture docs (no code changes)
- `docs/adr/0008-entity-component-system.md` — entity taxonomy, branded
IDs, component decomposition, migration strategy
- `docs/architecture/entity-interactions.md` — as-is Mermaid diagrams of
all entity relationships
- `docs/architecture/entity-problems.md` — structural problems with
file:line evidence
- `docs/architecture/ecs-target-architecture.md` — target architecture
diagrams
- `docs/architecture/proto-ecs-stores.md` — analysis of existing Pinia
stores as proto-ECS patterns
## Review Focus
- Does the entity taxonomy (Node, Link, Subgraph, Widget, Slot, Reroute,
Group) cover all cases?
- Are the component decompositions reasonable starting points?
- Is the migration strategy (bridge layer, incremental extraction)
feasible?
- Are there entity interactions or problems we missed?
┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10420-docs-ADR-0008-Entity-Component-System-32d6d73d365081feb048d16a5231d350)
by [Unito](https://www.unito.io)
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: GitHub Action <action@github.com>
Co-authored-by: Amp <amp@ampcode.com>
Co-authored-by: Christian Byrne <cbyrne@comfy.org>
240 lines
15 KiB
Markdown
240 lines
15 KiB
Markdown
# 8. Entity Component System
|
|
|
|
Date: 2026-03-23
|
|
|
|
## Status
|
|
|
|
Proposed
|
|
|
|
## Context
|
|
|
|
The litegraph layer is built on deeply coupled OOP classes (`LGraphNode`, `LLink`, `Subgraph`, `BaseWidget`, `Reroute`, `LGraphGroup`, `SlotBase`). Each entity directly references its container and children — nodes hold widget arrays, widgets back-reference their node, links reference origin/target node IDs, subgraphs extend the graph class, and so on.
|
|
|
|
This coupling makes it difficult to:
|
|
|
|
- Add cross-cutting concerns (undo/redo, serialization, multiplayer CRDT sync, rendering optimization) without modifying every class
|
|
- Test individual aspects of an entity in isolation
|
|
- Evolve rendering, serialization, and execution logic independently
|
|
- Implement the CRDT-based layout system proposed in [ADR 0003](0003-crdt-based-layout-system.md)
|
|
|
|
An Entity Component System (ECS) separates **identity** (entities), **data** (components), and **behavior** (systems), enabling each concern to evolve independently.
|
|
|
|
### Current pain points
|
|
|
|
- **God objects**: `LGraphNode` (~2000+ lines) mixes position, rendering, connectivity, execution, serialization, and input handling
|
|
- **Circular dependencies**: `LGraph` ↔ `Subgraph`, `LGraphNode` ↔ `LGraphCanvas`, requiring careful import ordering and barrel exports
|
|
- **Tight rendering coupling**: Visual properties (color, position, bounding rect) are interleaved with domain logic (execution order, slot types)
|
|
- **No unified entity model**: Each entity kind uses different ID types, ownership patterns, and lifecycle management
|
|
|
|
## Decision
|
|
|
|
Adopt an Entity Component System architecture for the graph domain model. This ADR defines the entity taxonomy, ID strategy, and component decomposition. Implementation will be incremental — existing classes remain untouched initially and will be migrated piecewise.
|
|
|
|
### Entity Taxonomy
|
|
|
|
Six entity kinds, each with a branded ID type:
|
|
|
|
| Entity Kind | Current Class(es) | Current ID | Branded ID |
|
|
| ----------- | ------------------------------------------------- | --------------------------- | ----------------- |
|
|
| Node | `LGraphNode` | `NodeId = number \| string` | `NodeEntityId` |
|
|
| Link | `LLink` | `LinkId = number` | `LinkEntityId` |
|
|
| Widget | `BaseWidget` subclasses (25+) | name + parent node | `WidgetEntityId` |
|
|
| Slot | `SlotBase` / `INodeInputSlot` / `INodeOutputSlot` | index on parent node | `SlotEntityId` |
|
|
| Reroute | `Reroute` | `RerouteId = number` | `RerouteEntityId` |
|
|
| Group | `LGraphGroup` | `number` | `GroupEntityId` |
|
|
|
|
Subgraphs are not a separate entity kind. A subgraph is a node with a `SubgraphStructure` component. See [Subgraph Boundaries and Widget Promotion](../architecture/subgraph-boundaries-and-promotion.md) for the full design rationale.
|
|
|
|
### Branded ID Design
|
|
|
|
Each entity kind gets a nominal/branded type wrapping its underlying primitive. The brand prevents accidental cross-kind usage at compile time while remaining structurally compatible with existing ID types:
|
|
|
|
```ts
|
|
type NodeEntityId = number & { readonly __brand: 'NodeEntityId' }
|
|
type LinkEntityId = number & { readonly __brand: 'LinkEntityId' }
|
|
type WidgetEntityId = number & { readonly __brand: 'WidgetEntityId' }
|
|
type SlotEntityId = number & { readonly __brand: 'SlotEntityId' }
|
|
type RerouteEntityId = number & { readonly __brand: 'RerouteEntityId' }
|
|
type GroupEntityId = number & { readonly __brand: 'GroupEntityId' }
|
|
|
|
// Scope identifier, not an entity ID
|
|
type GraphId = string & { readonly __brand: 'GraphId' }
|
|
```
|
|
|
|
Widgets and Slots currently lack independent IDs. The ECS will assign synthetic IDs at entity creation time via an auto-incrementing counter (matching the pattern used by `lastNodeId`, `lastLinkId`, etc. in `LGraphState`).
|
|
|
|
### Component Decomposition
|
|
|
|
Components are plain data objects — no methods, no back-references to parent entities. Systems query components to implement behavior.
|
|
|
|
#### Shared Components
|
|
|
|
- **Position** — `{ pos: Point }` — used by Node, Reroute, Group
|
|
- **Dimensions** — `{ size: Size, bounding: Rectangle }` — used by Node, Group
|
|
- **Visual** — rendering properties specific to each entity kind (separate interfaces, shared naming convention)
|
|
|
|
#### Node
|
|
|
|
| Component | Data (from `LGraphNode`) |
|
|
| ----------------- | --------------------------------------------------- |
|
|
| `Position` | `pos` |
|
|
| `Dimensions` | `size`, `_bounding` |
|
|
| `NodeVisual` | `color`, `bgcolor`, `boxcolor`, `title` |
|
|
| `NodeType` | `type`, `category`, `nodeData`, `description` |
|
|
| `Connectivity` | slot entity refs (replaces `inputs[]`, `outputs[]`) |
|
|
| `Execution` | `order`, `mode`, `flags` |
|
|
| `Properties` | `properties`, `properties_info` |
|
|
| `WidgetContainer` | widget entity refs (replaces `widgets[]`) |
|
|
|
|
#### Link
|
|
|
|
| Component | Data (from `LLink`) |
|
|
| --------------- | -------------------------------------------------------------- |
|
|
| `LinkEndpoints` | `origin_id`, `origin_slot`, `target_id`, `target_slot`, `type` |
|
|
| `LinkVisual` | `color`, `path`, `_pos` (center point) |
|
|
| `LinkState` | `_dragging`, `data` |
|
|
|
|
#### Subgraph (Node Components)
|
|
|
|
A node carrying a subgraph gains these additional components. Subgraphs are not a separate entity kind — see [Subgraph Boundaries](../architecture/subgraph-boundaries-and-promotion.md).
|
|
|
|
| Component | Data |
|
|
| ------------------- | ------------------------------------------------------------------------ |
|
|
| `SubgraphStructure` | `graphId`, typed interface (input/output names, types, slot entity refs) |
|
|
| `SubgraphMeta` | `name`, `description` |
|
|
|
|
#### Widget
|
|
|
|
| Component | Data (from `BaseWidget`) |
|
|
| ---------------- | ----------------------------------------------------------- |
|
|
| `WidgetIdentity` | `name`, `type` (widget type string), parent node entity ref |
|
|
| `WidgetValue` | `value`, `options`, `serialize` flags |
|
|
| `WidgetLayout` | `computedHeight`, layout size constraints |
|
|
|
|
#### Slot
|
|
|
|
| Component | Data (from `SlotBase` / `INodeInputSlot` / `INodeOutputSlot`) |
|
|
| ---------------- | ----------------------------------------------------------------------------------- |
|
|
| `SlotIdentity` | `name`, `type` (slot type), direction (`input` or `output`), parent node ref, index |
|
|
| `SlotConnection` | `link` (input) or `links[]` (output), `widget` locator |
|
|
| `SlotVisual` | `pos`, `boundingRect`, `color_on`, `color_off`, `shape` |
|
|
|
|
#### Reroute
|
|
|
|
| Component | Data (from `Reroute`) |
|
|
| --------------- | --------------------------------- |
|
|
| `Position` | `pos` (shared) |
|
|
| `RerouteLinks` | `parentId`, input/output link IDs |
|
|
| `RerouteVisual` | `color`, badge config |
|
|
|
|
#### Group
|
|
|
|
| Component | Data (from `LGraphGroup`) |
|
|
| --------------- | ----------------------------------- |
|
|
| `Position` | `pos` (shared) |
|
|
| `Dimensions` | `size`, `bounding` |
|
|
| `GroupMeta` | `title`, `font`, `font_size` |
|
|
| `GroupVisual` | `color` |
|
|
| `GroupChildren` | child entity refs (nodes, reroutes) |
|
|
|
|
### World
|
|
|
|
A central registry (the "World") maps entity IDs to their component sets. One
|
|
World exists per workflow instance, containing all entities across all nesting
|
|
levels. Each entity carries a `graphScope` identifier linking it to its
|
|
containing graph. The World also maintains a scope registry mapping each
|
|
`graphId` to its parent (or null for the root graph).
|
|
|
|
The "single source of truth" claim in this ADR is scoped to one workflow
|
|
instance. In a future linked-subgraph model, shared definitions can be loaded
|
|
into multiple workflow instances, but mutable runtime components
|
|
(`WidgetValue`, execution state, selection, transient layout caches) remain
|
|
instance-scoped unless explicitly declared shareable.
|
|
|
|
### Subgraph recursion model
|
|
|
|
The ECS model preserves recursive nesting without inheritance. A subgraph node
|
|
stores `SubgraphStructure.childGraphId`, and the scope registry stores
|
|
`childGraphId -> parentGraphId`. This forms a DAG that can represent arbitrary
|
|
subgraph depth.
|
|
|
|
Queries such as "all nodes at depth N" run by traversing the scope registry
|
|
from the root, materializing graph IDs at depth `N`, and then filtering entity
|
|
queries by `graphScope`.
|
|
|
|
### Systems (future work)
|
|
|
|
Systems are pure functions that query the World for entities with specific component combinations. Initial candidates:
|
|
|
|
- **RenderSystem** — queries `Position` + `Dimensions` (where present) + `*Visual` components
|
|
- **SerializationSystem** — queries all components to produce/consume workflow JSON
|
|
- **ExecutionSystem** — queries `Execution` + `Connectivity` to determine run order
|
|
- **LayoutSystem** — queries `Position` + `Dimensions` + structural components for auto-layout
|
|
- **SelectionSystem** — queries `Position` for point entities and `Position` + `Dimensions` for box hit-testing
|
|
|
|
System design is deferred to a future ADR.
|
|
|
|
### Migration Strategy
|
|
|
|
1. **Define types** — branded IDs, component interfaces, World type in a new `src/ecs/` directory
|
|
2. **Bridge layer** — adapter functions that read ECS components from existing class instances (zero-copy where possible)
|
|
3. **New features first** — any new cross-cutting feature (e.g., CRDT sync) builds on ECS components rather than class properties
|
|
4. **Incremental extraction** — migrate one component at a time from classes to the World, using the bridge layer for backward compatibility
|
|
5. **Deprecate class properties** — once all consumers read from the World, mark class properties as deprecated
|
|
|
|
### Relationship to ADR 0003 (Command Pattern / CRDT)
|
|
|
|
[ADR 0003](0003-crdt-based-layout-system.md) establishes that all mutations flow through serializable, idempotent commands. This ADR (0008) defines the entity data model and the World store. They are complementary architectural layers:
|
|
|
|
- **Commands** (ADR 0003) describe mutation intent — serializable objects that can be logged, replayed, sent over a wire, or undone.
|
|
- **Systems** (ADR 0008) are command handlers — they validate and execute mutations against the World.
|
|
- **The World** (ADR 0008) is the store — it holds component data. It does not know about commands.
|
|
|
|
The World's imperative API (`setComponent`, `deleteEntity`, etc.) is internal. External callers submit commands; the command executor wraps each in a World transaction. This is analogous to Redux: the store's internal mutation is imperative, but the public API is action-based.
|
|
|
|
For the full design showing how each lifecycle scenario maps to a command, see [World API and Command Layer](../architecture/ecs-world-command-api.md).
|
|
|
|
### Alternatives Considered
|
|
|
|
- **Refactoring classes in place**: Lower initial cost, but doesn't solve the cross-cutting concern problem. Each new feature still requires modifying multiple god objects.
|
|
- **Full rewrite**: Higher risk, blocks feature work during migration. The incremental approach avoids this.
|
|
- **Using an existing ECS library** (e.g., bitecs, miniplex): Adds a dependency for a domain that is specific to this project. The graph domain's component shapes don't align well with the dense numeric arrays favored by game-oriented ECS libraries. A lightweight, purpose-built approach is preferred.
|
|
|
|
## Consequences
|
|
|
|
### Positive
|
|
|
|
- Cross-cutting concerns (undo/redo, CRDT sync, serialization) can be implemented as systems without modifying entity classes
|
|
- Components are independently testable — no need to construct an entire `LGraphNode` to test position logic
|
|
- Branded IDs prevent a class of bugs where IDs are accidentally used across entity kinds
|
|
- The World provides a single source of truth for runtime entity state inside a workflow instance, simplifying debugging and state inspection
|
|
- Aligns with the CRDT layout system direction from ADR 0003
|
|
|
|
### Negative
|
|
|
|
- Additional indirection: reading a node's position requires a World lookup instead of `node.pos`
|
|
- Learning curve for contributors unfamiliar with ECS patterns
|
|
- Migration period where both OOP and ECS patterns coexist, increasing cognitive load
|
|
- Widgets and Slots need synthetic IDs, adding ID management complexity
|
|
|
|
### Render-Loop Performance Implications and Mitigations
|
|
|
|
Replacing direct property reads (`node.pos`) with component lookups (`world.getComponent(nodeId, Position)`) does add per-read overhead in the hot render path. In modern JS engines, hot `Map.get()` paths are heavily optimized and are often within a low constant factor of object property reads, but this ADR treats render-loop cost as a first-class risk rather than assuming it is free.
|
|
|
|
Planned mitigations for the ECS render path:
|
|
|
|
1. Pre-collect render queries into frame-stable caches (`visibleNodeIds`, `visibleLinkIds`, and resolved component references) and rebuild only on topology/layout dirty signals, not on every draw call.
|
|
2. Keep archetype-style buckets for common render signatures (for example: `Node = Position+Dimensions+NodeVisual`, `Reroute = Position+RerouteVisual`) so systems iterate arrays instead of probing unrelated entities.
|
|
3. Allow a hot-path storage upgrade behind the World API (for example, SoA-style typed arrays for `Position` and `Dimensions`) if profiling shows `Map.get()` dominates frame time.
|
|
4. Gate migration of each render concern with profiling parity checks against the legacy path (same workflow, same viewport, same frame budget).
|
|
5. Treat parity as a release gate: ECS render path must stay within agreed frame-time budgets (for example, no statistically significant regression in p95 frame time on representative 200-node and 500-node workflows).
|
|
|
|
The design goal is to preserve ECS modularity while keeping render throughput within existing frame-time budgets.
|
|
|
|
## Notes
|
|
|
|
- The 25+ widget types (`BooleanWidget`, `NumberWidget`, `ComboWidget`, etc.) will share the same ECS component schema. Widget-type-specific behavior lives in systems, not in component data.
|
|
- Subgraphs are not a separate entity kind. A `GraphId` scope identifier (branded `string`) tracks which graph an entity belongs to. The scope DAG must be acyclic — see [Subgraph Boundaries](../architecture/subgraph-boundaries-and-promotion.md).
|
|
- The existing `LGraphState.lastNodeId` / `lastLinkId` / `lastRerouteId` counters extend naturally to `lastWidgetId` and `lastSlotId`.
|
|
- The internal ECS model and the serialization format are deliberately separate concerns. The `SerializationSystem` translates between the flat World and the nested serialization format. Backward-compatible loading of all prior workflow formats is a hard, indefinite constraint.
|