Compare commits

...

13 Commits

Author SHA1 Message Date
DrJKL
7b906c30ad test(nodeDisplay): convert unknown converted widget test to vue-nodes
Replace canvas-wide screenshot with DOM assertions on title, inputs, and
the converted-widget marker, plus a per-node screenshot. Localizes any
future regression to this specific node instead of a faceless ~763 px
canvas diff.

Amp-Thread-ID: https://ampcode.com/threads/T-019df559-8de5-7245-bd4c-09d620849be6
Co-authored-by: Amp <amp@ampcode.com>
2026-05-04 16:49:23 -07:00
Alexander Brown
858fe23859 Merge branch 'main' into drjkl/world-consolidation-phase2 2026-05-04 13:59:50 -07:00
DrJKL
e364d69c4d refactor(world): move widget types to src/world/widgets/
- Move WidgetState type from widgetValueStore.ts to src/world/widgets/widgetState.ts
- Move widget component-key definitions to src/world/widgets/widgetComponents.ts
- Derive component bucket shapes from IBaseWidget via Pick<> instead of hand-rolling
- Re-export WidgetState from widgetValueStore for back-compat
- Convert slot from arrow expression to function declaration

Amp-Thread-ID: https://ampcode.com/threads/T-019de012-e72a-7218-8fd7-d48dc7222960
Co-authored-by: Amp <amp@ampcode.com>
2026-04-30 14:58:23 -07:00
DrJKL
01d4192f79 chore: drop unused exports flagged by knip
Amp-Thread-ID: https://ampcode.com/threads/T-019de014-4726-7527-a52a-766e2f2eaf22
Co-authored-by: Amp <amp@ampcode.com>
2026-04-30 14:06:07 -07:00
DrJKL
0e206118d3 refactor(world): split WidgetValue into per-aspect components
- Replace WidgetValueComponent with four components (Value, Display,
  Schema, Serialize) defined via new defineComponentKeys factory.
- Add slot() phantom-typed helper so factory can recover TData/TEntity
  per slot and emit string-literal-typed names.
- widgetValueStore now stores per-aspect data and returns delegating
  WidgetState views built by buildView(); identity is no longer
  preserved across getWidget calls (data round-trips, identity does not).
- Add WidgetRegistration input shape (carries name/nodeId for id
  construction) distinct from the returned WidgetState view.
- Add getNodeWidgetsByName() that derives names from WidgetEntityId via
  new parseWidgetEntityId() helper.
- entityIds: add parseWidgetEntityId, isNodeIdForGraph,
  isWidgetIdForGraph; clearGraph and stores use the predicates instead
  of duplicating prefix logic.
- world: introduce getBucket/getOrCreateBucket internals, key buckets
  by ComponentKey reference identity (documented), confine the
  existential cast to a single eraseKey boundary.
- Update tests for componentKey factory, entityIds parsers, world
  bucket semantics, widgetValueStore view semantics, and adjust
  BaseWidget/useUpstreamValue/useImageCrop tests accordingly.
- docs: refresh ECS pattern survey appendix to reflect new layout.

Amp-Thread-ID: https://ampcode.com/threads/T-019de010-ead4-7627-9552-3d44d7a46726
Co-authored-by: Amp <amp@ampcode.com>
2026-04-30 13:26:05 -07:00
DrJKL
9b0e47f72d fix: address World substrate review feedback
- shallowReactive outer registry so first-bucket creation is observable
- dev-mode collision guard for ComponentKey names
- drop redundant `as string` casts in widgetValueStore.clearGraph
- rename misleading reactive-bridging test, add stable-proxy invariant test
- reword identity claims to match actual reactive(Map) proxy semantics

Amp-Thread-ID: https://ampcode.com/threads/T-019dd61d-7103-737b-8dfb-be8cc784fc2d
Co-authored-by: Amp <amp@ampcode.com>
2026-04-28 16:27:14 -07:00
DrJKL
137b3664e2 refactor(world): trim verbose comments to intent only
Amp-Thread-ID: https://ampcode.com/threads/T-019dd597-b3a9-7699-88ac-901574007111
Co-authored-by: Amp <amp@ampcode.com>
2026-04-28 13:51:03 -07:00
DrJKL
20cbe7c83a refactor(world): drop widgetParent, centralize entity-id format
Amp-Thread-ID: https://ampcode.com/threads/T-019dd5d5-2c38-771e-a5c9-f827d981db7d
Co-authored-by: Amp <amp@ampcode.com>
2026-04-28 13:44:41 -07:00
DrJKL
d45f13cf61 docs: add ECS pattern survey appendix
Captures the bitECS / miniplex / koota / ECSY / Bevy survey from the
world-consolidation analysis: structural patterns adopted (substrate-deep
with domain-colocated components, small public API, reactive bridging via
reactive(Map), brand-typed entity IDs), patterns explicitly rejected
(replace-on-write, SoA/archetype storage, opaque entity IDs, substrate-side
parent/child relations), and revisit thresholds.

Cross-links from ADR 0008 Supporting Documents table.

Amp-Thread-ID: https://ampcode.com/threads/T-019dd18c-2255-702a-9a0c-851d10fcd420
Co-authored-by: Amp <amp@ampcode.com>
2026-04-27 17:54:22 -07:00
DrJKL
36a9b6467c refactor(world): delete bridge, revert BaseWidget/useUpstreamValue
Phase 2b of temp/plans/world-consolidation.md (completes Strategy A
bridge elimination begun in Phase 2a).

Now that useWidgetValueStore is a World facade (Phase 2a), the bridge
is redundant.

Deletions:
- src/world/widgetWorldBridge.ts + widgetWorldBridge.test.ts deleted.
  Their register/getNodeWidgets coverage rolled into widgetValueStore
  facade tests in Phase 2a; widgetParent tests already moved to
  src/stores/widgetComponents.test.ts in Phase 1.

Reverts (back to pre-slice-1 baseline):
- BaseWidget.setNodeId no longer double-writes via
  registerWidgetInWorld; the store IS the World writer now. Drops
  three @/world/* imports.
- useUpstreamValue reads via useWidgetValueStore().getNodeWidgets().
  Drops three @/world/* imports.

Test updates:
- useUpstreamValue.test.ts setup uses
  useWidgetValueStore().registerWidget instead of
  registerWidgetInWorld(getWorld(), ...). Hoists
  setActivePinia(createTestingPinia) + resetWorldInstance into
  beforeEach.
- widgetComponents.test.ts setup inlines a 4-line widget registration
  to replace the deleted bridge import.
- entityIds.ts: GraphId type un-exported (no external consumer; YAGNI;
  re-export when slice 2 needs it).

End state:
- src/world/ is pure substrate (5 source files: brand, entityIds,
  componentKey, world, worldInstance). No bridge, no components dir.
- BaseWidget.ts byte-identical with pre-slice-1 form at the
  setNodeId seam.
- useWidgetValueStore is the sole owner of widget value state;
  Vue tracking flows naturally through reactive(Map) inside the World.

Verification:
- pnpm typecheck, format:check, knip clean.
- 52 tests pass across src/world, src/stores/widgetValueStore,
  src/stores/widgetComponents, src/composables/useUpstreamValue,
  src/lib/litegraph/src/widgets/BaseWidget.
- rg "@/world" src/lib/litegraph/src/widgets/BaseWidget.ts returns 0.
- rg "@/world" src/composables/useUpstreamValue.ts returns 0.
- rg "registerWidgetInWorld|getNodeWidgetsThroughWorld|widgetWorldBridge" src/ returns 0.

Combined Phase 2 non-test diff -53 LOC (under 150 LOC budget).
BaseWidget._state shared reactive identity contract preserved end-to-end.

Amp-Thread-ID: https://ampcode.com/threads/T-019dd146-a3ad-734d-9825-0ab356454dd5
Co-authored-by: Amp <amp@ampcode.com>
2026-04-27 17:15:15 -07:00
DrJKL
8523a523d8 refactor(widgetValueStore): rewrite as World facade
Phase 2a of temp/plans/world-consolidation.md (split before bridge
deletion lands in 2b).

The store no longer owns its own ref(Map) of widget states; all reads
and writes delegate to the module-singleton World via getWorld().
Public API (registerWidget, getWidget, getNodeWidgets, clearGraph)
is unchanged. WidgetState interface and stripGraphPrefix helper are
preserved byte-identical.

Critical: getWorld() is called inside each action, NOT once in the
defineStore factory. Otherwise resetWorldInstance() in tests would
leave the store bound to a dead world. Regression test added.

Reactive-identity contract preserved: registerWidget returns the same
reference that store.getWidget and world.getComponent return on
subsequent reads. BaseWidget._state shared identity (the 40+ extension
ecosystem dependency) continues to hold end-to-end through this rewrite.

New tests:
- registerWidget === getWidget (reactive identity)
- store.getWidget === world.getComponent (two-way bridge identity)
- Vue computed reading through World observes mutations via the store
- resetWorldInstance regression: register -> reset -> register lands
  in the new world

All 12 existing widgetValueStore tests still pass against the new
facade. Total: 16 tests in widgetValueStore.test.ts.

Amp-Thread-ID: https://ampcode.com/threads/T-019dd146-a3ad-734d-9825-0ab356454dd5
Co-authored-by: Amp <amp@ampcode.com>
2026-04-27 17:00:18 -07:00
DrJKL
ec472d3e54 refactor(world): collapse substrate, colocate domain components
Phase 1 of temp/plans/world-consolidation.md.

Substrate-internal cleanup (src/world/world.ts):
- F2: inline InternalStore wrapper as closure-captured Map.
- F3: delete World.hasComponent (zero non-test consumers; tests collapse
  to expect(getComponent(...)).toBeUndefined()).
- F4: entitiesWith returns TEntity[] (snapshot) instead of generator.
  Makes Phase 2's clearGraph mutate-while-iterate inherently safe.
- D.1: add load-bearing SoA/AoS contract doc-comment to world.ts.
- D.2: add load-bearing deterministic-ID doc-comment to entityIds.ts.

Domain-component relocation (src/stores/widgetComponents.ts):
- S1: delete speculative WidgetIdentity, WidgetDisplayState, WidgetSchema.
- S2: drop <T = unknown> generic on WidgetValue (already discarded at
  component-key boundary).
- F1: move WidgetValueComponent, WidgetContainerComponent, widgetParent
  reverse-lookup into src/stores/widgetComponents.ts. Delete the entire
  src/world/components/ directory and src/world/worldIndex.ts.
- B1: delete unregisterWidgetInWorld (zero non-test consumers; Phase 2
  facade does not reintroduce one).

Industry ECS convention (bitECS, miniplex, koota, Bevy plugins) and
AGENTS.md DDD guidance both place components with the domain code that
owns them, not in the substrate.

End state: 6 substrate files, no components/ folder. Net non-test
diff -58 LOC (well under the 120 LOC Phase 1 budget).

Verification:
- pnpm typecheck, format:check clean.
- 54 tests pass across src/world, src/stores/widgetComponents,
  src/stores/widgetValueStore, src/composables/useUpstreamValue,
  src/lib/litegraph/src/widgets/BaseWidget.

Co-authored-by: Amp <amp@ampcode.com>
Amp-Thread-ID: https://ampcode.com/threads/T-019dd146-a3ad-734d-9825-0ab356454dd5
2026-04-27 16:52:34 -07:00
DrJKL
b35d1f3b58 feat(world): slice 1 - World substrate + WidgetValue bridge
Adds minimal ECS substrate (src/world/) per ADR 0008 and bridges into
useWidgetValueStore via widgetWorldBridge. setNodeId now writes the same
reactive _state into both the Pinia store and World, preserving shared
reactive identity for the 40+ extension ecosystem.

Subsystems added:
- src/world/{world,worldInstance,componentKey,brand,entityIds,worldIndex}
- src/world/components/{WidgetValue,WidgetContainer,WidgetIdentity,WidgetDisplayState,WidgetSchema}
- src/world/widgetWorldBridge

Consumers updated:
- BaseWidget.setNodeId: registers in World after store registration
- useUpstreamValue: reads via getNodeWidgetsThroughWorld

Cross-subgraph identity: widgetEntityId(rootGraphId, nodeId, name).

Amp-Thread-ID: https://ampcode.com/threads/T-019dd146-a3ad-734d-9825-0ab356454dd5
Co-authored-by: Amp <amp@ampcode.com>
2026-04-27 16:31:59 -07:00
20 changed files with 1489 additions and 146 deletions

View File

@@ -77,14 +77,42 @@ test.describe('Optional input', { tag: ['@screenshot', '@node'] }, () => {
await expect(comfyPage.canvas).toHaveScreenshot('simple_slider.png')
})
test('unknown converted widget', async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow(
'missing/missing_nodes_converted_widget'
)
await expect(comfyPage.canvas).toHaveScreenshot(
'missing_nodes_converted_widget.png'
)
})
test(
'unknown converted widget',
{ tag: ['@vue-nodes'] },
async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow(
'missing/missing_nodes_converted_widget'
)
const node = comfyPage.vueNodes.getNodeLocator('1')
await expect(node).toBeVisible()
// Title reflects the unknown node type
await expect(node).toContainText('UNKNOWN NODE')
// Inputs include the regular IMAGE input and the converted-widget "foo"
// (which is what differentiates this fixture from a plain missing node).
const nodeRef = await comfyPage.nodeOps.getNodeRefById('1')
const inputs = (await nodeRef.getProperty('inputs')) as {
name: string
type: string
widget?: { name: string }
}[]
expect(inputs.map((i) => i.name)).toEqual(['image', 'foo'])
const fooInput = inputs.find((i) => i.name === 'foo')
expect(fooInput?.type).toBe('STRING')
expect(fooInput?.widget?.name).toBe('foo')
// Per-node DOM screenshot localizes any visual regression to this node.
// A canvas-wide screenshot turns a 1-row label drift into a "763 px diff"
// with no signal about which widget is responsible.
await expect(node).toHaveScreenshot(
'missing_nodes_converted_widget_node.png'
)
}
)
test('dynamically added input', async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow('inputs/dynamically_added_input')

View File

@@ -239,18 +239,19 @@ The design goal is to preserve ECS modularity while keeping render throughput wi
Companion architecture documents that expand on the design in this ADR:
| Document | Description |
| ------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------- |
| [Entity Interactions](../architecture/entity-interactions.md) | Maps all current entity relationships and interaction patterns — the ECS migration baseline |
| [Entity System Structural Problems](../architecture/entity-problems.md) | Detailed problem catalog with line-level code references motivating the ECS migration |
| [Proto-ECS Stores](../architecture/proto-ecs-stores.md) | Inventory of existing Pinia stores that already partially implement ECS patterns |
| [ECS Target Architecture](../architecture/ecs-target-architecture.md) | Full target architecture showing how entities and interactions transform under ECS |
| [ECS Migration Plan](../architecture/ecs-migration-plan.md) | Phased migration roadmap with shipping milestones and go/no-go criteria |
| [ECS Lifecycle Scenarios](../architecture/ecs-lifecycle-scenarios.md) | Before/after walkthroughs of lifecycle operations (node removal, link creation, etc.) |
| [World API and Command Layer](../architecture/ecs-world-command-api.md) | How each lifecycle scenario maps to a command in the World API |
| [Subgraph Boundaries and Widget Promotion](../architecture/subgraph-boundaries-and-promotion.md) | Design rationale for modeling subgraphs as node components, not separate entities |
| [Appendix: Critical Analysis](../architecture/appendix-critical-analysis.md) | Independent verification of the accuracy of the architecture documents |
| [Change Tracker](../architecture/change-tracker.md) | Documents the current undo/redo system that ECS cross-cutting concerns will replace |
| Document | Description |
| ------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------- |
| [Entity Interactions](../architecture/entity-interactions.md) | Maps all current entity relationships and interaction patterns — the ECS migration baseline |
| [Entity System Structural Problems](../architecture/entity-problems.md) | Detailed problem catalog with line-level code references motivating the ECS migration |
| [Proto-ECS Stores](../architecture/proto-ecs-stores.md) | Inventory of existing Pinia stores that already partially implement ECS patterns |
| [ECS Target Architecture](../architecture/ecs-target-architecture.md) | Full target architecture showing how entities and interactions transform under ECS |
| [ECS Migration Plan](../architecture/ecs-migration-plan.md) | Phased migration roadmap with shipping milestones and go/no-go criteria |
| [ECS Lifecycle Scenarios](../architecture/ecs-lifecycle-scenarios.md) | Before/after walkthroughs of lifecycle operations (node removal, link creation, etc.) |
| [World API and Command Layer](../architecture/ecs-world-command-api.md) | How each lifecycle scenario maps to a command in the World API |
| [Subgraph Boundaries and Widget Promotion](../architecture/subgraph-boundaries-and-promotion.md) | Design rationale for modeling subgraphs as node components, not separate entities |
| [Appendix: Critical Analysis](../architecture/appendix-critical-analysis.md) | Independent verification of the accuracy of the architecture documents |
| [Appendix: ECS Pattern Survey](../architecture/appendix-ecs-pattern-survey.md) | Survey of bitECS, miniplex, koota, ECSY, and Bevy — patterns adopted, departed, when to revisit |
| [Change Tracker](../architecture/change-tracker.md) | Documents the current undo/redo system that ECS cross-cutting concerns will replace |
## Notes

View File

@@ -0,0 +1,279 @@
# Appendix: ECS Pattern Survey
_A survey of mainstream Entity Component System libraries — bitECS, miniplex,
koota, ECSY, and Bevy — captured during the world-consolidation analysis that
shipped slice 1 of [ADR 0008](../adr/0008-entity-component-system.md). This
appendix records which structural patterns our `src/world/` substrate adopts,
which it deliberately departs from, and where the trade-offs are load-bearing
rather than incidental._
The in-code anchors for the load-bearing constraints discussed below are the
doc-comments in [src/world/world.ts](../../src/world/world.ts) (storage
strategy) and [src/world/entityIds.ts](../../src/world/entityIds.ts) (identity
contract) — see §3 below.
---
## 1. Survey Comparison
Five libraries were sampled for structural patterns: where component
definitions live relative to the substrate, how components are declared,
how entities are identified, and roughly how large the substrate's public
surface is. Sources: the linked READMEs and docs.
| Library | Component placement | Component definition style | Entity ID type | Approx. # core exports |
| ------------------------------------------------- | ------------------------------------ | ----------------------------- | -------------------- | ----------------------: |
| [bitECS](https://github.com/NateTheGreatt/bitECS) | Outside the substrate; user's choice | plain arrays / objects | `number` (unbranded) | ~12 |
| [miniplex](https://github.com/hmans/miniplex) | Colocated with the `Entity` type | properties on a TS type | plain object ref | ~5 |
| [koota](https://github.com/pmndrs/koota) | Colocated with the consumer | `trait({...})` factory | numeric `.id()` | ~15 (core) + ~8 (react) |
| [ECSY](https://github.com/ecsyjs/ecsy) | User's choice | `class extends Component` | `Entity` object | ~10 |
| [Bevy](https://bevyengine.org/) (Rust, for shape) | Plugin-owned (industry std) | `#[derive(Component)] struct` | `Entity(u64)` | n/a |
Two structural patterns are unanimous across the surveyed libraries:
1. **Component definitions live with the code that owns the data**, not
inside the substrate package. Whether by explicit recommendation
(Bevy plugins, koota's colocation guidance) or by default (bitECS,
miniplex), no surveyed substrate ships pre-defined component types.
2. **Substrate surface area is small** — bitECS at ~12 exports, koota at
~15, miniplex at ~5. ECSY is the outlier with a wider class hierarchy.
Our slice-1 end state — five source files under
[src/world/](../../src/world/), ~14 exported names total — sits squarely in
this band.
---
## 2. Patterns We Adopt
### 2.1 Substrate is deep; components live in domain code
The mainstream convention is that the ECS substrate exposes only the
machinery — entities, component keys, a World — and component definitions
live next to the system, store, or feature module that owns the data.
This is the Bevy / miniplex / koota convention by design and the bitECS /
ECSY convention by default.
Our substrate follows the same shape: `src/world/` contains entity-ID
brands, the `ComponentKey` definition primitive, and the `World`
interface, but no domain-specific component types. Slice 1 places
`WidgetValueComponent` and `WidgetContainerComponent` in
[src/stores/widgetComponents.ts](../../src/stores/widgetComponents.ts),
next to [widgetValueStore.ts](../../src/stores/widgetValueStore.ts) — the
module that already owns widget value state.
This keeps the substrate / domain seam crisp: the World knows how to store
and look up arbitrary components keyed by entity ID; the domain layer
knows what a "widget value" is. It also aligns with the AGENTS.md DDD
guidance to group code by bounded context. Future components follow the
same rule — `PositionComponent`, when it lands, will live with the layout
domain rather than inside the substrate.
### 2.2 Small public API
The substrate exports ~14 names — comparable to bitECS (~12) and koota
(~15), much smaller than ECSY's class hierarchy. This is a deliberate
target: every exported name is a contract a contributor must understand
before extending the World, and every export is a potential migration
cost when the substrate evolves.
The `Brand` / `EntityId` / `ComponentKey` / `World` / `worldInstance`
split keeps each module single-purpose. `Brand<T,Tag>` is 5
LOC and shared across all branded ID kinds. `ComponentKey<TData,TEntity>`
carries a two-parameter phantom that enables cross-kind compile-time
checking. `asGraphId` is a single named boundary cast. The two explicit
factories `nodeEntityId` / `widgetEntityId` are kept rather than collapsed
into a parameterized helper because slice 2/3/4 will add factories with
different parameter tuples (`rerouteEntityId`, `linkEntityId`,
`slotEntityId`); the explicit-factory pattern scales linearly with new
entity kinds without growing the helper's signature.
### 2.3 Reactive bridging via existing storage proxy
bitECS, koota, and miniplex bolt on a separate `onChange` event bus when
a consumer wants reactive notifications. koota's React layer
(`useTrait(entity, ComponentKey)`) is the closest analog to what
`useUpstreamValue` and future composables want.
Because our World stores values inside Vue's `reactive(Map<EntityId, ...>)`,
a plain `computed(() => world.getComponent(id, key))` already provides
fine-grained per-`(entity, component)` tracking — no separate event bus
is needed. **This is a real Vue-specific advantage.** The Vue tracker and
the ECS storage are the same mechanism, so reactivity falls out of the
storage choice rather than being layered on top.
### 2.4 Brand-typed entity IDs
No surveyed TypeScript ECS uses branded IDs. bitECS uses unbranded
`number`, miniplex uses plain object references, koota uses a numeric
`.id()`. Our `Brand<T, Tag>` over each entity kind enables the
type-level cross-kind isolation assertion in
[world.test.ts](../../src/world/world.test.ts) and documents slice-2/3/4
entity kinds at compile time.
This is a deliberate departure rather than an accident. It earns its keep
once `Position` lands on `NodeEntityId | RerouteEntityId` (slice 2) and
`Connectivity` lands on `SlotEntityId` (slice 4); without brands, those
component-key declarations would accept any numeric ID and silently allow
cross-kind misuse.
---
## 3. Patterns We Explicitly Do NOT Adopt
Each of the following is a real industry idiom we considered and rejected
on load-bearing grounds. None of these are pure performance trade-offs.
### 3.1 Replace-on-write usage idioms
koota's `entity.set(Position, {...})` and miniplex's `world.add(entity)`
**replace** component values with new objects on each write. Adopting
either would break
[BaseWidget.\_state](../../src/lib/litegraph/src/widgets/BaseWidget.ts)
shared reactive identity — the contract that lets DOM widget overrides,
`useProcessedWidgets` memoization, and the 40+ extension ecosystem all
read the same proxy. Our `setComponent(id, key, ref)` stores by reference
and the inner `reactive(Map)` keeps a stable cached proxy per
entity-component pair: every `getComponent` returns the same proxy,
regardless of how many writes intervene. `widgetValueStore.registerWidget`
returns that proxy (not the caller's input ref), so `BaseWidget._state`
and every other reader observe the same object. Replace-on-write idioms
would swap the cached proxy on each write and break that stability —
the reactive-identity test in
[widgetValueStore.test.ts](../../src/stores/widgetValueStore.test.ts)
locks in the contract.
### 3.2 SoA / archetype storage
bitECS, koota, and miniplex use sparse-set / archetype storage internally
for cache locality. Our `reactive(Map<EntityId, unknown>)` is closer to
ECSY's AoS — slower iteration but **integrates natively with Vue's
tracking**.
The surface trade-off is performance; the deeper trade-off is identity.
SoA storage spreads each component's fields across parallel typed arrays,
so the per-entity "row object" is reconstructed on read. **A future
migration to SoA would lose the proxy on the row object** — and with it
the shared-reactive-identity contract that `BaseWidget._state` and the
`widgetValueStore` facade rely on. This is a load-bearing constraint, not
just a perf optimization decision.
The contract is pinned in the doc-comment at the top of
[src/world/world.ts](../../src/world/world.ts) — copied here for
proximity:
```ts
/**
* `setComponent` stores values by reference (no clone). The inner
* `reactive(Map)` produces a single cached Vue proxy per entity-component
* pair: every `getComponent` call returns the same proxy, and mutations
* through it propagate to all readers. Note that the proxy is NOT `===`
* to the raw object passed to `setComponent` — read through `getComponent`
* (or a `registerWidget`-style helper that does so internally) and treat
* that proxy as canonical.
*
* `BaseWidget._state` and `widgetValueStore` rely on this stable-proxy
* invariant. Replace-on-write idioms (koota's `entity.set(...)`,
* miniplex's `world.add(entity)`) would swap the cached proxy on each
* write and break the contract; revisiting either consumer is required
* before changing storage semantics.
*/
```
### 3.3 Auto-generated opaque entity IDs
bitECS and koota assume IDs are opaque numbers — `lastId++`, with no
external structure. miniplex uses plain object references with the same
property.
Our `widgetEntityId(rootGraphId, nodeId, name)` is **deterministic and
content-addressed**. Consumers consistently pass `rootGraph.id`, so a
widget viewed at different subgraph depths shares identity with itself.
Migrating to opaque numeric IDs would break cross-subgraph value sharing —
the same widget at depth 0 and depth 2 would receive different IDs and
diverge.
The contract is pinned in the doc-comment at the top of
[src/world/entityIds.ts](../../src/world/entityIds.ts):
```ts
/**
* Entity IDs are deterministic, content-addressed, and string-prefix
* encoded — NOT opaque numeric IDs (cf. bitECS, koota, miniplex).
*
* `widgetEntityId(rootGraphId, nodeId, name)` is load-bearing:
* consumers consistently pass `rootGraph.id` so widgets viewed at
* different subgraph depths share identity. Migrating to numeric IDs
* would break cross-subgraph value sharing. See ADR 0008 and
* widgetValueStore for the canonical keying contract.
*/
```
### 3.4 Substrate-side parent/child relations
Bevy ships `Parent` / `Children` components at the substrate layer; Flecs
ships first-class relations. These are useful when many subsystems need
hierarchical traversal at storage-near speeds.
We treat hierarchical traversal as a domain-layer concern instead. The
only structural relation slice 1 needs is `node → widgets` forward
lookup, expressed as a domain component (`WidgetContainer.widgetIds` in
[src/stores/widgetComponents.ts](../../src/stores/widgetComponents.ts))
and surfaced through `getNodeWidgets()` on the
[widget value store](../../src/stores/widgetValueStore.ts). Reverse
`widget → node` lookup is not modeled in the World at all today —
existing call sites already hold a widget object and read `widget.node`
directly via the `BaseWidget` back-reference, so no substrate-side
parent component earns its keep yet. We may revisit this if multiple
slices need a shared traversal API; until then, keeping hierarchy
domain-local preserves the substrate's "no domain knowledge" property.
---
## 4. When to Revisit
The choices in §3 are deliberate but not eternal. Each has a revisit
threshold.
**SoA / archetype storage.** The break-even point against `reactive(Map)`
iteration is roughly **>10k entities per component** in steady-state hot
paths. ComfyUI's projected widget count through slice 4 stays well under
that. The watch signal is whether a render-loop or solver-loop pass
demonstrably dominates frame time on `entitiesWith(WidgetValueComponent)`
or any successor query — not just micro-benchmarks of `Map.get`.
If we cross that threshold, the migration is non-trivial: SoA loses the
proxy on the row object (see §3.2), so a SoA World must either
reconstruct proxies on read (defeating the perf gain) or move
shared-identity reads back to a domain-side cache. ADR 0008's
"Render-Loop Performance Implications and Mitigations" section already
enumerates the planned mitigations (frame-stable query caches, archetype
buckets, profiling-gated storage upgrades behind the World API).
**Replace-on-write idioms.** Revisitable only if the 40+ extension
ecosystem moves off `BaseWidget._state` shared identity entirely — a
separate, larger slice with explicit cost analysis (re-entry, DOM widget
options.getValue overrides, `linkedWidgets` fan-out,
`useProcessedWidgets` memoization invalidation), out of scope for the
current ADR 0008 implementation.
**Opaque entity IDs.** Revisitable only if the cross-subgraph identity
contract is dropped. Today widget value sharing across subgraph depths
depends on it; slice 2 may extend the same contract to `nodeEntityId`
for spatial reads. Until the product requirement changes, opaque IDs
would be a regression.
**Substrate-side parent/child relations.** Revisitable when ≥2 subsystems
need parent traversal. At one consumer it stays domain-local.
---
## 5. Cross-References
- [ADR 0008 — Entity Component System](../adr/0008-entity-component-system.md)
for the full target taxonomy and migration strategy.
- [ECS Target Architecture](./ecs-target-architecture.md) for the full
end-state shape.
- [ECS Migration Plan](./ecs-migration-plan.md) for shipping milestones.
- [Appendix: Critical Analysis](./appendix-critical-analysis.md) for the
independent verification of the architecture documents.

View File

@@ -66,7 +66,8 @@ vi.mock('@/renderer/core/canvas/canvasStore', () => ({
vi.mock('@/stores/widgetValueStore', () => ({
useWidgetValueStore: () => ({
getNodeWidgets: vi.fn(() => [])
getNodeWidgets: vi.fn(() => []),
getNodeWidgetsByName: vi.fn(() => new Map())
})
}))

View File

@@ -1,12 +1,42 @@
import { describe, expect, it } from 'vitest'
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { NodeId } from '@/lib/litegraph/src/LGraphNode'
import type { UUID } from '@/lib/litegraph/src/utils/uuid'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import type { WidgetState } from '@/stores/widgetValueStore'
import type { NodeId } from '@/platform/workflow/validation/schemas/workflowSchema'
import { resetWorldInstance } from '@/world/worldInstance'
import { boundsExtractor, singleValueExtractor } from './useUpstreamValue'
import {
boundsExtractor,
singleValueExtractor,
useUpstreamValue
} from './useUpstreamValue'
function widget(name: string, value: unknown): WidgetState {
return { name, type: 'INPUT', value, nodeId: '1' as NodeId, options: {} }
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
useCanvasStore: () => ({
canvas: {
graph: { rootGraph: { id: '00000000-0000-0000-0000-000000000001' } }
}
})
}))
function widgetState(value: unknown): WidgetState {
return {
type: 'INPUT',
value,
options: {},
label: undefined,
serialize: undefined,
disabled: undefined
}
}
function widgetMap(
...entries: Array<[string, unknown]>
): Map<string, WidgetState> {
return new Map(entries.map(([name, value]) => [name, widgetState(value)]))
}
const isNumber = (v: unknown): v is number => typeof v === 'number'
@@ -15,37 +45,37 @@ describe('singleValueExtractor', () => {
const extract = singleValueExtractor(isNumber)
it('matches widget by outputName', () => {
const widgets = [widget('a', 'text'), widget('b', 42)]
const widgets = widgetMap(['a', 'text'], ['b', 42])
expect(extract(widgets, 'b')).toBe(42)
})
it('returns undefined when outputName widget has invalid value', () => {
const widgets = [widget('a', 'text'), widget('b', 'not a number')]
const widgets = widgetMap(['a', 'text'], ['b', 'not a number'])
expect(extract(widgets, 'b')).toBeUndefined()
})
it('falls back to unique valid widget when outputName has no match', () => {
const widgets = [widget('a', 'text'), widget('b', 42)]
const widgets = widgetMap(['a', 'text'], ['b', 42])
expect(extract(widgets, 'missing')).toBe(42)
})
it('falls back to unique valid widget when no outputName provided', () => {
const widgets = [widget('a', 'text'), widget('b', 42)]
const widgets = widgetMap(['a', 'text'], ['b', 42])
expect(extract(widgets, undefined)).toBe(42)
})
it('returns undefined when multiple widgets have valid values', () => {
const widgets = [widget('a', 1), widget('b', 2)]
const widgets = widgetMap(['a', 1], ['b', 2])
expect(extract(widgets, undefined)).toBeUndefined()
})
it('returns undefined when no widgets have valid values', () => {
const widgets = [widget('a', 'text')]
const widgets = widgetMap(['a', 'text'])
expect(extract(widgets, undefined)).toBeUndefined()
})
it('returns undefined for empty widgets', () => {
expect(extract([], undefined)).toBeUndefined()
expect(extract(new Map(), undefined)).toBeUndefined()
})
})
@@ -54,23 +84,23 @@ describe('boundsExtractor', () => {
it('extracts a single bounds object widget', () => {
const bounds = { x: 10, y: 20, width: 100, height: 200 }
const widgets = [widget('crop', bounds)]
const widgets = widgetMap(['crop', bounds])
expect(extract(widgets, undefined)).toEqual(bounds)
})
it('matches bounds widget by outputName', () => {
const bounds = { x: 1, y: 2, width: 3, height: 4 }
const widgets = [widget('other', 'text'), widget('crop', bounds)]
const widgets = widgetMap(['other', 'text'], ['crop', bounds])
expect(extract(widgets, 'crop')).toEqual(bounds)
})
it('assembles bounds from individual x/y/width/height widgets', () => {
const widgets = [
widget('x', 10),
widget('y', 20),
widget('width', 100),
widget('height', 200)
]
const widgets = widgetMap(
['x', 10],
['y', 20],
['width', 100],
['height', 200]
)
expect(extract(widgets, undefined)).toEqual({
x: 10,
y: 20,
@@ -80,39 +110,74 @@ describe('boundsExtractor', () => {
})
it('returns undefined when some bound components are missing', () => {
const widgets = [widget('x', 10), widget('y', 20), widget('width', 100)]
const widgets = widgetMap(['x', 10], ['y', 20], ['width', 100])
expect(extract(widgets, undefined)).toBeUndefined()
})
it('returns undefined when bound components have wrong types', () => {
const widgets = [
widget('x', '10'),
widget('y', 20),
widget('width', 100),
widget('height', 200)
]
const widgets = widgetMap(
['x', '10'],
['y', 20],
['width', 100],
['height', 200]
)
expect(extract(widgets, undefined)).toBeUndefined()
})
it('returns undefined for empty widgets', () => {
expect(extract([], undefined)).toBeUndefined()
expect(extract(new Map(), undefined)).toBeUndefined()
})
it('rejects partial bounds objects', () => {
const partial = { x: 10, y: 20 }
const widgets = [widget('crop', partial)]
const widgets = widgetMap(['crop', partial])
expect(extract(widgets, undefined)).toBeUndefined()
})
it('prefers single bounds object over individual widgets', () => {
const bounds = { x: 1, y: 2, width: 3, height: 4 }
const widgets = [
widget('crop', bounds),
widget('x', 99),
widget('y', 99),
widget('width', 99),
widget('height', 99)
]
const widgets = widgetMap(
['crop', bounds],
['x', 99],
['y', 99],
['width', 99],
['height', 99]
)
expect(extract(widgets, undefined)).toEqual(bounds)
})
})
describe('useUpstreamValue (store-backed read path)', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
resetWorldInstance()
})
it('reads upstream node widgets via the widget value store', () => {
const graphId = '00000000-0000-0000-0000-000000000001' as UUID
const state = useWidgetValueStore().registerWidget(graphId, {
nodeId: 'upstream-1' as NodeId,
name: 'value',
type: 'number',
value: 7,
options: {}
})
const upstreamValue = useUpstreamValue<number>(
() => ({ nodeId: 'upstream-1', outputName: 'value' }),
singleValueExtractor((v): v is number => typeof v === 'number')
)
expect(upstreamValue.value).toBe(7)
state.value = 11
expect(upstreamValue.value).toBe(11)
})
it('returns undefined when no upstream linkage is provided', () => {
const upstreamValue = useUpstreamValue(
() => undefined,
singleValueExtractor((v): v is number => typeof v === 'number')
)
expect(upstreamValue.value).toBeUndefined()
})
})

View File

@@ -1,13 +1,13 @@
import { computed } from 'vue'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import type { WidgetState } from '@/stores/widgetValueStore'
import type { Bounds } from '@/renderer/core/layout/types'
import type { WidgetState } from '@/stores/widgetValueStore'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import type { LinkedUpstreamInfo } from '@/types/simplifiedWidget'
type ValueExtractor<T = unknown> = (
widgets: WidgetState[],
widgets: Map<string, WidgetState>,
outputName: string | undefined
) => T | undefined
@@ -23,7 +23,10 @@ export function useUpstreamValue<T>(
if (!upstream) return undefined
const graphId = canvasStore.canvas?.graph?.rootGraph.id
if (!graphId) return undefined
const widgets = widgetValueStore.getNodeWidgets(graphId, upstream.nodeId)
const widgets = widgetValueStore.getNodeWidgetsByName(
graphId,
upstream.nodeId
)
return extractValue(widgets, upstream.outputName)
})
}
@@ -33,10 +36,12 @@ export function singleValueExtractor<T>(
): ValueExtractor<T> {
return (widgets, outputName) => {
if (outputName) {
const matched = widgets.find((w) => w.name === outputName)
const matched = widgets.get(outputName)
if (matched && isValid(matched.value)) return matched.value
}
const validValues = widgets.map((w) => w.value).filter(isValid)
const validValues = [...widgets.values()]
.map((w) => w.value)
.filter(isValid)
return validValues.length === 1 ? validValues[0] : undefined
}
}
@@ -60,7 +65,7 @@ export function boundsExtractor(): ValueExtractor<Bounds> {
// Fallback: assemble from individual widgets matching BoundingBoxInputSpec field names
const getNum = (name: string): number | undefined => {
const w = widgets.find((w) => w.name === name)
const w = widgets.get(name)
return typeof w?.value === 'number' ? w.value : undefined
}
const x = getNum('x')

View File

@@ -130,8 +130,6 @@ describe('BaseWidget store integration', () => {
const state = store.getWidget(graph.id, 1, 'autoRegWidget')
expect(state).toBeDefined()
expect(state?.nodeId).toBe(1)
expect(state?.name).toBe('autoRegWidget')
expect(state?.type).toBe('number')
expect(state?.value).toBe(100)
expect(state?.label).toBe('Auto Label')

View File

@@ -86,8 +86,7 @@ export abstract class BaseWidget<TWidget extends IBaseWidget = IBaseWidget>
computedDisabled?: boolean
tooltip?: string
private _state: Omit<WidgetState, 'nodeId'> &
Partial<Pick<WidgetState, 'nodeId'>>
private _state: WidgetState
get label(): string | undefined {
return this._state.label
@@ -147,6 +146,7 @@ export abstract class BaseWidget<TWidget extends IBaseWidget = IBaseWidget>
// BaseDOMWidgetImpl: this.value getter returns options.getValue?.() ?? ''. Resolves the correct initial value instead of undefined.
// I.e., calls overriden getter -> options.getValue() -> correct value (https://github.com/Comfy-Org/ComfyUI_frontend/issues/9194).
value: this.value,
name: this.name,
nodeId
})
}
@@ -196,7 +196,6 @@ export abstract class BaseWidget<TWidget extends IBaseWidget = IBaseWidget>
Object.assign(this, safeValues)
this._state = {
name: this.name,
type: this.type as TWidgetType,
value,
label,

View File

@@ -2,20 +2,38 @@ import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it } from 'vitest'
import type { NodeId } from '@/lib/litegraph/src/LGraphNode'
import type { UUID } from '@/lib/litegraph/src/utils/uuid'
import { asGraphId, widgetEntityId } from '@/world/entityIds'
import {
WidgetComponentDisplay,
WidgetComponentValue
} from '@/world/widgets/widgetComponents'
import { getWorld, resetWorldInstance } from '@/world/worldInstance'
import type { WidgetState } from './widgetValueStore'
import { useWidgetValueStore } from './widgetValueStore'
type WidgetInput<T = unknown> = WidgetState<T> & {
name: string
nodeId: NodeId
}
function widget<T>(
nodeId: string,
name: string,
type: string,
value: T,
extra: Partial<
Omit<WidgetState<T>, 'nodeId' | 'name' | 'type' | 'value'>
> = {}
): WidgetState<T> {
return { nodeId, name, type, value, options: {}, ...extra }
extra: Partial<Omit<WidgetState<T>, 'type' | 'value'>> = {}
): WidgetInput<T> {
return {
nodeId: nodeId as NodeId,
name,
type,
value,
options: {},
...extra
}
}
describe('useWidgetValueStore', () => {
@@ -23,12 +41,15 @@ describe('useWidgetValueStore', () => {
const graphB = 'graph-b' as UUID
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
resetWorldInstance()
})
describe('widgetState.value access', () => {
it('getWidget returns undefined for unregistered widget', () => {
const store = useWidgetValueStore()
expect(store.getWidget(graphA, 'missing', 'widget')).toBeUndefined()
expect(
store.getWidget(graphA, 'missing' as NodeId, 'widget')
).toBeUndefined()
})
it('widgetState.value can be read and written directly', () => {
@@ -40,7 +61,9 @@ describe('useWidgetValueStore', () => {
expect(state.value).toBe(100)
state.value = 200
expect(store.getWidget(graphA, 'node-1', 'seed')?.value).toBe(200)
expect(store.getWidget(graphA, 'node-1' as NodeId, 'seed')?.value).toBe(
200
)
})
it('stores different value types', () => {
@@ -53,12 +76,18 @@ describe('useWidgetValueStore', () => {
widget('node-1', 'array', 'combo', [1, 2, 3])
)
expect(store.getWidget(graphA, 'node-1', 'text')?.value).toBe('hello')
expect(store.getWidget(graphA, 'node-1', 'number')?.value).toBe(42)
expect(store.getWidget(graphA, 'node-1', 'boolean')?.value).toBe(true)
expect(store.getWidget(graphA, 'node-1', 'array')?.value).toEqual([
1, 2, 3
])
expect(store.getWidget(graphA, 'node-1' as NodeId, 'text')?.value).toBe(
'hello'
)
expect(store.getWidget(graphA, 'node-1' as NodeId, 'number')?.value).toBe(
42
)
expect(
store.getWidget(graphA, 'node-1' as NodeId, 'boolean')?.value
).toBe(true)
expect(
store.getWidget(graphA, 'node-1' as NodeId, 'array')?.value
).toEqual([1, 2, 3])
})
})
@@ -70,11 +99,9 @@ describe('useWidgetValueStore', () => {
widget('node-1', 'seed', 'number', 12345)
)
expect(state.nodeId).toBe('node-1')
expect(state.name).toBe('seed')
expect(state.type).toBe('number')
expect(state.value).toBe(12345)
expect(state.disabled).toBeUndefined()
expect(state.disabled).toBe(false)
expect(state.serialize).toBeUndefined()
expect(state.options).toEqual({})
})
@@ -103,15 +130,17 @@ describe('useWidgetValueStore', () => {
const store = useWidgetValueStore()
store.registerWidget(graphA, widget('node-1', 'seed', 'number', 100))
const state = store.getWidget(graphA, 'node-1', 'seed')
const state = store.getWidget(graphA, 'node-1' as NodeId, 'seed')
expect(state).toBeDefined()
expect(state?.name).toBe('seed')
expect(state?.type).toBe('number')
expect(state?.value).toBe(100)
})
it('getWidget returns undefined for missing widget', () => {
const store = useWidgetValueStore()
expect(store.getWidget(graphA, 'missing', 'widget')).toBeUndefined()
expect(
store.getWidget(graphA, 'missing' as NodeId, 'widget')
).toBeUndefined()
})
it('getNodeWidgets returns all widgets for a node', () => {
@@ -120,9 +149,8 @@ describe('useWidgetValueStore', () => {
store.registerWidget(graphA, widget('node-1', 'steps', 'number', 20))
store.registerWidget(graphA, widget('node-2', 'cfg', 'number', 7))
const widgets = store.getNodeWidgets(graphA, 'node-1')
const widgets = store.getNodeWidgets(graphA, 'node-1' as NodeId)
expect(widgets).toHaveLength(2)
expect(widgets.map((w) => w.name).sort()).toEqual(['seed', 'steps'])
})
})
@@ -135,7 +163,9 @@ describe('useWidgetValueStore', () => {
)
state.disabled = true
expect(store.getWidget(graphA, 'node-1', 'seed')?.disabled).toBe(true)
expect(
store.getWidget(graphA, 'node-1' as NodeId, 'seed')?.disabled
).toBe(true)
})
it('label can be set directly via getWidget', () => {
@@ -146,12 +176,14 @@ describe('useWidgetValueStore', () => {
)
state.label = 'Random Seed'
expect(store.getWidget(graphA, 'node-1', 'seed')?.label).toBe(
expect(store.getWidget(graphA, 'node-1' as NodeId, 'seed')?.label).toBe(
'Random Seed'
)
state.label = undefined
expect(store.getWidget(graphA, 'node-1', 'seed')?.label).toBeUndefined()
expect(
store.getWidget(graphA, 'node-1' as NodeId, 'seed')?.label
).toBeUndefined()
})
})
@@ -161,8 +193,8 @@ describe('useWidgetValueStore', () => {
store.registerWidget(graphA, widget('node-1', 'seed', 'number', 1))
store.registerWidget(graphB, widget('node-1', 'seed', 'number', 2))
expect(store.getWidget(graphA, 'node-1', 'seed')?.value).toBe(1)
expect(store.getWidget(graphB, 'node-1', 'seed')?.value).toBe(2)
expect(store.getWidget(graphA, 'node-1' as NodeId, 'seed')?.value).toBe(1)
expect(store.getWidget(graphB, 'node-1' as NodeId, 'seed')?.value).toBe(2)
})
it('clearGraph only removes one graph namespace', () => {
@@ -172,8 +204,113 @@ describe('useWidgetValueStore', () => {
store.clearGraph(graphA)
expect(store.getWidget(graphA, 'node-1', 'seed')).toBeUndefined()
expect(store.getWidget(graphB, 'node-1', 'seed')?.value).toBe(2)
expect(
store.getWidget(graphA, 'node-1' as NodeId, 'seed')
).toBeUndefined()
expect(store.getWidget(graphB, 'node-1' as NodeId, 'seed')?.value).toBe(2)
})
})
describe('view contract: data semantics, not identity', () => {
// The view is a delegating accessor object built fresh per call.
// Identity is intentionally NOT preserved across getWidget calls. See
// temp/plans/widget-component-decomposition.md §10.4.
const branded = asGraphId(graphA)
const sample = widget('node-1', 'seed', 'number', 100)
it('reads delegate live to the underlying components', () => {
const store = useWidgetValueStore()
const view = store.registerWidget(graphA, sample)
const widgetId = widgetEntityId(branded, sample.nodeId, sample.name)
const valueBucket = getWorld().getComponent(
widgetId,
WidgetComponentValue
)
expect(view.value).toBe(valueBucket?.value)
})
it('writes round-trip through the underlying components', () => {
const store = useWidgetValueStore()
const view = store.registerWidget(graphA, sample)
const widgetId = widgetEntityId(branded, sample.nodeId, sample.name)
view.value = 42
expect(
getWorld().getComponent(widgetId, WidgetComponentValue)?.value
).toBe(42)
view.label = 'hello'
expect(
getWorld().getComponent(widgetId, WidgetComponentDisplay)?.label
).toBe('hello')
view.disabled = true
expect(
getWorld().getComponent(widgetId, WidgetComponentDisplay)?.disabled
).toBe(true)
})
it('underlying component writes are visible through the view', () => {
const store = useWidgetValueStore()
const view = store.registerWidget(graphA, sample)
const widgetId = widgetEntityId(branded, sample.nodeId, sample.name)
const display = getWorld().getComponent(widgetId, WidgetComponentDisplay)
if (!display) throw new Error('display bucket missing')
display.label = 'fresh'
expect(view.label).toBe('fresh')
})
it('setters no-op safely after clearGraph', () => {
const store = useWidgetValueStore()
const view = store.registerWidget(graphA, sample)
store.clearGraph(graphA)
// Should not throw. Subsequent getWidget remains undefined.
view.value = 999
view.label = 'ignored'
view.disabled = true
expect(
store.getWidget(graphA, sample.nodeId, sample.name)
).toBeUndefined()
})
it('view properties are enumerable for spread/objectContaining', () => {
const store = useWidgetValueStore()
const view = store.registerWidget(graphA, sample)
const keys = Object.keys(view).sort()
expect(keys).toEqual(
['disabled', 'label', 'options', 'serialize', 'type', 'value'].sort()
)
})
})
describe('getNodeWidgetsByName', () => {
it('returns empty map when node has no widgets', () => {
const store = useWidgetValueStore()
const map = store.getNodeWidgetsByName(graphA, 'no-such' as NodeId)
expect(map.size).toBe(0)
})
it('returns map keyed by widget name', () => {
const store = useWidgetValueStore()
store.registerWidget(graphA, widget('node-1', 'seed', 'number', 1))
store.registerWidget(graphA, widget('node-1', 'cfg', 'number', 7))
const map = store.getNodeWidgetsByName(graphA, 'node-1' as NodeId)
expect(map.size).toBe(2)
expect(map.get('seed')?.value).toBe(1)
expect(map.get('cfg')?.value).toBe(7)
expect(map.get('missing')).toBeUndefined()
})
})
describe('reactivity through the view', () => {
it('clearGraph removes data; subsequent getWidget returns undefined', () => {
const store = useWidgetValueStore()
const sample = widget('node-1', 'seed', 'number', 100)
store.registerWidget(graphA, sample)
store.clearGraph(graphA)
expect(
store.getWidget(graphA, sample.nodeId, sample.name)
).toBeUndefined()
})
})
})

View File

@@ -1,19 +1,31 @@
import { defineStore } from 'pinia'
import { reactive, ref } from 'vue'
import type { NodeId } from '@/lib/litegraph/src/LGraphNode'
import type { UUID } from '@/lib/litegraph/src/utils/uuid'
import type { ComponentKey } from '@/world/componentKey'
import {
asGraphId,
isNodeIdForGraph,
isWidgetIdForGraph,
nodeEntityId,
parseWidgetEntityId,
widgetEntityId
} from '@/world/entityIds'
import type { WidgetEntityId } from '@/world/entityIds'
import {
WidgetComponentContainer,
WidgetComponentDisplay,
WidgetComponentSchema,
WidgetComponentSerialize,
WidgetComponentValue
} from '@/world/widgets/widgetComponents'
import type {
IBaseWidget,
IWidgetOptions
} from '@/lib/litegraph/src/types/widgets'
WidgetRegistration,
WidgetState
} from '@/world/widgets/widgetState'
import { getWorld } from '@/world/worldInstance'
/**
* Widget state is keyed by `nodeId:widgetName` without graph context.
* This is intentional: nodes viewed at different subgraph depths share
* the same widget state, enabling synchronized values across the hierarchy.
*/
type WidgetKey = `${NodeId}:${string}`
export type { WidgetState } from '@/world/widgets/widgetState'
/**
* Strips graph/subgraph prefixes from a scoped node ID to get the bare node ID.
@@ -23,49 +35,92 @@ export function stripGraphPrefix(scopedId: NodeId | string): NodeId {
return String(scopedId).replace(/^(.*:)+/, '') as NodeId
}
export interface WidgetState<
TValue = unknown,
TType extends string = string,
TOptions extends IWidgetOptions = IWidgetOptions
> extends Pick<
IBaseWidget<TValue, TType, TOptions>,
'name' | 'type' | 'value' | 'options' | 'label' | 'serialize' | 'disabled'
> {
nodeId: NodeId
}
export const useWidgetValueStore = defineStore('widgetValue', () => {
const graphWidgetStates = ref(new Map<UUID, Map<WidgetKey, WidgetState>>())
function getWidgetStateMap(graphId: UUID): Map<WidgetKey, WidgetState> {
const widgetStates = graphWidgetStates.value.get(graphId)
if (widgetStates) return widgetStates
const nextWidgetStates = reactive(new Map<WidgetKey, WidgetState>())
graphWidgetStates.value.set(graphId, nextWidgetStates)
return nextWidgetStates
}
function makeKey(nodeId: NodeId, widgetName: string): WidgetKey {
return `${nodeId}:${widgetName}`
}
function registerWidget<TValue = unknown>(
graphId: UUID,
state: WidgetState<TValue>
state: WidgetRegistration<TValue>
): WidgetState<TValue> {
const widgetStates = getWidgetStateMap(graphId)
const key = makeKey(state.nodeId, state.name)
widgetStates.set(key, state)
return widgetStates.get(key) as WidgetState<TValue>
const world = getWorld()
const branded = asGraphId(graphId)
const widgetId = widgetEntityId(branded, state.nodeId, state.name)
world.setComponent(widgetId, WidgetComponentValue, { value: state.value })
world.setComponent(widgetId, WidgetComponentDisplay, {
label: state.label,
disabled: state.disabled ?? false
})
world.setComponent(widgetId, WidgetComponentSchema, {
type: state.type,
options: state.options
})
world.setComponent(widgetId, WidgetComponentSerialize, {
serialize: state.serialize
})
const ownerId = nodeEntityId(branded, state.nodeId)
const container = world.getComponent(ownerId, WidgetComponentContainer)
if (!container) {
world.setComponent(ownerId, WidgetComponentContainer, {
widgetIds: [widgetId]
})
} else if (!container.widgetIds.includes(widgetId)) {
container.widgetIds.push(widgetId)
}
return buildView(widgetId) as WidgetState<TValue>
}
function getNodeWidgets(graphId: UUID, nodeId: NodeId): WidgetState[] {
const widgetStates = getWidgetStateMap(graphId)
const prefix = `${nodeId}:`
return [...widgetStates]
.filter(([key]) => key.startsWith(prefix))
.map(([, state]) => state)
/**
* Build a delegating view object for a widget entity. The view owns no
* data — every accessor routes through the world. Getters assert the
* underlying bucket exists; setters silently no-op when the bucket is
* missing (post-`clearGraph` safety) and never re-create buckets.
*/
function buildView(widgetId: WidgetEntityId): WidgetState {
const world = getWorld()
function read<T>(key: ComponentKey<T, WidgetEntityId>): T {
const bucket = world.getComponent(widgetId, key)
if (!bucket) {
throw new Error(
`Widget ${widgetId} missing component ${key.name}; view is invalid (likely accessed after clearGraph).`
)
}
return bucket
}
return {
get value() {
return read(WidgetComponentValue).value
},
set value(v: unknown) {
const bucket = world.getComponent(widgetId, WidgetComponentValue)
if (bucket) bucket.value = v
},
get label() {
return read(WidgetComponentDisplay).label
},
set label(v: string | undefined) {
const bucket = world.getComponent(widgetId, WidgetComponentDisplay)
if (bucket) bucket.label = v
},
get disabled() {
return read(WidgetComponentDisplay).disabled
},
set disabled(v: boolean | undefined) {
const bucket = world.getComponent(widgetId, WidgetComponentDisplay)
if (bucket) bucket.disabled = v ?? false
},
get type() {
return read(WidgetComponentSchema).type
},
get options() {
return read(WidgetComponentSchema).options
},
get serialize() {
return read(WidgetComponentSerialize).serialize
}
}
}
function getWidget(
@@ -73,17 +128,66 @@ export const useWidgetValueStore = defineStore('widgetValue', () => {
nodeId: NodeId,
widgetName: string
): WidgetState | undefined {
return getWidgetStateMap(graphId).get(makeKey(nodeId, widgetName))
const world = getWorld()
const widgetId = widgetEntityId(asGraphId(graphId), nodeId, widgetName)
if (!world.getComponent(widgetId, WidgetComponentValue)) return undefined
return buildView(widgetId)
}
function getNodeWidgets(graphId: UUID, nodeId: NodeId): WidgetState[] {
const world = getWorld()
const ownerId = nodeEntityId(asGraphId(graphId), nodeId)
const container = world.getComponent(ownerId, WidgetComponentContainer)
if (!container) return []
const widgets: WidgetState[] = []
for (const widgetId of container.widgetIds) {
if (world.getComponent(widgetId, WidgetComponentValue)) {
widgets.push(buildView(widgetId))
}
}
return widgets
}
function getNodeWidgetsByName(
graphId: UUID,
nodeId: NodeId
): Map<string, WidgetState> {
const world = getWorld()
const ownerId = nodeEntityId(asGraphId(graphId), nodeId)
const container = world.getComponent(ownerId, WidgetComponentContainer)
const result = new Map<string, WidgetState>()
if (!container) return result
for (const widgetId of container.widgetIds) {
if (!world.getComponent(widgetId, WidgetComponentValue)) continue
const { name } = parseWidgetEntityId(widgetId)
result.set(name, buildView(widgetId))
}
return result
}
function clearGraph(graphId: UUID): void {
graphWidgetStates.value.delete(graphId)
const world = getWorld()
const branded = asGraphId(graphId)
for (const widgetId of world.entitiesWith(WidgetComponentValue)) {
if (isWidgetIdForGraph(branded, widgetId)) {
world.removeComponent(widgetId, WidgetComponentValue)
world.removeComponent(widgetId, WidgetComponentDisplay)
world.removeComponent(widgetId, WidgetComponentSchema)
world.removeComponent(widgetId, WidgetComponentSerialize)
}
}
for (const nodeId of world.entitiesWith(WidgetComponentContainer)) {
if (isNodeIdForGraph(branded, nodeId)) {
world.removeComponent(nodeId, WidgetComponentContainer)
}
}
}
return {
registerWidget,
getWidget,
getNodeWidgets,
getNodeWidgetsByName,
clearGraph
}
})

6
src/world/brand.ts Normal file
View File

@@ -0,0 +1,6 @@
/**
* Nominal-typed brand helper. Used by entity ID and component-key types so
* mixing kinds is a compile-time error.
*/
declare const brand: unique symbol
export type Brand<T, Tag extends string> = T & { readonly [brand]: Tag }

View File

@@ -0,0 +1,84 @@
import { describe, expect, expectTypeOf, it, vi } from 'vitest'
import type { ComponentKey } from './componentKey'
import { defineComponentKey, defineComponentKeys, slot } from './componentKey'
import type { NodeEntityId, WidgetEntityId } from './entityIds'
describe('defineComponentKeys', () => {
it('synthesizes runtime names from prefix and property keys', () => {
const keys = defineComponentKeys('Foo', {
Bar: slot<{ x: number }, NodeEntityId>(),
Baz: slot<{ y: string }, NodeEntityId>()
})
expect(keys.FooComponentBar.name).toBe('FooComponentBar')
expect(keys.FooComponentBaz.name).toBe('FooComponentBaz')
})
it('produces keys with distinct identities across calls', () => {
// Suppress dev-time collision warning fired by defineComponentKey.
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
const a = defineComponentKeys('FooA', {
Bar: slot<Record<string, never>, NodeEntityId>()
})
const b = defineComponentKeys('FooA', {
Bar: slot<Record<string, never>, NodeEntityId>()
})
// Two separate factory calls => two separate key objects, even with same name.
expect(a.FooAComponentBar).not.toBe(b.FooAComponentBar)
expect(a.FooAComponentBar.name).toBe(b.FooAComponentBar.name)
errorSpy.mockRestore()
})
it('produces keys with the expected literal-type name (compile-time check)', () => {
const keys = defineComponentKeys('WidgetTest', {
Value: slot<{ value: unknown }, WidgetEntityId>()
})
// Type-only assertion: the literal name flows through the type. If the
// literal disappears from the return type, this assignment fails to
// compile.
type CheckName = (typeof keys.WidgetTestComponentValue)['name']
const _check: CheckName = 'WidgetTestComponentValue'
void _check
expect(keys.WidgetTestComponentValue.name).toBe('WidgetTestComponentValue')
})
})
describe('ComponentKey type shapes', () => {
it('defineComponentKey returns ComponentKey<TData, TEntity>', () => {
const key = defineComponentKey<{ value: number }, WidgetEntityId>(
'TypeShapeKey'
)
expectTypeOf(key).toEqualTypeOf<
ComponentKey<{ value: number }, WidgetEntityId>
>()
})
it('defineComponentKeys recovers TData/TEntity per slot', () => {
const keys = defineComponentKeys('Demo', {
Value: slot<{ v: number }, WidgetEntityId>(),
Tag: slot<string, NodeEntityId>()
})
// Each key carries its own (TData, TEntity, full-name literal) trio.
expectTypeOf(keys.DemoComponentValue).toEqualTypeOf<
ComponentKey<{ v: number }, WidgetEntityId, 'DemoComponentValue'>
>()
expectTypeOf(keys.DemoComponentTag).toEqualTypeOf<
ComponentKey<string, NodeEntityId, 'DemoComponentTag'>
>()
})
it('ComponentKey phantom params keep entity kinds disjoint', () => {
// A widget-keyed ComponentKey is not assignable to a node-keyed one,
// even when TData matches.
expectTypeOf<
ComponentKey<{ v: number }, WidgetEntityId> extends ComponentKey<
{ v: number },
NodeEntityId
>
? true
: false
>().toEqualTypeOf<false>()
})
})

88
src/world/componentKey.ts Normal file
View File

@@ -0,0 +1,88 @@
import type { EntityId } from './entityIds'
declare const componentKeyData: unique symbol
declare const componentKeyEntity: unique symbol
declare const slotData: unique symbol
declare const slotEntity: unique symbol
/**
* Nominal handle for a component type. The phantom params drive
* `world.getComponent` return-type inference and forbid cross-kind misuse
* (e.g. reading a `WidgetValue` off a `NodeEntityId` is a type error).
*
* `TName` carries the registered name as a string literal type when the key
* was produced via `defineComponentKeys`. For one-off `defineComponentKey`
* calls it widens to `string`.
*/
export interface ComponentKey<
TData,
TEntity extends EntityId,
TName extends string = string
> {
readonly name: TName
readonly [componentKeyData]?: TData
readonly [componentKeyEntity]?: TEntity
}
/**
* Phantom slot used as the per-property argument to `defineComponentKeys`.
* `slot<TData, TEntity>()` returns an empty object whose phantom symbols
* carry the data + entity types so the factory can recover them via `infer`.
*/
interface Slot<TData, TEntity extends EntityId> {
readonly [slotData]?: TData
readonly [slotEntity]?: TEntity
}
export function slot<TData, TEntity extends EntityId>(): Slot<TData, TEntity> {
return {} as Slot<TData, TEntity>
}
const registeredNames = new Set<string>()
export function defineComponentKey<TData, TEntity extends EntityId>(
name: string
): ComponentKey<TData, TEntity> {
if (import.meta.env.DEV && registeredNames.has(name)) {
console.error(
`[world] ComponentKey name collision: "${name}" was already registered. ` +
`Two keys with the same name share storage and will silently overwrite each other.`
)
}
registeredNames.add(name)
return { name } as ComponentKey<TData, TEntity>
}
/**
* Define a related set of `ComponentKey`s under a shared prefix in one call.
*
* The full registered name for each key is `${TPrefix}Component${ShortName}`,
* derived from both the runtime prefix and the property keys of the slots
* object. The literal-type return signature mirrors that string so each key
* carries its full name as a string literal type.
*
* Internally calls `defineComponentKey` per slot, so the dev-time collision
* warning still fires for factory-created keys.
*/
export function defineComponentKeys<
TPrefix extends string,
TSlots extends Record<string, Slot<unknown, EntityId>>
>(
prefix: TPrefix,
slots: TSlots
): {
[K in keyof TSlots &
string as `${TPrefix}Component${K}`]: TSlots[K] extends Slot<
infer TData,
infer TEntity
>
? ComponentKey<TData, TEntity, `${TPrefix}Component${K}`>
: never
} {
const result = {} as Record<string, ComponentKey<unknown, EntityId>>
for (const shortName of Object.keys(slots)) {
const fullName = `${prefix}Component${shortName}`
result[fullName] = defineComponentKey<unknown, EntityId>(fullName)
}
return result as never
}

View File

@@ -0,0 +1,97 @@
import { describe, expect, expectTypeOf, it } from 'vitest'
import type { NodeId } from '@/lib/litegraph/src/LGraphNode'
import type { UUID } from '@/lib/litegraph/src/utils/uuid'
import type { NodeEntityId, WidgetEntityId } from './entityIds'
import {
asGraphId,
nodeEntityId,
parseWidgetEntityId,
widgetEntityId
} from './entityIds'
describe('parseWidgetEntityId', () => {
const graphId = asGraphId('a3f2c1d8-4567-89ab-cdef-1234567890ab' as UUID)
it('round-trips a simple name', () => {
const id = widgetEntityId(graphId, 42 as NodeId, 'seed')
expect(parseWidgetEntityId(id)).toEqual({
graphId,
nodeId: '42',
name: 'seed'
})
})
it('preserves names containing colons', () => {
const id = widgetEntityId(graphId, 7 as NodeId, 'images.image:0')
expect(parseWidgetEntityId(id).name).toBe('images.image:0')
})
it('handles string node ids', () => {
// Documented limitation: a colon-containing nodeId would split at the
// FIRST colon after graphId. NodeId values are scalar-shaped in
// production, so we only assert the graphId still round-trips here.
const id = widgetEntityId(graphId, '12:5' as NodeId, 'sub_widget')
const parsed = parseWidgetEntityId(id)
expect(parsed.graphId).toBe(graphId)
})
it('round-trips an empty name', () => {
const id = widgetEntityId(graphId, 1 as NodeId, '')
expect(parseWidgetEntityId(id)).toEqual({
graphId,
nodeId: '1',
name: ''
})
})
it('throws on missing widget: prefix', () => {
expect(() =>
parseWidgetEntityId(`node:${graphId}:42` as unknown as WidgetEntityId)
).toThrow(/Malformed WidgetEntityId/)
})
it('throws on too few colons', () => {
expect(() => parseWidgetEntityId('widget:abc' as WidgetEntityId)).toThrow(
/Malformed WidgetEntityId/
)
})
it('throws when nodeId segment is missing', () => {
expect(() =>
parseWidgetEntityId(`widget:${graphId}:42` as WidgetEntityId)
).toThrow(/Malformed WidgetEntityId/)
})
})
describe('entityIds type shapes', () => {
type GraphId = ReturnType<typeof asGraphId>
it('widgetEntityId returns the WidgetEntityId brand', () => {
expectTypeOf(widgetEntityId).returns.toEqualTypeOf<WidgetEntityId>()
})
it('nodeEntityId returns the NodeEntityId brand', () => {
expectTypeOf(nodeEntityId).returns.toEqualTypeOf<NodeEntityId>()
})
it('parseWidgetEntityId returns the documented shape', () => {
expectTypeOf(parseWidgetEntityId).returns.toEqualTypeOf<{
graphId: GraphId
nodeId: NodeId
name: string
}>()
})
it('WidgetEntityId and NodeEntityId are distinct brands', () => {
// Brand isolation: neither direction is assignable. Both `extends`
// checks must resolve to `never` for the brand contract to hold.
expectTypeOf<
WidgetEntityId extends NodeEntityId ? WidgetEntityId : never
>().toEqualTypeOf<never>()
expectTypeOf<
NodeEntityId extends WidgetEntityId ? NodeEntityId : never
>().toEqualTypeOf<never>()
})
})

93
src/world/entityIds.ts Normal file
View File

@@ -0,0 +1,93 @@
/**
* Entity IDs are deterministic, content-addressed, string-prefixed values
* — not opaque numerics (cf. bitECS, koota, miniplex).
*
* Identity is keyed by `rootGraph.id`, so an entity viewed at different
* subgraph depths shares state. Migrating to numeric IDs would break
* cross-subgraph value sharing. See ADR 0008 and `widgetValueStore.ts`.
*
* The `graph*Prefix` and `*EntityId` helpers below are the sole owners of
* the on-the-wire format. Never hand-construct or parse these strings.
*/
import type { NodeId } from '@/lib/litegraph/src/LGraphNode'
import type { UUID } from '@/lib/litegraph/src/utils/uuid'
import type { Brand } from './brand'
type GraphId = Brand<UUID, 'GraphId'>
export function asGraphId(id: UUID): GraphId {
return id as GraphId
}
export type NodeEntityId = Brand<string, 'NodeEntityId'>
function graphNodePrefix(graphId: GraphId): string {
return `node:${graphId}:`
}
export function nodeEntityId(graphId: GraphId, nodeId: NodeId): NodeEntityId {
return `${graphNodePrefix(graphId)}${nodeId}` as NodeEntityId
}
export type WidgetEntityId = Brand<string, 'WidgetEntityId'>
function graphWidgetPrefix(graphId: GraphId): string {
return `widget:${graphId}:`
}
export function widgetEntityId(
graphId: GraphId,
nodeId: NodeId,
name: string
): WidgetEntityId {
return `${graphWidgetPrefix(graphId)}${nodeId}:${name}` as WidgetEntityId
}
/**
* Parse a `WidgetEntityId` into its constituent parts.
*
* The on-the-wire format is `widget:${graphId}:${nodeId}:${name}`. The
* regex captures the first two colon-delimited segments as graphId and
* nodeId, then takes the rest as the widget name. This means widget
* names may contain colons (e.g. `images.image:0`).
*
* Throws on malformed input (missing prefix, too few colons) so
* upstream type-cast bugs surface at the parse site instead of leaking
* garbage `{graphId, nodeId, name}` triples downstream.
*
* Limitation: nodeIds containing colons are not supported. NodeId values
* are always serialized scalars (numeric or short string) in production,
* so this is a documented edge case rather than a defect.
*/
const WIDGET_ID_RE = /^widget:([^:]+):([^:]+):(.*)$/
export function parseWidgetEntityId(id: WidgetEntityId): {
graphId: GraphId
nodeId: NodeId
name: string
} {
const match = WIDGET_ID_RE.exec(id)
if (!match) {
throw new Error(`Malformed WidgetEntityId: ${id}`)
}
const [, graphId, nodeId, name] = match
return {
graphId: graphId as GraphId,
nodeId: nodeId as NodeId,
name
}
}
export function isNodeIdForGraph(graphId: GraphId, id: NodeEntityId): boolean {
return id.startsWith(graphNodePrefix(graphId))
}
export function isWidgetIdForGraph(
graphId: GraphId,
id: WidgetEntityId
): boolean {
return id.startsWith(graphWidgetPrefix(graphId))
}
export type EntityId = NodeEntityId | WidgetEntityId

View File

@@ -0,0 +1,31 @@
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { defineComponentKeys, slot } from '@/world/componentKey'
import type { NodeEntityId, WidgetEntityId } from '@/world/entityIds'
/**
* Per-bucket widget component shapes. Each bucket carves a disjoint slice
* of {@link IBaseWidget} so the component stores stay in sync with the
* source of truth in `src/lib/litegraph/src/types/widgets.ts`.
*/
type WidgetValue = Pick<IBaseWidget<unknown>, 'value'>
type WidgetDisplay = Pick<IBaseWidget, 'label' | 'disabled'>
type WidgetSchema = Pick<IBaseWidget, 'type' | 'options'>
type WidgetSerialize = Pick<IBaseWidget, 'serialize'>
interface WidgetContainer {
widgetIds: WidgetEntityId[]
}
export const {
WidgetComponentValue,
WidgetComponentDisplay,
WidgetComponentSchema,
WidgetComponentSerialize,
WidgetComponentContainer
} = defineComponentKeys('Widget', {
Value: slot<WidgetValue, WidgetEntityId>(),
Display: slot<WidgetDisplay, WidgetEntityId>(),
Schema: slot<WidgetSchema, WidgetEntityId>(),
Serialize: slot<WidgetSerialize, WidgetEntityId>(),
Container: slot<WidgetContainer, NodeEntityId>()
})

View File

@@ -0,0 +1,43 @@
import type { NodeId } from '@/lib/litegraph/src/LGraphNode'
import type {
IBaseWidget,
IWidgetOptions
} from '@/lib/litegraph/src/types/widgets'
/**
* `WidgetState` is a *derived view* over the four widget-side components
* (`WidgetComponentValue` / `Display` / `Schema` / `Serialize`). Property
* accessors are installed via `Object.defineProperty` and delegate live
* to the world; reads always hit the underlying reactive proxies, so
* Vue tracking propagates through the view.
*
* Object identity is **not** preserved across `getWidget` calls — each
* call constructs a fresh view. Data semantics round-trip; identity does
* not. Do not cache views or rely on `===`.
*
* `name` and `nodeId` are not present on the view: they live in the
* underlying `WidgetEntityId` and would be a redundant copy here. Callers
* that need them should derive from the entity id (or from the BaseWidget
* instance, which still owns them).
*/
export type WidgetState<
TValue = unknown,
TType extends string = string,
TOptions extends IWidgetOptions = IWidgetOptions
> = Pick<
IBaseWidget<TValue, TType, TOptions>,
'value' | 'options' | 'label' | 'serialize' | 'disabled' | 'type'
>
/**
* Input shape for `registerWidget`: a `WidgetState` view augmented with the
* identity fields (`name`, `nodeId`) needed to construct the widget's
* `WidgetEntityId`. The view returned from `registerWidget` is the
* un-augmented `WidgetState` because identity fields live in the entity id.
*/
export interface WidgetRegistration<
TValue = unknown
> extends WidgetState<TValue> {
name: string
nodeId: NodeId
}

176
src/world/world.test.ts Normal file
View File

@@ -0,0 +1,176 @@
import { describe, expect, expectTypeOf, it, vi } from 'vitest'
import { computed } from 'vue'
import { defineComponentKey } from './componentKey'
import type { NodeEntityId, WidgetEntityId } from './entityIds'
import { asGraphId, nodeEntityId, widgetEntityId } from './entityIds'
import { createWorld } from './world'
const TestWidgetThing = defineComponentKey<{ value: number }, WidgetEntityId>(
'TestWidgetThing'
)
const TestNodeThing = defineComponentKey<{ tag: string }, NodeEntityId>(
'TestNodeThing'
)
describe('createWorld', () => {
const graphId = asGraphId('00000000-0000-0000-0000-000000000001')
it('round-trips set / get / remove', () => {
const world = createWorld()
const widgetId = widgetEntityId(graphId, 1, 'seed')
expect(world.getComponent(widgetId, TestWidgetThing)).toBeUndefined()
world.setComponent(widgetId, TestWidgetThing, { value: 42 })
expect(world.getComponent(widgetId, TestWidgetThing)?.value).toBe(42)
world.removeComponent(widgetId, TestWidgetThing)
expect(world.getComponent(widgetId, TestWidgetThing)).toBeUndefined()
})
it('propagates mutations through the stored proxy', () => {
const world = createWorld()
const widgetId = widgetEntityId(graphId, 1, 'seed')
const data = { value: 42 }
world.setComponent(widgetId, TestWidgetThing, data)
data.value = 99
expect(world.getComponent(widgetId, TestWidgetThing)?.value).toBe(99)
})
it('returns the same proxy across reads of the same (id, key)', () => {
const world = createWorld()
const widgetId = widgetEntityId(graphId, 1, 'seed')
world.setComponent(widgetId, TestWidgetThing, { value: 42 })
const a = world.getComponent(widgetId, TestWidgetThing)
const b = world.getComponent(widgetId, TestWidgetThing)
expect(a).toBe(b)
})
it('reacts when subscribing before the first component for a key exists', () => {
const world = createWorld()
const widgetId = widgetEntityId(graphId, 1, 'seed')
const observed = computed(
() => world.getComponent(widgetId, TestWidgetThing)?.value
)
expect(observed.value).toBeUndefined()
world.setComponent(widgetId, TestWidgetThing, { value: 42 })
expect(observed.value).toBe(42)
})
it('iterates entities for a given component key', () => {
const world = createWorld()
const a = widgetEntityId(graphId, 1, 'seed')
const b = widgetEntityId(graphId, 1, 'cfg')
world.setComponent(a, TestWidgetThing, { value: 1 })
world.setComponent(b, TestWidgetThing, { value: 2 })
const ids = world.entitiesWith(TestWidgetThing)
expect(ids.sort()).toEqual([a, b].sort())
})
it('keeps entity kinds isolated by ComponentKey phantom param', () => {
const world = createWorld()
const nodeId = nodeEntityId(graphId, 1)
world.setComponent(nodeId, TestNodeThing, { tag: 'foo' })
expect(world.getComponent(nodeId, TestNodeThing)?.tag).toBe('foo')
// Cross-kind access is rejected at compile time. The type-level assertion
// below fails to compile if `widgetEntityId(...)` ever becomes assignable
// to a parameter expecting `NodeEntityId`, locking in the brand isolation
// contract without resorting to `@ts-expect-error`.
type CrossKindGetComponent = Parameters<
typeof world.getComponent<{ tag: string }, NodeEntityId>
>[0]
type WidgetIsNotAssignableToNode =
WidgetEntityId extends CrossKindGetComponent ? false : true
const _crossKindIsRejected: WidgetIsNotAssignableToNode = true
expect(_crossKindIsRejected).toBe(true)
})
})
describe('widgetEntityId', () => {
it('is deterministic across (graphId, nodeId, name)', () => {
const g = asGraphId('00000000-0000-0000-0000-000000000001')
expect(widgetEntityId(g, 1, 'seed')).toBe(widgetEntityId(g, 1, 'seed'))
})
it('preserves cross-subgraph identity (root graph keying)', () => {
// Same root graph + same nodeId + same name = same entity, regardless of
// the subgraph depth from which the consumer reaches the node.
const g = asGraphId('00000000-0000-0000-0000-000000000001')
const fromRoot = widgetEntityId(g, 42, 'seed')
const fromNested = widgetEntityId(g, 42, 'seed')
expect(fromRoot).toBe(fromNested)
})
})
describe('ComponentKey identity', () => {
it('keys component buckets by reference, not by name string', () => {
// Suppress the dev-time collision warning emitted by defineComponentKey.
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
interface PayloadA {
a: number
}
interface PayloadB {
b: number
}
const keyA = defineComponentKey<PayloadA, NodeEntityId>('Collision')
const keyB = defineComponentKey<PayloadB, NodeEntityId>('Collision')
expect(keyA).not.toBe(keyB)
expect(keyA.name).toBe(keyB.name)
const world = createWorld()
const id = nodeEntityId(
asGraphId('00000000-0000-0000-0000-000000000001'),
1
)
world.setComponent(id, keyA, { a: 1 })
world.setComponent(id, keyB, { b: 2 })
expect(world.getComponent(id, keyA)).toEqual({ a: 1 })
expect(world.getComponent(id, keyB)).toEqual({ b: 2 })
errorSpy.mockRestore()
})
})
describe('World type shapes', () => {
const world = createWorld()
const WidgetThing = defineComponentKey<{ value: number }, WidgetEntityId>(
'TypeShapeWidgetThing'
)
it('getComponent narrows to TData | undefined for the key', () => {
expectTypeOf(
world.getComponent<{ value: number }, WidgetEntityId>
).returns.toEqualTypeOf<{ value: number } | undefined>()
})
it('setComponent third parameter matches the key TData', () => {
expectTypeOf(world.setComponent<{ value: number }, WidgetEntityId>)
.parameter(2)
.toEqualTypeOf<{ value: number }>()
})
it('entitiesWith returns TEntity[] for the key', () => {
expectTypeOf(
world.entitiesWith<{ value: number }, WidgetEntityId>
).returns.toEqualTypeOf<WidgetEntityId[]>()
})
it('rejects cross-kind entity ids at the call site', () => {
// A widget-keyed read demands a WidgetEntityId. NodeEntityId must not
// be assignable, otherwise a NodeEntityId could be passed to a
// ComponentKey<_, WidgetEntityId>.
expectTypeOf<
NodeEntityId extends WidgetEntityId ? true : false
>().toEqualTypeOf<false>()
void WidgetThing
})
})

94
src/world/world.ts Normal file
View File

@@ -0,0 +1,94 @@
import { reactive, shallowReactive } from 'vue'
import type { ComponentKey } from './componentKey'
import type { EntityId } from './entityIds'
/**
* `setComponent` stores by reference; `getComponent` returns a Vue proxy
* cached per `(id, key)`. The proxy is stable across reads and is NOT
* `===` to the input. Treat `getComponent` as the canonical read path.
*
* Component buckets are keyed by `ComponentKey` reference identity, NOT by
* `key.name`. Two distinct keys with the same `name` string therefore do
* not share storage. `key.name` remains useful for debugging only.
*/
export interface World {
getComponent<TData, TEntity extends EntityId>(
id: TEntity,
key: ComponentKey<TData, TEntity>
): TData | undefined
setComponent<TData, TEntity extends EntityId>(
id: TEntity,
key: ComponentKey<TData, TEntity>,
data: TData
): void
removeComponent<TData, TEntity extends EntityId>(
id: TEntity,
key: ComponentKey<TData, TEntity>
): void
entitiesWith<TData, TEntity extends EntityId>(
key: ComponentKey<TData, TEntity>
): TEntity[]
}
interface AnyComponentKey extends ComponentKey<unknown, EntityId> {}
interface AnyBucket extends Map<EntityId, unknown> {}
interface Bucket<TData, TEntity extends EntityId> extends Map<TEntity, TData> {}
export function createWorld(): World {
// shallowReactive so first-bucket creation is observable to subscribers.
const store = shallowReactive(new Map<AnyComponentKey, AnyBucket>())
/**
* The single existential erasure boundary. The phantom `TData`/`TEntity`
* params on `ComponentKey` are not representable in the heterogeneous outer
* `Map`, so we erase here and reify in `getBucket`.
*/
function eraseKey<TData, TEntity extends EntityId>(
key: ComponentKey<TData, TEntity>
): AnyComponentKey {
return key as AnyComponentKey
}
/**
* Invariant (audited at this boundary only): for a given
* `ComponentKey<TData, TEntity>`, the stored bucket is absent or a
* `Map<TEntity, TData>` created and mutated only through this world.
*/
function getBucket<TData, TEntity extends EntityId>(
key: ComponentKey<TData, TEntity>
): Bucket<TData, TEntity> | undefined {
return store.get(eraseKey(key)) as Bucket<TData, TEntity> | undefined
}
function getOrCreateBucket<TData, TEntity extends EntityId>(
key: ComponentKey<TData, TEntity>
): Bucket<TData, TEntity> {
const existing = getBucket(key)
if (existing) return existing
// `reactive()` widens the bucket's value type to `UnwrapRefSimple<TData>`;
// `TData` is a generic so TS can't prove they coincide. Cast confined here.
const created = reactive(new Map<TEntity, TData>()) as Bucket<
TData,
TEntity
>
store.set(eraseKey(key), created as AnyBucket)
return created
}
return {
getComponent(id, key) {
return getBucket(key)?.get(id)
},
setComponent(id, key, data) {
getOrCreateBucket(key).set(id, data)
},
removeComponent(id, key) {
getBucket(key)?.delete(id)
},
entitiesWith(key) {
const bucket = getBucket(key)
return bucket ? Array.from(bucket.keys()) : []
}
}
}

View File

@@ -0,0 +1,14 @@
import type { World } from './world'
import { createWorld } from './world'
/** Module-singleton `World` for the editor process. */
let instance: World | undefined
export function getWorld(): World {
if (!instance) instance = createWorld()
return instance
}
export function resetWorldInstance(): void {
instance = undefined
}