Compare commits

...

17 Commits

Author SHA1 Message Date
DrJKL
87b8ff016e refactor: collapse world into widgetValueStore
The World/ComponentKey/worldInstance layer had a single production
consumer (widgetValueStore). Delete the abstraction and hold widget
state in two reactive Maps on the store directly. EntityId branded
types and the WidgetState shape are preserved; 30+ importers untouched.

- Drop src/world/{world,worldInstance,componentKey,widgets/widgetComponents}.ts
- Rewrite widgetValueStore to own reactive(Map) state directly
- Drop now-unused EntityId union and resetWorldInstance test calls

Amp-Thread-ID: https://ampcode.com/threads/T-019e852c-93d8-708b-9a1c-b648c0acf735
Co-authored-by: Amp <amp@ampcode.com>
2026-06-01 15:40:23 -07:00
DrJKL
ee8f1b40e0 refactor: move extractRawNodeId next to its only caller
The helper has nothing to do with widget values or the widget store —
it just strips graph-scope prefixes off node ids for DOM identity keys
in useProcessedWidgets. Inline it as a private function there and drop
the export from widgetValueStore.

Amp-Thread-ID: https://ampcode.com/threads/T-019e71ba-da65-71c1-b43d-bab1eb1c7312
Co-authored-by: Amp <amp@ampcode.com>
2026-06-01 14:25:14 -07:00
DrJKL
2b8b1b174c refactor: collapse widget state into a single world component
Replaces the 4-way WidgetComponentValue/Display/Schema/Serialize split
with one WidgetComponent bucket. WidgetState is now a flat interface
that IBaseWidget extends directly, the per-call delegating view is
gone, and the store returns the live reactive bucket from the world
so identity is preserved across registerWidget/getWidget.

Amp-Thread-ID: https://ampcode.com/threads/T-019e71ba-da65-71c1-b43d-bab1eb1c7312
Co-authored-by: Amp <amp@ampcode.com>
2026-06-01 12:04:23 -07:00
DrJKL
307ece903b refactor: make widgetValueStore entity-id-native, delete widgetValueIO
The IO module's four functions all parsed a WidgetEntityId into
(graphId, nodeId, name) only to call store methods that immediately
reconstructed the same string. The round-trip ran on every widget
read and write.

Store methods now take WidgetEntityId / NodeEntityId directly:

  registerWidget(widgetId, state)        // was (graphId, registration)
  getWidget(widgetId)                    // was _lookupWidgetState(g,n,n)
  setValue(widgetId, value)              // was (g,n,n,v)
  getNodeWidgets(nodeId)                 // was (g,n)
  getNodeWidgetsByName(nodeId)           // was (g,n)
  clearGraph(graphId)                    // unchanged

WidgetRegistration is gone — name/nodeId now live in the entity id.
writeWidgetValue had zero callers; ensureWidgetState had one, now
inlined as 'if (!existing) registerWidget(...)'. SubgraphNode and
promotionUtils call store.getWidget(eid)?.value directly.

30 files, -121 net lines.

Amp-Thread-ID: https://ampcode.com/threads/T-019e71ba-da65-71c1-b43d-bab1eb1c7312
Co-authored-by: Amp <amp@ampcode.com>
2026-05-30 00:36:41 -07:00
DrJKL
7dca6ce05d refactor: inline widget entity-id derivation, delete getWidgetStateByTriple
The bridge function's own JSDoc warned 'DO NOT use from new code'.
Each call site now derives a WidgetEntityId via deriveWidgetEntityId
and looks up state through getWidgetState directly. Removes the
triple-keyed lookup helper and its test.

Amp-Thread-ID: https://ampcode.com/threads/T-019e71ba-da65-71c1-b43d-bab1eb1c7312
Co-authored-by: Amp <amp@ampcode.com>
2026-05-29 16:49:02 -07:00
DrJKL
a1b4a9c33b refactor: drop slot/defineComponentKeys, use defineComponentKey directly
The slot()/defineComponentKeys() factory pair existed to save a few
lines and to flow each key's full name as a string-literal type into
ComponentKey['name']. No consumer depended on the literal name. Replace
the single use site with five direct defineComponentKey calls.

Amp-Thread-ID: https://ampcode.com/threads/T-019e71ba-da65-71c1-b43d-bab1eb1c7312
Co-authored-by: Amp <amp@ampcode.com>
2026-05-28 23:22:10 -07:00
DrJKL
e2483a9c65 refactor: drop litegraph uuid shim, point importers at @/utils/uuid
Every consumer was already going through a 2-line re-export at
src/lib/litegraph/src/utils/uuid.ts. Rewrites the 20 importers (10 in
src/lib/litegraph/, 10 outside) to import from @/utils/uuid directly
and deletes the shim. The litegraph.ts barrel still re-exports
createUuidv4/UUID for the package's public surface.

Amp-Thread-ID: https://ampcode.com/threads/T-019e71ba-da65-71c1-b43d-bab1eb1c7312
Co-authored-by: Amp <amp@ampcode.com>
2026-05-28 20:39:53 -07:00
DrJKL
5208b29b5a refactor: move uuid logic to src/utils/uuid.ts
Amp-Thread-ID: https://ampcode.com/threads/T-019e70e4-c5ae-715c-8800-4d0f23c789e4
Co-authored-by: Amp <amp@ampcode.com>
2026-05-28 16:32:59 -07:00
DrJKL
c9e109ad61 docs(world): mark getWidgetStateByTriple as transitional bridge
Amp-Thread-ID: https://ampcode.com/threads/T-019e704c-d726-77ac-8fd4-2e7337da50eb
Co-authored-by: Amp <amp@ampcode.com>
2026-05-28 14:07:24 -07:00
DrJKL
c4ac2425af docs: add Thyseus to ECS pattern survey appendix
Amp-Thread-ID: https://ampcode.com/threads/T-019e704c-d726-77ac-8fd4-2e7337da50eb
Co-authored-by: Amp <amp@ampcode.com>
2026-05-28 13:53:47 -07:00
DrJKL
7e94798512 chore: drop dead Omit and stale promotionStore doc refs
- Remove no-op `Omit<WidgetState, 'nodeId' | 'name'>` alias in widgetValueIO;
  `WidgetState` already lacks those identity fields (they live on
  `WidgetStateInit` in widgetState.ts).
- Update stale `PromotionStore` / `usePromotionStore` references in 4 docs
  to reflect ADR 0009 removal (legacy three-layer mechanism gone; promoted
  value widgets are now linked SubgraphInputs).

Amp-Thread-ID: https://ampcode.com/threads/T-019e6c3f-ac88-75ac-bce3-4aacbe45c490
Co-authored-by: Amp <amp@ampcode.com>
2026-05-28 00:42:05 -07:00
DrJKL
03e922fc13 refactor: migrate widget-lookup callers to widgetValueIO
Move every widget-lookup site off the deprecated triple-keyed
widgetValueStore.getWidget API to the WidgetEntityId-keyed helpers in
src/world/widgetValueIO.ts.

- Callers that hold an IBaseWidget now use getWidgetState(widget.entityId)
  — promotedWidgetView, WidgetItem.vue, dynamicWidgets, customWidgets.
- Callers that hold only a (graphId, nodeId, name) triple use a new
  getWidgetStateByTriple helper — useGLSLUniforms, useGLSLPreview,
  uploadAudio, useProgressTextWidget, usePartitionedBadges, useMarkdownWidget,
  useStringWidget, useProcessedWidgets fallback.
- widgetValueStore.getWidget renamed to _lookupWidgetState (private lookup
  primitive used only by widgetValueIO); external callers go through the IO
  module so the branded WidgetEntityId prevents producer/consumer drift.
- stripGraphPrefix renamed to extractRawNodeId with a doc comment noting
  that its remaining use in useProcessedWidgets is DOM identity scoping,
  not widget lookup.
- promotedWidgetView.getLinkedInputWidgets simplified to return IBaseWidget[]
  now that the (nodeId, widgetName) triple is no longer needed.

Amp-Thread-ID: https://ampcode.com/threads/T-019e6c3f-ac88-75ac-bce3-4aacbe45c490
Co-authored-by: Amp <amp@ampcode.com>
2026-05-28 00:42:05 -07:00
DrJKL
7d867b5960 refactor(world): collapse widget entityId derivation
Introduce deriveWidgetEntityId(graphId, nodeId, name) as the single guard
for raw widget identity. BaseWidget.entityId and getWidgetEntityIdForNode
both delegate to it, replacing two near-duplicate guard blocks. Adds a
defensive nodeId === -1 check to the BaseWidget path (previously only
guarded in getWidgetEntityIdForNode) — unreachable in production today,
but correctness-positive.

Amp-Thread-ID: https://ampcode.com/threads/T-019e6c3f-ac88-75ac-bce3-4aacbe45c490
Co-authored-by: Amp <amp@ampcode.com>
2026-05-28 00:42:04 -07:00
DrJKL
55d0010011 refactor(world): invert IBaseWidget — extend World widget shape interfaces
Define per-bucket shape interfaces (WidgetValueShape, WidgetDisplayShape,
WidgetSchemaShape, WidgetSerializeShape) in src/world/widgets/widgetComponents.ts
and have IBaseWidget in litegraph extend them. WidgetState in src/world/
composes the same shapes directly rather than picking from IBaseWidget,
eliminating the cross-layer Pick dependency and the three eslint-disable
import-x/no-restricted-paths comments.

Amp-Thread-ID: https://ampcode.com/threads/T-019e6c3f-ac88-75ac-bce3-4aacbe45c490
Co-authored-by: Amp <amp@ampcode.com>
2026-05-28 00:42:04 -07:00
DrJKL
36fe47670a refactor: relocate NodeId, UUID out of litegraph
Move the canonical type aliases out of litegraph so src/world/entityIds.ts
can reference them without an ESLint disable. Litegraph re-exports both
types for backward compatibility, so no callers need to migrate.

Amp-Thread-ID: https://ampcode.com/threads/T-019e6c3f-ac88-75ac-bce3-4aacbe45c490
Co-authored-by: Amp <amp@ampcode.com>
2026-05-28 00:42:03 -07:00
GitHub Action
b1ffc074d9 [automated] Apply ESLint and Oxfmt fixes 2026-05-27 21:03:10 +00:00
DrJKL
fb502ffb6d refactor(world): consolidate World substrate phase 2
- feat(world): slice 1 - World substrate + WidgetValue bridge
- refactor(world): collapse substrate, colocate domain components
- refactor(widgetValueStore): rewrite as World facade
- refactor(world): delete bridge, revert BaseWidget/useUpstreamValue
- docs: add ECS pattern survey appendix
- refactor(world): drop widgetParent, centralize entity-id format
- refactor(world): trim verbose comments to intent only
- fix: address World substrate review feedback
- refactor(world): split WidgetValue into per-aspect components
- chore: drop unused exports flagged by knip
- refactor(world): move widget types to src/world/widgets/
- test(nodeDisplay): convert unknown converted widget test to vue-nodes
2026-05-27 13:57:13 -07:00
60 changed files with 1417 additions and 783 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,19 +239,20 @@ 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 |
| [ADR 0009: Subgraph promoted widgets](0009-subgraph-promoted-widgets-use-linked-inputs.md) | Follow-up decision for promoted widget identity and value ownership at subgraph boundaries |
| [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 |
| [ADR 0009: Subgraph promoted widgets](0009-subgraph-promoted-widgets-use-linked-inputs.md) | Follow-up decision for promoted widget identity and value ownership at subgraph boundaries |
| [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

@@ -14,8 +14,8 @@ reads could collapse back to the interior source widget, while host
flowchart TD
workflow[Workflow JSON] --> proxyWidgets[properties.proxyWidgets]
workflow --> hostValues[host widgets_values]
proxyWidgets --> promotionStore[PromotionStore / promotion runtime]
promotionStore --> sourceWidget[Interior source widget]
proxyWidgets --> legacyRuntime[Legacy promotion runtime (removed)]
legacyRuntime --> sourceWidget[Interior source widget]
linkedInput[Linked SubgraphInput] --> hostWidget[Host promoted widget]
sourceWidget --> hostWidget
hostValues --> hostWidget
@@ -27,7 +27,7 @@ flowchart TD
classDef ambiguous fill:#f8d7da,stroke:#842029,color:#330000
classDef canonical fill:#d1e7dd,stroke:#0f5132,color:#052e16
class proxyWidgets,promotionStore legacy
class proxyWidgets,legacyRuntime legacy
class sourceWidget,hostValues ambiguous
class linkedInput,hostWidget canonical
```

View File

@@ -0,0 +1,388 @@
# Appendix: ECS Pattern Survey
_A survey of mainstream Entity Component System libraries — bitECS, miniplex,
koota, ECSY, Thyseus, 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. Thyseus is called out specifically because it is the most
Bevy-shaped of the TypeScript ECSs surveyed — its `Commands` parameter is the
closest external analog to the command layer ADR 0003 / ADR 0008 are
converging on, so it gets dedicated treatment in §2.5 and §3.5._
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
Six 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 |
| [Thyseus](https://github.com/JaimeGensler/thyseus) | Colocated with the consumer | plain ES6 `class` (instances stored as values) | numeric (via handle) | ~25 (`World`/`Schedule`/`Query`/`Commands`/filters/`Resource`/`Event`) |
| [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, Thyseus's
`import { Position, Velocity } from './components'` convention) 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 and Thyseus are the outliers: ECSY exposes a
wider class hierarchy, and Thyseus exposes a broader Bevy-shaped
surface (Commands, Schedules, Resources, Events, filter combinators)
because it commits to a full system-execution runtime, not just
storage.
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()`, and Thyseus hands back a numeric handle wrapped in `Commands`
APIs. 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.
### 2.5 Commands pattern (Thyseus / Bevy) — direction we are converging on
Thyseus mutates the World exclusively through a `Commands` system
parameter:
```ts
export function spawnEntities(commands: Commands) {
commands.spawn().add(new Position()).add(new Velocity(1, 2))
}
```
`commands.spawn()`, `.add(component)`, and `.remove(component)` enqueue
deferred mutations against a command buffer; the World applies them at
defined sync points in the schedule. This is the same shape Bevy uses
and is the closest direct external analog to the mutation layer
[ADR 0003](../adr/0003-crdt-based-layout-system.md) and the
[World API and Command Layer](./ecs-world-command-api.md) describe for
this codebase.
We deliberately match the **shape** of this pattern: external callers
submit commands; only the executor calls the World's imperative
`setComponent` / `deleteEntity`. ADR 0008 §"Relationship to ADR 0003"
spells this out, and the parallel with Thyseus is intentional — when we
extend slice 1 with a command executor, the public seam will look much
more like Thyseus's `Commands` than like koota's `entity.set(...)` or
bitECS's `addComponent(world, ...)`.
What we deliberately do **not** copy from Thyseus's commands surface,
yet:
- **Deferred buffering with schedule sync points.** Thyseus batches
commands and flushes them at well-defined frame phases for archetype
efficiency. Our command executor stays synchronous in slice 1 because
Vue reactivity wants writes to be observable in the same microtask,
and we have no archetype churn cost to amortize.
- **Auto-injected `Commands` parameter.** Thyseus's runtime inspects
system signatures and injects `Commands`, `Query<...>`, `Res<...>`,
etc. We do not have a system-runner yet (see §3.5), so commands today
are called through a plain executor module rather than constructor
injection.
The point of calling Thyseus out separately is that when ADR 0008 lands
its command executor slice, "what does this look like in Thyseus?" is a
load-bearing comparison point — not a curiosity. Diverging from the
Bevy/Thyseus shape there should require an explicit justification, not
silent drift.
---
## 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, miniplex, and Thyseus use sparse-set / archetype storage
internally for cache locality — Thyseus is explicitly archetypal and
sells "lean memory use and cache-friendly iteration" as a headline
feature. 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.
### 3.5 Thyseus-style system runner, schedules, and worker threads
Thyseus ships a full execution runtime alongside its storage:
- **System functions as units of work**, written as plain functions
whose parameters (`Commands`, `Query<[Position, Velocity]>`,
`Res<Time>`, `Maybe<Velocity>`, `With<Active>`, `Without<Frozen>`)
describe the data they read and write.
- **Schedules** (`class SetupSchedule extends Schedule {}`,
`world.runSchedule(SetupSchedule)`) name groups of systems and control
ordering / frequency, including fixed-update patterns.
- **Boilerplate-free worker threads** for running disjoint systems in
parallel without `eval()`.
- **Builder `World`** assembled imperatively
(`new World().addSystems(SetupSchedule, spawnEntities).prepare()`).
We deliberately do not adopt any of this in slice 1. The reasons:
1. **Vue already owns scheduling.** Reactivity-driven recomputation,
`watch`, and component render passes are how work runs in this
codebase. Inserting a parallel system scheduler would mean every
piece of work has two possible execution contexts, and consumers
would have to know which one applies. ADR 0008's planned executor is
a thin command-application layer, not a fixed-step ECS schedule.
2. **No parallelism budget to spend.** Worker-thread parallelism pays
off when systems are CPU-bound and clearly data-disjoint. ComfyUI
frontend's hot paths are render and DOM-bound; the cost of marshaling
state across threads would dwarf any gain at our entity counts.
3. **Constructor-style parameter injection has a real DX cost.**
Thyseus's `Query<[Position, Velocity]>` injection requires the
runtime to introspect and resolve types at registration time. That
couples every system to the runner. The plain-function +
`world.getComponent` shape we use today stays trivially testable
without a `World` fixture.
Revisitable if (a) we end up running solver-style passes that are
clearly CPU-bound and disjoint, or (b) the command executor grows enough
phase ordering that an explicit schedule abstraction earns its keep over
ad-hoc call sites. Until then, "Thyseus has a scheduler so we should
too" is not a sufficient argument — the slice-1 substrate intentionally
stops at storage + identity.
---
## 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.
**Thyseus-style system runner / schedule / worker threads.** Revisitable
only when the command executor grows multiple explicit phases that have
to be ordered against each other, or when a profiled CPU-bound, clearly
data-disjoint pass shows worker-thread parallelism would pay for the
marshaling cost. Until both of those conditions land in a real ticket,
keep the substrate at storage + identity and let Vue own scheduling.
---
## 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

@@ -212,7 +212,6 @@ graph LR
B3["computedHeight, margin"]
B4["drawWidget(), onClick()"]
B5["useWidgetValueStore()"]
B6["usePromotionStore()"]
end
subgraph After["WidgetEntityId + Components"]
@@ -367,7 +366,6 @@ graph TD
Canvas["LGraphCanvas"] -->|"node.graph._version++"| Graph
Canvas -->|"node.graph.remove(node)"| Graph
Widget["BaseWidget"] -->|"useWidgetValueStore()"| Store1["Pinia Store"]
Widget -->|"usePromotionStore()"| Store2["Pinia Store"]
Node -->|"useLayoutMutations()"| Store3["Layout Store"]
Graph -->|"useLayoutMutations()"| Store3
LLink["LLink"] -->|"useLayoutMutations()"| Store3

View File

@@ -59,12 +59,11 @@ This means the render pass is not idempotent — drawing a node changes its stat
### Store Dependencies in Domain Objects
`BaseWidget` (line 20-22) imports two Pinia stores at the module level:
`BaseWidget` imports a Pinia store at the module level:
- `usePromotionStore` — queried on every `getOutlineColor()` call
- `useWidgetValueStore` — widget state delegation via `setNodeId()`
Similarly, `LGraph` (lines 10-13) imports `useLayoutMutations`, `usePromotionStore`, and `useWidgetValueStore`. Domain objects should not have direct dependencies on UI framework stores.
Similarly, `LGraph` imports `useLayoutMutations` and `useWidgetValueStore`. Domain objects should not have direct dependencies on UI framework stores.
### Serialization Interleaved with Container Logic
@@ -171,7 +170,7 @@ Domain objects call Pinia composables at the module level or in methods, creatin
- `LLink.ts:24``const layoutMutations = useLayoutMutations()` (module scope)
- `Reroute.ts` — same pattern at module scope
- `BaseWidget.ts:20-22` — imports `usePromotionStore` and `useWidgetValueStore`
- `BaseWidget.ts` — imports `useWidgetValueStore`
These make the domain objects untestable without a Vue app context.
@@ -192,7 +191,6 @@ The render pass is not pure — it mutates state as a side effect:
| ----------------------------------- | ------------------------------------------------------------------- |
| `LGraphCanvas.drawNode()` line 5562 | `node._setConcreteSlots()` — rebuilds concrete slot arrays |
| `LGraphCanvas.drawNode()` line 5564 | `node.arrange()` — recalculates widget positions and sizes |
| `BaseWidget.getOutlineColor()` | Queries `PromotionStore` on every frame |
| Link rendering | Caches `_pos` center point and `_centreAngle` on the LLink instance |
This means:

View File

@@ -303,22 +303,29 @@ must choose before Phase 3 of the migration.
### Current mechanism
The current system has three layers:
> **Historical note:** the legacy three-layer mechanism described below
> (PromotionStore, PromotedWidgetViewManager, PromotedWidgetView) has been
> removed by [ADR 0009](../adr/0009-subgraph-promoted-widgets-use-linked-inputs.md).
> Promoted value widgets are now standard linked `SubgraphInput` widgets.
> This section is retained for archival context.
1. **PromotionStore** (`src/stores/promotionStore.ts`): A ref-counted Pinia
store mapping `graphId → subgraphNodeId → PromotedWidgetSource[]`. Tracks
which interior widgets are promoted and provides O(1) `isPromotedByAny()`
queries.
The legacy system had three layers:
2. **PromotedWidgetViewManager**: A reconciliation layer that maintains stable
`PromotedWidgetView` proxy widget objects, diffing against the store on each
update — a pattern analogous to virtual DOM reconciliation.
1. **PromotionStore** (removed; formerly `src/stores/promotionStore.ts`): A
ref-counted Pinia store mapping
`graphId → subgraphNodeId → PromotedWidgetSource[]`. Tracked which interior
widgets were promoted and provided O(1) `isPromotedByAny()` queries.
3. **PromotedWidgetView**: A proxy widget on the SubgraphNode that mirrors the
interior widget's type, value, and options. Reads and writes delegate to the
original widget's entry in `WidgetValueStore`.
2. **PromotedWidgetViewManager** (removed): A reconciliation layer that
maintained stable `PromotedWidgetView` proxy widget objects, diffing against
the store on each update — a pattern analogous to virtual DOM reconciliation.
Serialized as `properties.proxyWidgets` on the SubgraphNode.
3. **PromotedWidgetView** (removed): A proxy widget on the SubgraphNode that
mirrored the interior widget's type, value, and options. Reads and writes
delegated to the original widget's entry in `WidgetValueStore`.
Serialized as `properties.proxyWidgets` on the SubgraphNode (now consumed only
during legacy load repair).
### Candidate A: Connections-only

View File

@@ -6,7 +6,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import { app } from '@/scripts/app'
import { useAppModeStore } from '@/stores/appModeStore'
import type { WidgetEntityId } from '@/world/entityIds'
import { widgetEntityId } from '@/world/entityIds'
import { useResolvedSelectedInputs } from './useResolvedSelectedInputs'
@@ -22,14 +22,14 @@ vi.mock('@/scripts/app', () => ({
}))
const rootGraphId = '11111111-1111-4111-8111-111111111111'
const entitySeed = `${rootGraphId}:1:seed` as WidgetEntityId
const entitySeed = widgetEntityId(rootGraphId, 1, 'seed')
function makeNode(id: number, widgetNames: string[]): LGraphNode {
return fromAny<LGraphNode, unknown>({
id,
widgets: widgetNames.map((name) => ({
name,
entityId: `${rootGraphId}:${id}:${name}` as WidgetEntityId
entityId: widgetEntityId(rootGraphId, id, name)
}))
})
}

View File

@@ -16,10 +16,7 @@ import {
shouldExpand
} from '@/renderer/extensions/vueNodes/widgets/registry/widgetRegistry'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import {
useWidgetValueStore,
stripGraphPrefix
} from '@/stores/widgetValueStore'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import { useFavoritedWidgetsStore } from '@/stores/workspace/favoritedWidgetsStore'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import { resolveNodeDisplayName } from '@/utils/nodeTitleUtil'
@@ -58,8 +55,8 @@ const { t } = useI18n()
const canvasStore = useCanvasStore()
const nodeDefStore = useNodeDefStore()
const widgetValueStore = useWidgetValueStore()
const favoritedWidgetsStore = useFavoritedWidgetsStore()
const widgetValueStore = useWidgetValueStore()
const isEditing = ref(false)
const widgetComponent = computed(() => {
@@ -74,10 +71,8 @@ function resolveSourceWidget(): { node: LGraphNode; widget: IBaseWidget } {
const simplifiedWidget = computed((): SimplifiedWidget => {
const { node: sourceNode, widget: sourceWidget } = resolveSourceWidget()
const graphId = node.graph?.rootGraph?.id
const bareNodeId = stripGraphPrefix(String(sourceNode.id))
const widgetState = graphId
? widgetValueStore.getWidget(graphId, bareNodeId, sourceWidget.name)
const widgetState = sourceWidget.entityId
? widgetValueStore.getWidget(sourceWidget.entityId)
: undefined
return {

View File

@@ -7,7 +7,7 @@ import { computed, nextTick, watch } from 'vue'
import { useGraphNodeManager } from '@/composables/graph/useGraphNodeManager'
import { createPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetView'
import { BaseWidget, LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
import { widgetEntityId } from '@/world/entityIds'
import { asGraphId, widgetEntityId } from '@/world/entityIds'
import {
createTestSubgraph,
createTestSubgraphNode
@@ -47,9 +47,10 @@ describe('Node Reactivity', () => {
expect((widget as BaseWidget).node.id).toBe(node.id)
// Initial value should be in store after setNodeId was called
expect(store.getWidget(graph.id, node.id, 'testnum')?.value).toBe(2)
const testnumId = widgetEntityId(asGraphId(graph.id), node.id, 'testnum')
expect(store.getWidget(testnumId)?.value).toBe(2)
const state = store.getWidget(graph.id, node.id, 'testnum')
const state = store.getWidget(testnumId)
if (!state) throw new Error('Expected widget state to exist')
const onValueChange = vi.fn()
@@ -74,7 +75,9 @@ describe('Node Reactivity', () => {
})
await nextTick()
const state = store.getWidget(graph.id, node.id, 'testnum')
const state = store.getWidget(
widgetEntityId(asGraphId(graph.id), node.id, 'testnum')
)
if (!state) throw new Error('Expected widget state to exist')
const widgetValue = computed(() => state.value)

View File

@@ -3,7 +3,7 @@ import { computed, toValue } from 'vue'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
import type { UUID } from '@/lib/litegraph/src/utils/uuid'
import type { UUID } from '@/utils/uuid'
import { useNodeOutputStore } from '@/stores/nodeOutputStore'
import { usePreviewExposureStore } from '@/stores/previewExposureStore'
import { createNodeLocatorId } from '@/types/nodeIdentification'

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 '@/utils/uuid'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import type { WidgetState } from '@/stores/widgetValueStore'
import type { NodeId } from '@/platform/workflow/validation/schemas/workflowSchema'
import { asGraphId, widgetEntityId } from '@/world/entityIds'
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,76 @@ 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 }))
})
it('reads upstream node widgets via the widget value store', () => {
const graphId = '00000000-0000-0000-0000-000000000001' as UUID
const widgetId = widgetEntityId(
asGraphId(graphId),
'upstream-1' as NodeId,
'value'
)
const state = useWidgetValueStore().registerWidget(widgetId, {
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,14 @@
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'
import { asGraphId, nodeEntityId } from '@/world/entityIds'
type ValueExtractor<T = unknown> = (
widgets: WidgetState[],
widgets: Map<string, WidgetState>,
outputName: string | undefined
) => T | undefined
@@ -23,7 +24,9 @@ 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(
nodeEntityId(asGraphId(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

@@ -1,7 +1,7 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { PreviewExposure } from '@/core/schemas/previewExposureSchema'
import type { UUID } from '@/lib/litegraph/src/utils/uuid'
import type { UUID } from '@/utils/uuid'
import type { PreviewExposureChainContext } from './previewExposureChain'
import { resolvePreviewExposureChain } from './previewExposureChain'

View File

@@ -1,5 +1,5 @@
import type { PreviewExposure } from '@/core/schemas/previewExposureSchema'
import type { UUID } from '@/lib/litegraph/src/utils/uuid'
import type { UUID } from '@/utils/uuid'
export interface ResolvedPreviewChainStep {
rootGraphId: UUID

View File

@@ -9,6 +9,7 @@ import {
resetSubgraphFixtureState
} from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import { asGraphId, widgetEntityId } from '@/world/entityIds'
import { isPromotedWidgetView } from './promotedWidgetTypes'
@@ -73,9 +74,12 @@ describe('PromotedWidgetView — host-wins semantics', () => {
subgraph.inputNode.slots[0].connect(interior.inputs[0], interior)
const widgetStore = useWidgetValueStore()
widgetStore.registerWidget(subgraph.rootGraph.id, {
nodeId: String(interior.id),
name: 'widget',
const interiorWidgetId = widgetEntityId(
asGraphId(subgraph.rootGraph.id),
String(interior.id),
'widget'
)
widgetStore.registerWidget(interiorWidgetId, {
type: 'number',
value: 42,
options: {},
@@ -90,11 +94,7 @@ describe('PromotedWidgetView — host-wins semantics', () => {
view.value = 99
const interiorState = widgetStore.getWidget(
subgraph.rootGraph.id,
String(interior.id),
'widget'
)
const interiorState = widgetStore.getWidget(interiorWidgetId)
expect(interiorState?.value).toBe(42)
})
})

View File

@@ -1,4 +1,4 @@
import type { LGraphNode, NodeId } from '@/lib/litegraph/src/LGraphNode'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import type { LGraphCanvas } from '@/lib/litegraph/src/LGraphCanvas'
import type { CanvasPointer } from '@/lib/litegraph/src/CanvasPointer'
import type { Point } from '@/lib/litegraph/src/interfaces'
@@ -11,11 +11,8 @@ import { toConcreteWidget } from '@/lib/litegraph/src/widgets/widgetMap'
import { t } from '@/i18n'
import { nextValueForLinkedTarget } from '@/scripts/valueControl'
import { useDomWidgetStore } from '@/stores/domWidgetStore'
import {
stripGraphPrefix,
useWidgetValueStore
} from '@/stores/widgetValueStore'
import type { WidgetState } from '@/stores/widgetValueStore'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import {
resolveConcretePromotedWidget,
resolvePromotedWidgetAtHost
@@ -24,7 +21,6 @@ import { matchPromotedInput } from '@/core/graph/subgraph/matchPromotedInput'
import { hasWidgetNode } from '@/core/graph/subgraph/widgetNodeTypeGuard'
import type { WidgetEntityId } from '@/world/entityIds'
import { widgetEntityId } from '@/world/entityIds'
import { ensureWidgetState, getWidgetState } from '@/world/widgetValueIO'
import { isPromotedWidgetView } from './promotedWidgetTypes'
import type { PromotedWidgetView as IPromotedWidgetView } from './promotedWidgetTypes'
@@ -162,7 +158,7 @@ class PromotedWidgetView implements IPromotedWidgetView {
}
private getHostWidgetState(): WidgetState | undefined {
return getWidgetState(this.entityId)
return useWidgetValueStore().getWidget(this.entityId)
}
private setHostWidgetState(value: IBaseWidget['value']): void {
@@ -207,8 +203,12 @@ class PromotedWidgetView implements IPromotedWidgetView {
}
private registerHostWidgetState(value: IBaseWidget['value']): void {
const widgetValueStore = useWidgetValueStore()
const existing = widgetValueStore.getWidget(this.entityId)
if (existing) return
const resolved = this.resolveDeepest()
ensureWidgetState(this.entityId, {
widgetValueStore.registerWidget(this.entityId, {
type: resolved?.widget.type ?? 'button',
value,
options: { ...(resolved?.widget.options ?? {}) },
@@ -418,19 +418,11 @@ class PromotedWidgetView implements IPromotedWidgetView {
if (linkedState) return linkedState
const resolved = this.resolveDeepest()
if (!resolved) return undefined
return useWidgetValueStore().getWidget(
this.graphId,
stripGraphPrefix(String(resolved.node.id)),
resolved.widget.name
)
if (!resolved?.widget.entityId) return undefined
return useWidgetValueStore().getWidget(resolved.widget.entityId)
}
private getLinkedInputWidgets(): Array<{
nodeId: NodeId
widgetName: string
widget: IBaseWidget
}> {
private getLinkedInputWidgets(): IBaseWidget[] {
const linkedInputSlot = this.subgraphNode.inputs.find((input) => {
if (!input._subgraphSlot) return false
if (matchPromotedInput([input], this) !== input) return false
@@ -457,23 +449,15 @@ class PromotedWidgetView implements IPromotedWidgetView {
const linkedInput = linkedInputSlot?._subgraphSlot
if (!linkedInput) return []
return linkedInput
.getConnectedWidgets()
.filter(hasWidgetNode)
.map((widget) => ({
nodeId: stripGraphPrefix(String(widget.node.id)),
widgetName: widget.name,
widget
}))
return linkedInput.getConnectedWidgets().filter(hasWidgetNode)
}
private getLinkedInputWidgetStates(): WidgetState[] {
const widgetStore = useWidgetValueStore()
const widgetValueStore = useWidgetValueStore()
return this.getLinkedInputWidgets()
.map(({ nodeId, widgetName }) =>
widgetStore.getWidget(this.graphId, nodeId, widgetName)
)
.map((widget) => widget.entityId)
.filter((id): id is WidgetEntityId => id !== undefined)
.map((id) => widgetValueStore.getWidget(id))
.filter((state): state is WidgetState => state !== undefined)
}

View File

@@ -18,7 +18,7 @@ import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useLitegraphService } from '@/services/litegraphService'
import { usePreviewExposureStore } from '@/stores/previewExposureStore'
import { useSubgraphNavigationStore } from '@/stores/subgraphNavigationStore'
import { readWidgetValue } from '@/world/widgetValueIO'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
type PartialNode = Pick<LGraphNode, 'title' | 'id' | 'type'>
@@ -121,7 +121,7 @@ function getExplicitHostWidgetValue(
if (!widget) return undefined
if (!isPromotedWidgetView(widget)) return widget.value
const value = readWidgetValue(widget.entityId)
const value = useWidgetValueStore().getWidget(widget.entityId)?.value
return isWidgetValue(value) ? value : undefined
}

View File

@@ -11,7 +11,7 @@ import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import type { LLink } from '@/lib/litegraph/src/LLink'
import { commonType } from '@/lib/litegraph/src/utils/type'
import { resolveNodeRootGraphId } from '@/lib/litegraph/src/utils/widget'
import { transformInputSpecV1ToV2 } from '@/schemas/nodeDef/migration'
import type { ComboInputSpec, InputSpec } from '@/schemas/nodeDefSchema'
import type { InputSpec as InputSpecV2 } from '@/schemas/nodeDef/nodeDefSchemaV2'
@@ -187,11 +187,9 @@ function dynamicComboWidget(
//A little hacky, but onConfigure won't work.
//It fires too late and is overly disruptive
let widgetValue = widget.value
const getState = () => {
const graphId = resolveNodeRootGraphId(node)
if (!graphId) return undefined
return useWidgetValueStore().getWidget(graphId, node.id, widget.name)
}
const widgetValueStore = useWidgetValueStore()
const getState = () =>
widget.entityId ? widgetValueStore.getWidget(widget.entityId) : undefined
Object.defineProperty(widget, 'value', {
get() {
return getState()?.value ?? widgetValue

View File

@@ -47,21 +47,20 @@ function onCustomComboCreated(this: LGraphNode) {
const widget = node.addWidget('string', widgetName, '', () => {})
if (!widget) return
let localValue = `${widget.value ?? ''}`
const widgetValueStore = useWidgetValueStore()
Object.defineProperty(widget, 'value', {
get() {
return (
useWidgetValueStore().getWidget(app.rootGraph.id, node.id, widgetName)
?.value ?? localValue
)
const state = widget.entityId
? widgetValueStore.getWidget(widget.entityId)
: undefined
return state?.value ?? localValue
},
set(v: string) {
localValue = v
const state = useWidgetValueStore().getWidget(
app.rootGraph.id,
node.id,
widgetName
)
const state = widget.entityId
? widgetValueStore.getWidget(widget.entityId)
: undefined
if (state) state.value = v
updateCombo()
if (!node.widgets) return

View File

@@ -24,6 +24,7 @@ import { getNodeByLocatorId } from '@/utils/graphTraversalUtil'
import { api } from '../../scripts/api'
import { app } from '../../scripts/app'
import { deriveWidgetEntityId } from '@/world/entityIds'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
function updateUIWidget(
@@ -150,19 +151,27 @@ app.registerExtension({
}
}
audioUIWidget.options.getValue = () =>
(useWidgetValueStore().getWidget(
const widgetValueStore = useWidgetValueStore()
audioUIWidget.options.getValue = () => {
const entityId = deriveWidgetEntityId(
resolveNodeRootGraphId(node, app.rootGraph.id),
node.id,
inputName
)?.value as string) ?? ''
)
const widgetState = entityId
? widgetValueStore.getWidget(entityId)
: undefined
return (widgetState?.value as string) ?? ''
}
audioUIWidget.options.setValue = (v) => {
const graphId = resolveNodeRootGraphId(node, app.rootGraph.id)
const widgetState = useWidgetValueStore().getWidget(
graphId,
const entityId = deriveWidgetEntityId(
resolveNodeRootGraphId(node, app.rootGraph.id),
node.id,
inputName
)
const widgetState = entityId
? widgetValueStore.getWidget(entityId)
: undefined
if (widgetState) widgetState.value = v
}

View File

@@ -12,10 +12,11 @@ import {
SubgraphNode
} from '@/lib/litegraph/src/litegraph'
import type { SerialisableGraph } from '@/lib/litegraph/src/types/serialisation'
import type { UUID } from '@/lib/litegraph/src/utils/uuid'
import { zeroUuid } from '@/lib/litegraph/src/utils/uuid'
import type { UUID } from '@/utils/uuid'
import { zeroUuid } from '@/utils/uuid'
import { usePreviewExposureStore } from '@/stores/previewExposureStore'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import { asGraphId, widgetEntityId } from '@/world/entityIds'
import {
createTestSubgraph,
createTestSubgraphData,
@@ -296,9 +297,8 @@ describe('Graph Clearing and Callbacks', () => {
})
const widgetValueStore = useWidgetValueStore()
widgetValueStore.registerWidget(graphId, {
nodeId: '10' as NodeId,
name: 'seed',
const seedId = widgetEntityId(asGraphId(graphId), '10' as NodeId, 'seed')
widgetValueStore.registerWidget(seedId, {
type: 'number',
value: 1,
options: {},
@@ -307,7 +307,7 @@ describe('Graph Clearing and Callbacks', () => {
disabled: undefined
})
expect(widgetValueStore.getWidget(graphId, '10' as NodeId, 'seed')).toEqual(
expect(widgetValueStore.getWidget(seedId)).toEqual(
expect.objectContaining({ value: 1 })
)
expect(
@@ -316,9 +316,7 @@ describe('Graph Clearing and Callbacks', () => {
graph.clear()
expect(
widgetValueStore.getWidget(graphId, '10' as NodeId, 'seed')
).toBeUndefined()
expect(widgetValueStore.getWidget(seedId)).toBeUndefined()
expect(previewExposureStore.getExposures(graphId, `${graphId}:1`)).toEqual(
[]
)

View File

@@ -5,8 +5,8 @@ import {
SUBGRAPH_OUTPUT_ID
} from '@/lib/litegraph/src/constants'
import { isNodeBindable } from '@/lib/litegraph/src/utils/type'
import type { UUID } from '@/lib/litegraph/src/utils/uuid'
import { createUuidv4, zeroUuid } from '@/lib/litegraph/src/utils/uuid'
import type { UUID } from '@/utils/uuid'
import { createUuidv4, zeroUuid } from '@/utils/uuid'
import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations'
import { LayoutSource } from '@/renderer/core/layout/types'
import { usePreviewExposureStore } from '@/stores/previewExposureStore'

View File

@@ -112,7 +112,7 @@ import type { IBaseWidget, TWidgetValue } from './types/widgets'
import { alignNodes, distributeNodes, getBoundaryNodes } from './utils/arrange'
import { findFirstNode, getAllNestedItems } from './utils/collections'
import { resolveConnectingLinkColor } from './utils/linkColors'
import { createUuidv4 } from './utils/uuid'
import { createUuidv4 } from '@/utils/uuid'
import { BaseWidget } from './widgets/BaseWidget'
import { toConcreteWidget } from './widgets/widgetMap'

View File

@@ -96,9 +96,11 @@ import { BaseWidget } from './widgets/BaseWidget'
import { toConcreteWidget } from './widgets/widgetMap'
import type { WidgetTypeMap } from './widgets/widgetMap'
import type { NodeId } from '@/world/entityIds'
// #region Types
export type NodeId = number | string
export type { NodeId }
export type NodeProperty = string | number | boolean | object

View File

@@ -28,7 +28,7 @@ import {
RenderShape,
TitleMode
} from './types/globalEnums'
import { createUuidv4 } from './utils/uuid'
import { createUuidv4 } from '@/utils/uuid'
/**
* The Global Scope. It contains all the registered node classes.

View File

@@ -144,8 +144,8 @@ export type {
} from './types/serialisation'
export type { IWidget } from './types/widgets'
export { isColorable } from './utils/type'
export { createUuidv4 } from './utils/uuid'
export type { UUID } from './utils/uuid'
export { createUuidv4 } from '@/utils/uuid'
export type { UUID } from '@/utils/uuid'
export { truncateText } from './utils/textUtils'
export {
evaluateInput,

View File

@@ -3,7 +3,7 @@ import type { LLink } from '@/lib/litegraph/src/LLink'
import type { RerouteId } from '@/lib/litegraph/src/Reroute'
import type { INodeInputSlot, Point } from '@/lib/litegraph/src/interfaces'
import { nextUniqueName } from '@/lib/litegraph/src/strings'
import { zeroUuid } from '@/lib/litegraph/src/utils/uuid'
import { zeroUuid } from '@/utils/uuid'
import { SubgraphInput } from './SubgraphInput'
import type { SubgraphInputNode } from './SubgraphInputNode'

View File

@@ -3,7 +3,7 @@ import type { LLink } from '@/lib/litegraph/src/LLink'
import type { RerouteId } from '@/lib/litegraph/src/Reroute'
import type { INodeOutputSlot, Point } from '@/lib/litegraph/src/interfaces'
import { nextUniqueName } from '@/lib/litegraph/src/strings'
import { zeroUuid } from '@/lib/litegraph/src/utils/uuid'
import { zeroUuid } from '@/utils/uuid'
import { SubgraphOutput } from './SubgraphOutput'
import type { SubgraphOutputNode } from './SubgraphOutputNode'

View File

@@ -45,7 +45,7 @@ import { parseProxyWidgetErrorQuarantine } from '@/core/schemas/proxyWidgetQuara
import { useDomWidgetStore } from '@/stores/domWidgetStore'
import { usePreviewExposureStore } from '@/stores/previewExposureStore'
import { createNodeLocatorId } from '@/types/nodeIdentification'
import { readWidgetValue } from '@/world/widgetValueIO'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import { ExecutableNodeDTO } from './ExecutableNodeDTO'
import type { ExecutableLGraphNode, ExecutionId } from './ExecutableNodeDTO'
@@ -1088,10 +1088,11 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
)
}
const widgetValueStore = useWidgetValueStore()
const widgetValues = this.inputs.flatMap((input) => {
const widget = input._widget
if (!widget || !isPromotedWidgetView(widget)) return []
const value = readWidgetValue(widget.entityId)
const value = widgetValueStore.getWidget(widget.entityId)?.value
return [isWidgetValue(value) ? value : undefined]
})

View File

@@ -21,8 +21,8 @@ import type {
Serialisable,
SubgraphIO
} from '@/lib/litegraph/src/types/serialisation'
import { createUuidv4 } from '@/lib/litegraph/src/utils/uuid'
import type { UUID } from '@/lib/litegraph/src/utils/uuid'
import { createUuidv4 } from '@/utils/uuid'
import type { UUID } from '@/utils/uuid'
import type { SubgraphInput } from './SubgraphInput'
import type { SubgraphInputNode } from './SubgraphInputNode'

View File

@@ -24,6 +24,7 @@ import { usePreviewExposureStore } from '@/stores/previewExposureStore'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import { createNodeLocatorId } from '@/types/nodeIdentification'
import { graphToPrompt } from '@/utils/executionUtil'
import { asGraphId, nodeEntityId, widgetEntityId } from '@/world/entityIds'
import {
createEventCapture,
@@ -588,13 +589,18 @@ describe('SubgraphWidgetPromotion', () => {
const hostNode = createTestSubgraphNode(subgraph)
const hostWidget = hostNode.widgets[0]
expectPromotedWidgetView(hostWidget)
useWidgetValueStore().registerWidget(hostNode.rootGraph.id, {
nodeId: hostNode.id,
name: hostWidget.name,
type: hostWidget.type,
value: 99,
options: {}
})
useWidgetValueStore().registerWidget(
widgetEntityId(
asGraphId(hostNode.rootGraph.id),
hostNode.id,
hostWidget.name
),
{
type: hostWidget.type,
value: 99,
options: {}
}
)
hostNode.serialize()
expect(interiorWidget.value).toBe(42)
@@ -944,8 +950,10 @@ describe('SubgraphWidgetPromotion', () => {
if (c.expect.storeSeedValue !== undefined) {
expect(
useWidgetValueStore()
.getNodeWidgets(host.rootGraph.id, host.id)
.find((entry) => entry.name === 'seed')?.value
.getNodeWidgetsByName(
nodeEntityId(asGraphId(host.rootGraph.id), host.id)
)
.get('seed')?.value
).toBe(c.expect.storeSeedValue)
}
})
@@ -1006,13 +1014,14 @@ describe('SubgraphWidgetPromotion', () => {
const host = createTestSubgraphNode(subgraph)
const widgetStore = useWidgetValueStore()
for (const { node, widget } of sources) {
widgetStore.registerWidget(host.rootGraph.id, {
nodeId: node.id,
name: widget.name,
type: widget.type,
value: `${node.title} value`,
options: {}
})
widgetStore.registerWidget(
widgetEntityId(asGraphId(host.rootGraph.id), node.id, widget.name),
{
type: widget.type,
value: `${node.title} value`,
options: {}
}
)
}
reorderSubgraphInputsByName(host, ['second', 'first'])
expect(host.serialize().widgets_values).toBeUndefined()
@@ -1032,13 +1041,18 @@ describe('SubgraphWidgetPromotion', () => {
const host = createTestSubgraphNode(subgraph, { id: 101 })
const widgetStore = useWidgetValueStore()
widgetStore.registerWidget(host.rootGraph.id, {
nodeId: interiorNode.id,
name: interiorWidget.name,
type: interiorWidget.type,
value: 'source fallback',
options: {}
})
widgetStore.registerWidget(
widgetEntityId(
asGraphId(host.rootGraph.id),
interiorNode.id,
interiorWidget.name
),
{
type: interiorWidget.type,
value: 'source fallback',
options: {}
}
)
const serialized = host.serialize()
expect(serialized.widgets_values).toBeUndefined()
@@ -1047,7 +1061,9 @@ describe('SubgraphWidgetPromotion', () => {
reloaded.configure(serialized)
expect(
widgetStore.getNodeWidgets(reloaded.rootGraph.id, reloaded.id)
widgetStore.getNodeWidgets(
nodeEntityId(asGraphId(reloaded.rootGraph.id), reloaded.id)
)
).toEqual([])
expect(reloaded.serialize().widgets_values).toBeUndefined()
})
@@ -1073,14 +1089,27 @@ describe('SubgraphWidgetPromotion', () => {
expectPromotedWidgetView(first)
expectPromotedWidgetView(second)
expect(
widgetStore.getWidget(reloaded.rootGraph.id, reloaded.id, first.name)
widgetStore.getWidget(
widgetEntityId(
asGraphId(reloaded.rootGraph.id),
reloaded.id,
first.name
)
)
).toBeUndefined()
expect(
widgetStore.getWidget(reloaded.rootGraph.id, reloaded.id, second.name)
?.value
widgetStore.getWidget(
widgetEntityId(
asGraphId(reloaded.rootGraph.id),
reloaded.id,
second.name
)
)?.value
).toBe('second host value')
expect(
widgetStore.getNodeWidgets(reloaded.rootGraph.id, reloaded.id)
widgetStore.getNodeWidgets(
nodeEntityId(asGraphId(reloaded.rootGraph.id), reloaded.id)
)
).toHaveLength(1)
expect(reloaded.serialize().widgets_values).toEqual([
undefined,

View File

@@ -1,4 +1,4 @@
import type { UUID } from '@/lib/litegraph/src/utils/uuid'
import type { UUID } from '@/utils/uuid'
import type {
LGraphConfig,

View File

@@ -1,6 +1,7 @@
import type { Bounds } from '@/renderer/core/layout/types'
import type { CurveData } from '@/components/curve/types'
import type { WidgetEntityId } from '@/world/entityIds'
import type { WidgetState } from '@/world/widgets/widgetState'
import type {
CanvasColour,
@@ -394,7 +395,7 @@ export interface IBaseWidget<
TValue = boolean | number | string | object | undefined,
TType extends string = string,
TOptions extends IWidgetOptions = IWidgetOptions
> {
> extends WidgetState<TValue, TType, TOptions> {
[symbol: symbol]: boolean
linkedWidgets?: IBaseWidget[]
@@ -402,25 +403,9 @@ export interface IBaseWidget<
readonly entityId?: WidgetEntityId
name: string
options: TOptions
label?: string
/** Widget type (see {@link TWidgetType}) */
type: TType
value?: TValue
/**
* Whether the widget value is persisted in the workflow JSON
* (`widgets_values`). Checked by {@link LGraphNode.serialize} and
* {@link LGraphNode.configure}.
*
* This is distinct from {@link IWidgetOptions.serialize}, which controls
* whether the value is included in the API prompt sent for execution.
*
* @default true
* @see IWidgetOptions.serialize — API prompt inclusion
*/
serialize?: boolean
/**
* The computed height of the widget. Used by customized node resize logic.
@@ -444,15 +429,10 @@ export interface IBaseWidget<
last_y?: number
width?: number
/**
* Whether the widget is disabled. Disabled widgets are rendered at half opacity.
* See also {@link IBaseWidget.computedDisabled}.
*/
disabled?: boolean
/**
* The disabled state used for rendering based on various conditions including
* {@link IBaseWidget.disabled}.
* {@link WidgetState.disabled}.
* @readonly [Computed] This property is computed by the node.
*/
computedDisabled?: boolean

View File

@@ -1,6 +1,6 @@
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import type { IWidgetOptions } from '@/lib/litegraph/src/types/widgets'
import type { UUID } from '@/lib/litegraph/src/utils/uuid'
import type { UUID } from '@/utils/uuid'
import { evaluateMathExpression } from '@/lib/litegraph/src/utils/mathParser'

View File

@@ -4,9 +4,23 @@ import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it } from 'vitest'
import { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { NodeId } from '@/lib/litegraph/src/LGraphNode'
import type { INumericWidget } from '@/lib/litegraph/src/types/widgets'
import { NumberWidget } from '@/lib/litegraph/src/widgets/NumberWidget'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import type { UUID } from '@/utils/uuid'
import { asGraphId, widgetEntityId } from '@/world/entityIds'
function lookup(
store: ReturnType<typeof useWidgetValueStore>,
graphId: string,
nodeId: NodeId,
name: string
) {
return store.getWidget(
widgetEntityId(asGraphId(graphId as UUID), nodeId, name)
)
}
function createTestWidget(
node: LGraphNode,
@@ -95,7 +109,7 @@ describe('BaseWidget store integration', () => {
widget.disabled = true
widget.advanced = true
const state = store.getWidget(graph.id, 1, 'writeWidget')
const state = lookup(store, graph.id, 1, 'writeWidget')
expect(state?.label).toBe('Updated Label')
expect(state?.disabled).toBe(true)
@@ -108,9 +122,9 @@ describe('BaseWidget store integration', () => {
widget.setNodeId(1)
widget.value = 99
expect(store.getWidget(graph.id, 1, 'valueWidget')?.value).toBe(99)
expect(lookup(store, graph.id, 1, 'valueWidget')?.value).toBe(99)
const state = store.getWidget(graph.id, 1, 'valueWidget')!
const state = lookup(store, graph.id, 1, 'valueWidget')!
state.value = 55
expect(widget.value).toBe(55)
})
@@ -128,10 +142,8 @@ describe('BaseWidget store integration', () => {
})
widget.setNodeId(1)
const state = store.getWidget(graph.id, 1, 'autoRegWidget')
const state = lookup(store, 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')
@@ -146,7 +158,7 @@ describe('BaseWidget store integration', () => {
const widget = createTestWidget(node, { name: 'defaultsWidget' })
widget.setNodeId(1)
const state = store.getWidget(graph.id, 1, 'defaultsWidget')
const state = lookup(store, graph.id, 1, 'defaultsWidget')
expect(state).toBeDefined()
expect(state?.disabled).toBe(false)
expect(state?.label).toBeUndefined()
@@ -159,7 +171,7 @@ describe('BaseWidget store integration', () => {
const widget = createTestWidget(node, { name: 'valuesWidget', value: 77 })
widget.setNodeId(1)
expect(store.getWidget(graph.id, 1, 'valuesWidget')?.value).toBe(77)
expect(lookup(store, graph.id, 1, 'valuesWidget')?.value).toBe(77)
})
})
@@ -177,20 +189,20 @@ describe('BaseWidget store integration', () => {
get() {
const graphId = widget.node.graph?.rootGraph.id
if (!graphId) return defaultValue
const state = store.getWidget(graphId, node.id, 'system_prompt')
const state = lookup(store, graphId, node.id, 'system_prompt')
return (state?.value as string) ?? defaultValue
},
set(v: string) {
const graphId = widget.node.graph?.rootGraph.id
if (!graphId) return
const state = store.getWidget(graphId, node.id, 'system_prompt')
const state = lookup(store, graphId, node.id, 'system_prompt')
if (state) state.value = v
}
})
widget.setNodeId(node.id)
const state = store.getWidget(graph.id, node.id, 'system_prompt')
const state = lookup(store, graph.id, node.id, 'system_prompt')
expect(state?.value).toBe(defaultValue)
})
})
@@ -211,7 +223,7 @@ describe('BaseWidget store integration', () => {
widget.disabled = undefined
const state = store.getWidget(graph.id, 1, 'testWidget')
const state = lookup(store, graph.id, 1, 'testWidget')
expect(state?.disabled).toBe(false)
})
})

View File

@@ -20,7 +20,7 @@ import type {
import type { WidgetState } from '@/stores/widgetValueStore'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import type { WidgetEntityId } from '@/world/entityIds'
import { widgetEntityId } from '@/world/entityIds'
import { deriveWidgetEntityId } from '@/world/entityIds'
export interface DrawWidgetOptions {
/** The width of the node where this widget will be displayed. */
@@ -85,8 +85,8 @@ export abstract class BaseWidget<TWidget extends IBaseWidget = IBaseWidget>
computedDisabled?: boolean
tooltip?: string
private _state: Omit<WidgetState, 'nodeId'> &
Partial<Pick<WidgetState, 'nodeId'>>
private _state: WidgetState
private _nodeId?: NodeId
get label(): string | undefined {
return this._state.label
@@ -133,10 +133,11 @@ export abstract class BaseWidget<TWidget extends IBaseWidget = IBaseWidget>
}
get entityId(): WidgetEntityId | undefined {
const graphId = this.node.graph?.rootGraph.id
const nodeId = this._state.nodeId
if (!graphId || nodeId === undefined) return undefined
return widgetEntityId(graphId, nodeId, this.name)
return deriveWidgetEntityId(
this.node.graph?.rootGraph.id,
this._nodeId,
this.name
)
}
/**
@@ -144,16 +145,16 @@ export abstract class BaseWidget<TWidget extends IBaseWidget = IBaseWidget>
* Once set, value reads/writes will be delegated to the store.
*/
setNodeId(nodeId: NodeId): void {
const graphId = this.node.graph?.rootGraph.id
if (!graphId) return
this._nodeId = nodeId
const widgetId = this.entityId
if (!widgetId) return
this._state = useWidgetValueStore().registerWidget(graphId, {
this._state = useWidgetValueStore().registerWidget(widgetId, {
...this._state,
// BaseWidget: this.value getter returns this._state.value. So value: this.value === value: this._state.value.
// 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,
nodeId
value: this.value
})
}
@@ -202,7 +203,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

@@ -294,16 +294,17 @@ describe('NodeWidgets', () => {
const { container } = renderComponent(nodeData)
const widgetValueStore = useWidgetValueStore()
widgetValueStore.registerWidget('graph-test', {
nodeId: 'test_node',
name: 'test_widget',
type: 'combo',
value: 'value',
options: { hidden: true },
label: undefined,
serialize: true,
disabled: false
})
widgetValueStore.registerWidget(
widgetEntityId(GRAPH_ID, 'test_node', 'test_widget'),
{
type: 'combo',
value: 'value',
options: { hidden: true },
label: undefined,
serialize: true,
disabled: false
}
)
await nextTick()

View File

@@ -10,6 +10,7 @@ import { useSettingStore } from '@/platform/settings/settingStore'
import type { NodeBadgeProps } from '@/renderer/extensions/vueNodes/components/NodeBadge.vue'
import { app } from '@/scripts/app'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import { deriveWidgetEntityId } from '@/world/entityIds'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import { NodeBadgeMode } from '@/types/nodeSource'
@@ -40,13 +41,13 @@ export function trackNodePrice(node: TrackableNode) {
// Access only the widget values that affect pricing (from widgetValueStore)
const relevantNames = getRelevantWidgetNames(node.type)
const widgetStore = useWidgetValueStore()
const graphId = app.canvas?.graph?.rootGraph.id
if (relevantNames.length > 0 && node.id != null) {
const widgetValueStore = useWidgetValueStore()
for (const name of relevantNames) {
// Access value from store to create reactive dependency
if (!graphId) continue
void widgetStore.getWidget(graphId, node.id, name)?.value
const entityId = deriveWidgetEntityId(graphId, node.id, name)
if (entityId) void widgetValueStore.getWidget(entityId)?.value
}
}
// Access input connections for regular inputs
@@ -142,13 +143,13 @@ export function usePartitionedBadges(nodeData: VueNodeData) {
if (isDynamicPricing.value) {
// Access only the widget values that affect pricing (from widgetValueStore)
const relevantNames = relevantPricingWidgets.value
const widgetStore = useWidgetValueStore()
const graphId = app.canvas?.graph?.rootGraph.id
if (relevantNames.length > 0 && nodeData?.id != null) {
const widgetValueStore = useWidgetValueStore()
for (const name of relevantNames) {
// Access value from store to create reactive dependency
if (!graphId) continue
void widgetStore.getWidget(graphId, nodeData.id, name)?.value
const entityId = deriveWidgetEntityId(graphId, nodeData.id, name)
if (entityId) void widgetValueStore.getWidget(entityId)?.value
}
}
// Access input connections for regular inputs

View File

@@ -13,7 +13,7 @@ import {
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import { widgetEntityId } from '@/world/entityIds'
import { deriveWidgetEntityId, widgetEntityId } from '@/world/entityIds'
const GRAPH_ID = 'graph-test'
@@ -432,13 +432,14 @@ describe('createWidgetUpdateHandler (via computeProcessedWidgets)', () => {
callback
})
useWidgetValueStore().registerWidget(GRAPH_ID, {
nodeId: NODE_ID,
name: 'seed',
type: 'combo',
value: 0,
options: {}
})
useWidgetValueStore().registerWidget(
widgetEntityId(GRAPH_ID, NODE_ID, 'seed'),
{
type: 'combo',
value: 0,
options: {}
}
)
const [processed] = processWidgets([widget])
processed.updateHandler(42)
@@ -466,18 +467,22 @@ describe('createWidgetUpdateHandler (via computeProcessedWidgets)', () => {
nodeId: NODE_ID
})
useWidgetValueStore().registerWidget(GRAPH_ID, {
nodeId: NODE_ID,
name: 'seed',
type: 'combo',
value: 0,
options: {}
})
useWidgetValueStore().registerWidget(
widgetEntityId(GRAPH_ID, NODE_ID, 'seed'),
{
type: 'combo',
value: 0,
options: {}
}
)
const [processed] = processWidgets([widget])
processed.updateHandler(99)
const state = useWidgetValueStore().getWidget(GRAPH_ID, NODE_ID, 'seed')
const entityId = deriveWidgetEntityId(GRAPH_ID, NODE_ID, 'seed')
const state = entityId
? useWidgetValueStore().getWidget(entityId)
: undefined
expect(state?.value).toBe(99)
})

View File

@@ -25,14 +25,11 @@ import {
} from '@/renderer/extensions/vueNodes/widgets/registry/widgetRegistry'
import { nodeTypeValidForApp } from '@/stores/appModeStore'
import type { WidgetState } from '@/stores/widgetValueStore'
import {
stripGraphPrefix,
useWidgetValueStore
} from '@/stores/widgetValueStore'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { deriveWidgetEntityId } from '@/world/entityIds'
import type { WidgetEntityId } from '@/world/entityIds'
import { getWidgetState } from '@/world/widgetValueIO'
import type { LGraph } from '@/lib/litegraph/src/litegraph'
import type {
LinkedUpstreamInfo,
@@ -77,6 +74,15 @@ interface ComputeProcessedWidgetsOptions {
ui: WidgetUiCallbacks
}
/**
* Strips graph-scope prefix segments from a node id (e.g. `outer:inner:42`
* → `42`) so nested node renders get stable DOM identity keys. Not for
* widget value lookup — that routes through {@link WidgetEntityId}.
*/
function extractRawNodeId(scopedId: string | number): string {
return String(scopedId).replace(/^(.*:)+/, '')
}
function createWidgetUpdateHandler(
widgetState: WidgetState | undefined,
widget: SafeWidgetData,
@@ -135,10 +141,10 @@ export function getWidgetIdentity(
const slotNameForIdentity = widget.slotName ?? widget.name
const hostNodeIdRoot =
nodeId !== undefined && nodeId !== ''
? `node:${String(stripGraphPrefix(nodeId))}`
? `node:${String(extractRawNodeId(nodeId))}`
: undefined
const stableIdentityRoot = widget.nodeId
? `node:${String(stripGraphPrefix(widget.nodeId))}`
? `node:${String(extractRawNodeId(widget.nodeId))}`
: widget.sourceExecutionId
? `exec:${widget.sourceExecutionId}`
: hostNodeIdRoot
@@ -198,15 +204,19 @@ export function computeProcessedWidgets({
if (!shouldRenderAsVue(widget)) continue
const identity = getWidgetIdentity(widget, nodeId, index)
const widgetState = widget.entityId
? getWidgetState(widget.entityId)
: graphId
? widgetValueStore.getWidget(
graphId,
String(stripGraphPrefix(widget.nodeId ?? nodeId ?? '')),
widget.name
)
let widgetState: WidgetState | undefined
if (widget.entityId) {
widgetState = widgetValueStore.getWidget(widget.entityId)
} else {
const fallbackEntityId = deriveWidgetEntityId(
graphId,
String(extractRawNodeId(widget.nodeId ?? nodeId ?? '')),
widget.name
)
widgetState = fallbackEntityId
? widgetValueStore.getWidget(fallbackEntityId)
: undefined
}
const mergedOptions: IWidgetOptions = {
...(widget.options ?? {}),
...(widgetState?.options ?? {})
@@ -254,7 +264,7 @@ export function computeProcessedWidgets({
widgetState,
identity: { renderKey }
} of uniqueWidgets) {
const bareWidgetId = String(stripGraphPrefix(widget.nodeId ?? nodeId ?? ''))
const bareWidgetId = String(extractRawNodeId(widget.nodeId ?? nodeId ?? ''))
const vueComponent =
getComponent(widget.type) ||
@@ -318,7 +328,7 @@ export function computeProcessedWidgets({
e,
widget.name,
widget.nodeId !== undefined
? String(stripGraphPrefix(widget.nodeId))
? String(extractRawNodeId(widget.nodeId))
: undefined
)
}

View File

@@ -14,6 +14,7 @@ import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import { app } from '@/scripts/app'
import type { ComfyWidgetConstructorV2 } from '@/scripts/widgets'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import { deriveWidgetEntityId } from '@/world/entityIds'
// TODO: This widget manually syncs with widgetValueStore via getValue/setValue.
// Consolidate with useStringWidget into shared helpers (domWidgetHelpers.ts).
@@ -41,24 +42,35 @@ function addMarkdownWidget(
editable: false
})
const widgetStore = useWidgetValueStore()
const inputEl = editor.options.element as HTMLElement
inputEl.classList.add('comfy-markdown')
const textarea = document.createElement('textarea')
inputEl.append(textarea)
const widgetValueStore = useWidgetValueStore()
const widget = node.addDOMWidget(name, 'MARKDOWN', inputEl, {
getValue(): string {
const graphId = resolveNodeRootGraphId(node, app.rootGraph.id)
const storedValue = widgetStore.getWidget(graphId, node.id, name)?.value
const entityId = deriveWidgetEntityId(
resolveNodeRootGraphId(node, app.rootGraph.id),
node.id,
name
)
const storedValue = entityId
? widgetValueStore.getWidget(entityId)?.value
: undefined
return typeof storedValue === 'string' ? storedValue : textarea.value
},
setValue(v: string) {
textarea.value = v
editor.commands.setContent(v)
const graphId = resolveNodeRootGraphId(node, app.rootGraph.id)
const widgetState = widgetStore.getWidget(graphId, node.id, name)
const entityId = deriveWidgetEntityId(
resolveNodeRootGraphId(node, app.rootGraph.id),
node.id,
name
)
const widgetState = entityId
? widgetValueStore.getWidget(entityId)
: undefined
if (widgetState) widgetState.value = v
}
})

View File

@@ -8,6 +8,7 @@ import { app } from '@/scripts/app'
import type { ComfyWidgetConstructorV2 } from '@/scripts/widgets'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import { deriveWidgetEntityId } from '@/world/entityIds'
type TextPreviewCustomProps = Omit<
InstanceType<typeof TextPreviewWidget>['$props'],
@@ -25,6 +26,7 @@ export function useTextPreviewWidget(
node: LGraphNode,
inputSpec: InputSpec
): IBaseWidget {
const widgetValueStore = useWidgetValueStore()
const widget = new ComponentWidgetImpl<
string | object,
TextPreviewCustomProps
@@ -37,19 +39,26 @@ export function useTextPreviewWidget(
nodeId: node.id
},
options: {
getValue: () =>
useWidgetValueStore().getWidget(
getValue: () => {
const entityId = deriveWidgetEntityId(
resolveNodeRootGraphId(node, app.rootGraph.id),
node.id,
inputSpec.name
)?.value ?? '',
)
const widgetState = entityId
? widgetValueStore.getWidget(entityId)
: undefined
return widgetState?.value ?? ''
},
setValue: (value: string | object) => {
const graphId = resolveNodeRootGraphId(node, app.rootGraph.id)
const widgetState = useWidgetValueStore().getWidget(
graphId,
const entityId = deriveWidgetEntityId(
resolveNodeRootGraphId(node, app.rootGraph.id),
node.id,
inputSpec.name
)
const widgetState = entityId
? widgetValueStore.getWidget(entityId)
: undefined
if (widgetState)
widgetState.value =
typeof value === 'string' ? value : String(value)

View File

@@ -8,6 +8,7 @@ import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import { app } from '@/scripts/app'
import type { ComfyWidgetConstructorV2 } from '@/scripts/widgets'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import { deriveWidgetEntityId } from '@/world/entityIds'
const TRACKPAD_DETECTION_THRESHOLD = 50
@@ -18,7 +19,6 @@ function addMultilineWidget(
name: string,
opts: { defaultVal: string; placeholder?: string }
) {
const widgetStore = useWidgetValueStore()
const inputEl = document.createElement('textarea')
inputEl.className = 'comfy-multiline-input'
inputEl.dataset.testid = 'dom-widget-textarea'
@@ -26,17 +26,29 @@ function addMultilineWidget(
inputEl.placeholder = opts.placeholder || name
inputEl.spellcheck = useSettingStore().get('Comfy.TextareaWidget.Spellcheck')
const widgetValueStore = useWidgetValueStore()
const widget = node.addDOMWidget(name, 'customtext', inputEl, {
getValue(): string {
const graphId = resolveNodeRootGraphId(node, app.rootGraph.id)
const widgetState = widgetStore.getWidget(graphId, node.id, name)
const entityId = deriveWidgetEntityId(
resolveNodeRootGraphId(node, app.rootGraph.id),
node.id,
name
)
const widgetState = entityId
? widgetValueStore.getWidget(entityId)
: undefined
return (widgetState?.value as string) ?? inputEl.value
},
setValue(v: string) {
inputEl.value = v
const graphId = resolveNodeRootGraphId(node, app.rootGraph.id)
const widgetState = widgetStore.getWidget(graphId, node.id, name)
const entityId = deriveWidgetEntityId(
resolveNodeRootGraphId(node, app.rootGraph.id),
node.id,
name
)
const widgetState = entityId
? widgetValueStore.getWidget(entityId)
: undefined
if (widgetState) widgetState.value = v
}
})

View File

@@ -76,14 +76,18 @@ vi.mock('@/stores/nodeOutputStore', () => ({
})
}))
vi.mock('@/stores/widgetValueStore', () => {
vi.mock('@/stores/widgetValueStore', async () => {
const { parseWidgetEntityId } = await import('@/world/entityIds')
const widgetMap = new Map<string, { value: unknown }>()
const getWidget = vi.fn((_graphId: string, _nodeId: string, name: string) =>
widgetMap.get(name)
)
const getWidget = vi.fn((widgetId: string) => {
const { name } = parseWidgetEntityId(widgetId as never)
return widgetMap.get(name)
})
const getNodeWidgets = vi.fn(() => [])
return {
useWidgetValueStore: () => ({
getWidget,
getNodeWidgets,
_widgetMap: widgetMap
})
}

View File

@@ -4,10 +4,11 @@ import { computed, effectScope, onScopeDispose, ref, toValue, watch } from 'vue'
import type { ComputedRef, EffectScope, MaybeRefOrGetter, Ref } from 'vue'
import type { LGraphNode, NodeId } from '@/lib/litegraph/src/LGraphNode'
import type { Subgraph } from '@/lib/litegraph/src/subgraph/Subgraph'
import type { UUID } from '@/lib/litegraph/src/utils/uuid'
import type { UUID } from '@/utils/uuid'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { useNodeOutputStore } from '@/stores/nodeOutputStore'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import { deriveWidgetEntityId } from '@/world/entityIds'
import { curveDataToFloatLUT } from '@/components/curve/curveUtils'
import type { GLSLRendererConfig } from '@/renderer/glsl/useGLSLRenderer'
@@ -121,8 +122,8 @@ function createInnerPreview(
lastError: Ref<string | null>,
isActiveOut: Ref<boolean>
): () => void {
const widgetValueStore = useWidgetValueStore()
const nodeOutputStore = useNodeOutputStore()
const widgetValueStore = useWidgetValueStore()
const { nodeToNodeLocatorId } = useWorkflowStore()
let renderer: ReturnType<typeof useGLSLRenderer> | null = null
@@ -194,18 +195,22 @@ function createInnerPreview(
if (isGLSLNode.value) {
const nId = nodeId.value
if (nId == null) return undefined
return widgetValueStore.getWidget(gId, nId, 'fragment_shader')?.value as
| string
| undefined
const entityId = deriveWidgetEntityId(gId, nId, 'fragment_shader')
return entityId
? (widgetValueStore.getWidget(entityId)?.value as string | undefined)
: undefined
}
const inner = innerGLSLNode
if (inner) {
return widgetValueStore.getWidget(
const entityId = deriveWidgetEntityId(
gId,
inner.id as NodeId,
'fragment_shader'
)?.value as string | undefined
)
return entityId
? (widgetValueStore.getWidget(entityId)?.value as string | undefined)
: undefined
}
return undefined
@@ -291,23 +296,16 @@ function createInnerPreview(
: nodeId.value
if (sizeModeNodeId == null) return null
const sizeMode = widgetValueStore.getWidget(
gId,
sizeModeNodeId,
'size_mode'
)
const lookup = (name: string) => {
const entityId = deriveWidgetEntityId(gId, sizeModeNodeId, name)
return entityId ? widgetValueStore.getWidget(entityId) : undefined
}
const sizeMode = lookup('size_mode')
if (sizeMode?.value !== 'custom') return null
const widthWidget = widgetValueStore.getWidget(
gId,
sizeModeNodeId,
'size_mode.width'
)
const heightWidget = widgetValueStore.getWidget(
gId,
sizeModeNodeId,
'size_mode.height'
)
const widthWidget = lookup('size_mode.width')
const heightWidget = lookup('size_mode.height')
if (!widthWidget || !heightWidget) return null
return clampResolution(

View File

@@ -4,8 +4,13 @@ import type { ComputedRef } from 'vue'
import type { LGraphNode, NodeId } from '@/lib/litegraph/src/LGraphNode'
import { SUBGRAPH_INPUT_ID } from '@/lib/litegraph/src/constants'
import type { Subgraph } from '@/lib/litegraph/src/subgraph/Subgraph'
import type { UUID } from '@/lib/litegraph/src/utils/uuid'
import type { UUID } from '@/utils/uuid'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import {
asGraphId,
deriveWidgetEntityId,
nodeEntityId
} from '@/world/entityIds'
import { isCurveData } from '@/components/curve/curveUtils'
import type { CurveData } from '@/components/curve/types'
@@ -132,7 +137,10 @@ export function useGLSLUniforms(
if (subgraphSources) {
return subgraphSources.map(({ nodeId: nId, widgetName, directValue }) => {
const widget = widgetValueStore.getWidget(gId, nId, widgetName)
const entityId = deriveWidgetEntityId(gId, nId, widgetName)
const widget = entityId
? widgetValueStore.getWidget(entityId)
: undefined
return coerce(widget?.value ?? directValue() ?? defaultValue)
})
}
@@ -144,7 +152,8 @@ export function useGLSLUniforms(
const values: T[] = []
for (let i = 0; i < maxCount; i++) {
const inputName = `${groupName}.${uniformPrefix}${i}`
const widget = widgetValueStore.getWidget(gId, nId, inputName)
const entityId = deriveWidgetEntityId(gId, nId, inputName)
const widget = entityId ? widgetValueStore.getWidget(entityId) : undefined
if (widget !== undefined) {
values.push(coerce(widget.value))
continue
@@ -158,8 +167,7 @@ export function useGLSLUniforms(
const upstreamNode = node.getInputNode(slot)
if (!upstreamNode) break
const upstreamWidgets = widgetValueStore.getNodeWidgets(
gId,
upstreamNode.id as NodeId
nodeEntityId(asGraphId(gId), upstreamNode.id as NodeId)
)
if (
upstreamWidgets.length === 0 ||
@@ -214,7 +222,10 @@ export function useGLSLUniforms(
if (sources && sources.length > 0) {
return sources
.map(({ nodeId: nId, widgetName, directValue }) => {
const widget = widgetValueStore.getWidget(gId, nId, widgetName)
const entityId = deriveWidgetEntityId(gId, nId, widgetName)
const widget = entityId
? widgetValueStore.getWidget(entityId)
: undefined
const value = widget?.value ?? directValue()
return isCurveData(value) ? (value as CurveData) : null
})
@@ -230,7 +241,8 @@ export function useGLSLUniforms(
for (let i = 0; i < max; i++) {
const inputName = `curves.u_curve${i}`
const widget = widgetValueStore.getWidget(gId, nId, inputName)
const entityId = deriveWidgetEntityId(gId, nId, inputName)
const widget = entityId ? widgetValueStore.getWidget(entityId) : undefined
if (widget && isCurveData(widget.value)) {
values.push(widget.value as CurveData)
continue
@@ -243,8 +255,7 @@ export function useGLSLUniforms(
if (!upstreamNode) break
const upstreamWidgets = widgetValueStore.getNodeWidgets(
gId,
upstreamNode.id as NodeId
nodeEntityId(asGraphId(gId), upstreamNode.id as NodeId)
)
const curveWidget = upstreamWidgets.find((w) => isCurveData(w.value))
if (!curveWidget) break

View File

@@ -18,6 +18,7 @@ import { app } from '@/scripts/app'
import { ChangeTracker } from '@/scripts/changeTracker'
import { createMockChangeTracker } from '@/utils/__tests__/litegraphTestUtils'
import type { WidgetEntityId } from '@/world/entityIds'
import { widgetEntityId } from '@/world/entityIds'
const mockEmptyWorkflowDialog = vi.hoisted(() => {
let lastOptions: { onEnterBuilder: () => void; onDismiss: () => void }
@@ -127,16 +128,16 @@ function createWorkflowWithLinearData(
}
const rootGraphId = '11111111-1111-4111-8111-111111111111'
const entityPrompt = `${rootGraphId}:1:prompt` as WidgetEntityId
const entitySeed = `${rootGraphId}:1:seed` as WidgetEntityId
const entitySteps = `${rootGraphId}:1:steps` as WidgetEntityId
const entityPrompt = widgetEntityId(rootGraphId, 1, 'prompt')
const entitySeed = widgetEntityId(rootGraphId, 1, 'seed')
const entitySteps = widgetEntityId(rootGraphId, 1, 'steps')
function nodeWithWidgets(id: number, widgetNames: string[]) {
return fromAny<LGraphNode, unknown>({
id,
widgets: widgetNames.map((name) => ({
name,
entityId: `${rootGraphId}:${id}:${name}` as WidgetEntityId
entityId: widgetEntityId(rootGraphId, id, name)
}))
})
}

View File

@@ -2,7 +2,7 @@ import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it } from 'vitest'
import type { UUID } from '@/lib/litegraph/src/utils/uuid'
import type { UUID } from '@/utils/uuid'
import { usePreviewExposureStore } from './previewExposureStore'

View File

@@ -9,7 +9,7 @@ import { resolvePreviewExposureChain } from '@/core/graph/subgraph/preview/previ
import type { PromotedWidgetSource } from '@/core/graph/subgraph/promotedWidgetTypes'
import type { PreviewExposure } from '@/core/schemas/previewExposureSchema'
import { nextUniqueName } from '@/lib/litegraph/src/strings'
import type { UUID } from '@/lib/litegraph/src/utils/uuid'
import type { UUID } from '@/utils/uuid'
const EMPTY_EXPOSURES: readonly PreviewExposure[] = Object.freeze([])

View File

@@ -2,25 +2,33 @@ import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it } from 'vitest'
import type { UUID } from '@/lib/litegraph/src/utils/uuid'
import type { NodeId } from '@/lib/litegraph/src/LGraphNode'
import type { UUID } from '@/utils/uuid'
import { asGraphId, nodeEntityId, widgetEntityId } from '@/world/entityIds'
import type { WidgetEntityId } from '@/world/entityIds'
import type { WidgetState } from './widgetValueStore'
import { useWidgetValueStore } from './widgetValueStore'
function widget<T>(
nodeId: string,
name: string,
function widgetState<T>(
type: string,
value: T,
extra: Partial<
Omit<WidgetState<T>, 'nodeId' | 'name' | 'type' | 'value'>
> = {}
extra: Partial<Omit<WidgetState<T>, 'type' | 'value'>> = {}
): WidgetState<T> {
return { nodeId, name, type, value, options: {}, ...extra }
return {
type,
value,
options: {},
...extra
}
}
describe('useWidgetValueStore', () => {
const graphA = 'graph-a' as UUID
const graphB = 'graph-b' as UUID
const wid = (graphId: UUID, nodeId: NodeId, name: string): WidgetEntityId =>
widgetEntityId(asGraphId(graphId), nodeId, name)
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
})
@@ -28,35 +36,45 @@ describe('useWidgetValueStore', () => {
describe('widgetState.value access', () => {
it('getWidget returns undefined for unregistered widget', () => {
const store = useWidgetValueStore()
expect(store.getWidget(graphA, 'missing', 'widget')).toBeUndefined()
expect(
store.getWidget(wid(graphA, 'missing' as NodeId, 'widget'))
).toBeUndefined()
})
it('widgetState.value can be read and written directly', () => {
const store = useWidgetValueStore()
const state = store.registerWidget(
graphA,
widget('node-1', 'seed', 'number', 100)
)
const id = wid(graphA, 'node-1' as NodeId, 'seed')
const state = store.registerWidget(id, widgetState('number', 100))
expect(state.value).toBe(100)
state.value = 200
expect(store.getWidget(graphA, 'node-1', 'seed')?.value).toBe(200)
expect(store.getWidget(id)?.value).toBe(200)
})
it('stores different value types', () => {
const store = useWidgetValueStore()
store.registerWidget(graphA, widget('node-1', 'text', 'string', 'hello'))
store.registerWidget(graphA, widget('node-1', 'number', 'number', 42))
store.registerWidget(graphA, widget('node-1', 'boolean', 'toggle', true))
const node = 'node-1' as NodeId
store.registerWidget(
graphA,
widget('node-1', 'array', 'combo', [1, 2, 3])
wid(graphA, node, 'text'),
widgetState('string', 'hello')
)
store.registerWidget(
wid(graphA, node, 'number'),
widgetState('number', 42)
)
store.registerWidget(
wid(graphA, node, 'boolean'),
widgetState('toggle', true)
)
store.registerWidget(
wid(graphA, node, 'array'),
widgetState('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([
expect(store.getWidget(wid(graphA, node, 'text'))?.value).toBe('hello')
expect(store.getWidget(wid(graphA, node, 'number'))?.value).toBe(42)
expect(store.getWidget(wid(graphA, node, 'boolean'))?.value).toBe(true)
expect(store.getWidget(wid(graphA, node, 'array'))?.value).toEqual([
1, 2, 3
])
})
@@ -66,15 +84,13 @@ describe('useWidgetValueStore', () => {
it('registers a widget with minimal properties', () => {
const store = useWidgetValueStore()
const state = store.registerWidget(
graphA,
widget('node-1', 'seed', 'number', 12345)
wid(graphA, 'node-1' as NodeId, 'seed'),
widgetState('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({})
})
@@ -82,8 +98,8 @@ describe('useWidgetValueStore', () => {
it('registers a widget with all properties', () => {
const store = useWidgetValueStore()
const state = store.registerWidget(
graphA,
widget('node-1', 'prompt', 'string', 'test', {
wid(graphA, 'node-1' as NodeId, 'prompt'),
widgetState('string', 'test', {
label: 'Prompt Text',
disabled: true,
serialize: false,
@@ -96,84 +112,187 @@ describe('useWidgetValueStore', () => {
expect(state.serialize).toBe(false)
expect(state.options).toEqual({ multiline: true })
})
it('overwrites existing widget state when registerWidget is called twice', () => {
const store = useWidgetValueStore()
const id = wid(graphA, 'node-1' as NodeId, 'seed')
const first = store.registerWidget(id, widgetState('number', 11))
first.value = 99
store.registerWidget(id, widgetState('number', 11))
expect(store.getWidget(id)?.value).toBe(11)
})
it('register-if-absent pattern preserves existing state', () => {
// Captures the idempotency guarantee that the prior IO helper used to
// provide: callers that want non-destructive init must check getWidget
// first.
const store = useWidgetValueStore()
const id = wid(graphA, 'node-1' as NodeId, 'seed')
store.registerWidget(id, widgetState('number', 11))
const first = store.getWidget(id)!
first.value = 99
const existing = store.getWidget(id)
if (!existing) store.registerWidget(id, widgetState('number', 11))
expect(store.getWidget(id)?.value).toBe(99)
})
})
describe('widget getters', () => {
it('getWidget returns widget state', () => {
const store = useWidgetValueStore()
store.registerWidget(graphA, widget('node-1', 'seed', 'number', 100))
const id = wid(graphA, 'node-1' as NodeId, 'seed')
store.registerWidget(id, widgetState('number', 100))
const state = store.getWidget(graphA, 'node-1', 'seed')
const state = store.getWidget(id)
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(wid(graphA, 'missing' as NodeId, 'widget'))
).toBeUndefined()
})
it('getNodeWidgets returns all widgets for a node', () => {
const store = useWidgetValueStore()
store.registerWidget(graphA, widget('node-1', 'seed', 'number', 1))
store.registerWidget(graphA, widget('node-1', 'steps', 'number', 20))
store.registerWidget(graphA, widget('node-2', 'cfg', 'number', 7))
const node1 = 'node-1' as NodeId
const node2 = 'node-2' as NodeId
store.registerWidget(wid(graphA, node1, 'seed'), widgetState('number', 1))
store.registerWidget(
wid(graphA, node1, 'steps'),
widgetState('number', 20)
)
store.registerWidget(wid(graphA, node2, 'cfg'), widgetState('number', 7))
const widgets = store.getNodeWidgets(graphA, 'node-1')
const widgets = store.getNodeWidgets(
nodeEntityId(asGraphId(graphA), node1)
)
expect(widgets).toHaveLength(2)
expect(widgets.map((w) => w.name).sort()).toEqual(['seed', 'steps'])
})
})
describe('direct property mutation', () => {
it('disabled can be set directly via getWidget', () => {
const store = useWidgetValueStore()
const state = store.registerWidget(
graphA,
widget('node-1', 'seed', 'number', 100)
)
const id = wid(graphA, 'node-1' as NodeId, 'seed')
const state = store.registerWidget(id, widgetState('number', 100))
state.disabled = true
expect(store.getWidget(graphA, 'node-1', 'seed')?.disabled).toBe(true)
expect(store.getWidget(id)?.disabled).toBe(true)
})
it('label can be set directly via getWidget', () => {
const store = useWidgetValueStore()
const state = store.registerWidget(
graphA,
widget('node-1', 'seed', 'number', 100)
)
const id = wid(graphA, 'node-1' as NodeId, 'seed')
const state = store.registerWidget(id, widgetState('number', 100))
state.label = 'Random Seed'
expect(store.getWidget(graphA, 'node-1', 'seed')?.label).toBe(
'Random Seed'
)
expect(store.getWidget(id)?.label).toBe('Random Seed')
state.label = undefined
expect(store.getWidget(graphA, 'node-1', 'seed')?.label).toBeUndefined()
expect(store.getWidget(id)?.label).toBeUndefined()
})
})
describe('graph isolation', () => {
it('isolates widget states by graph', () => {
const store = useWidgetValueStore()
store.registerWidget(graphA, widget('node-1', 'seed', 'number', 1))
store.registerWidget(graphB, widget('node-1', 'seed', 'number', 2))
const node = 'node-1' as NodeId
store.registerWidget(wid(graphA, node, 'seed'), widgetState('number', 1))
store.registerWidget(wid(graphB, node, 'seed'), widgetState('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(wid(graphA, node, 'seed'))?.value).toBe(1)
expect(store.getWidget(wid(graphB, node, 'seed'))?.value).toBe(2)
})
it('clearGraph only removes one graph namespace', () => {
const store = useWidgetValueStore()
store.registerWidget(graphA, widget('node-1', 'seed', 'number', 1))
store.registerWidget(graphB, widget('node-1', 'seed', 'number', 2))
const node = 'node-1' as NodeId
store.registerWidget(wid(graphA, node, 'seed'), widgetState('number', 1))
store.registerWidget(wid(graphB, node, 'seed'), widgetState('number', 2))
store.clearGraph(graphA)
expect(store.getWidget(graphA, 'node-1', 'seed')).toBeUndefined()
expect(store.getWidget(graphB, 'node-1', 'seed')?.value).toBe(2)
expect(store.getWidget(wid(graphA, node, 'seed'))).toBeUndefined()
expect(store.getWidget(wid(graphB, node, 'seed'))?.value).toBe(2)
})
})
describe('returned state identity', () => {
const node = 'node-1' as NodeId
const sample = widgetState('number', 100)
const widgetId = wid(graphA, node, 'seed')
it('getWidget returns the same reference as registerWidget', () => {
const store = useWidgetValueStore()
const registered = store.registerWidget(widgetId, sample)
expect(store.getWidget(widgetId)).toBe(registered)
})
it('cached references detach safely after clearGraph', () => {
const store = useWidgetValueStore()
const view = store.registerWidget(widgetId, sample)
store.clearGraph(graphA)
view.value = 999
view.label = 'ignored'
view.disabled = true
expect(store.getWidget(widgetId)).toBeUndefined()
})
})
describe('getNodeWidgetsByName', () => {
it('returns empty map when node has no widgets', () => {
const store = useWidgetValueStore()
const map = store.getNodeWidgetsByName(
nodeEntityId(asGraphId(graphA), 'no-such' as NodeId)
)
expect(map.size).toBe(0)
})
it('returns map keyed by widget name', () => {
const store = useWidgetValueStore()
const node = 'node-1' as NodeId
store.registerWidget(wid(graphA, node, 'seed'), widgetState('number', 1))
store.registerWidget(wid(graphA, node, 'cfg'), widgetState('number', 7))
const map = store.getNodeWidgetsByName(
nodeEntityId(asGraphId(graphA), node)
)
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('setValue', () => {
it('updates an existing widget value and returns true', () => {
const store = useWidgetValueStore()
const id = wid(graphA, 'node-1' as NodeId, 'seed')
store.registerWidget(id, widgetState('number', 1))
expect(store.setValue(id, 99)).toBe(true)
expect(store.getWidget(id)?.value).toBe(99)
})
it('returns false when setting value on an unregistered widget', () => {
const store = useWidgetValueStore()
const id = wid(graphA, 'node-1' as NodeId, 'seed')
expect(store.setValue(id, 99)).toBe(false)
})
})
describe('reactivity through the view', () => {
it('clearGraph removes data; subsequent getWidget returns undefined', () => {
const store = useWidgetValueStore()
const id = wid(graphA, 'node-1' as NodeId, 'seed')
store.registerWidget(id, widgetState('number', 100))
store.clearGraph(graphA)
expect(store.getWidget(id)).toBeUndefined()
})
})
})

View File

@@ -1,97 +1,97 @@
import { defineStore } from 'pinia'
import { reactive, ref } from 'vue'
import { reactive } from 'vue'
import type { NodeId } from '@/lib/litegraph/src/LGraphNode'
import type { UUID } from '@/lib/litegraph/src/utils/uuid'
import type {
IBaseWidget,
IWidgetOptions
} from '@/lib/litegraph/src/types/widgets'
import type { UUID } from '@/utils/uuid'
import {
asGraphId,
isNodeIdForGraph,
isWidgetIdForGraph,
nodeEntityId,
parseWidgetEntityId
} from '@/world/entityIds'
import type { NodeEntityId, WidgetEntityId } from '@/world/entityIds'
import type { WidgetState } from '@/world/widgets/widgetState'
type WidgetKey = `${NodeId}:${string}`
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 type { WidgetState } from '@/world/widgets/widgetState'
export const useWidgetValueStore = defineStore('widgetValue', () => {
const graphWidgetStates = ref(new Map<UUID, Map<WidgetKey, WidgetState>>())
const widgets = reactive(new Map<WidgetEntityId, WidgetState>())
const widgetIdsByNode = reactive(new Map<NodeEntityId, WidgetEntityId[]>())
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}`
}
/**
* @deprecated Use `ensureWidgetState(widget.entityId, init)` from
* `src/world/widgetValueIO.ts` — the branded `WidgetEntityId` prevents
* producer/consumer drift that loose triples allow.
*/
function registerWidget<TValue = unknown>(
graphId: UUID,
widgetId: WidgetEntityId,
state: WidgetState<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 { graphId, nodeId } = parseWidgetEntityId(widgetId)
widgets.set(widgetId, {
...state,
disabled: state.disabled ?? false
})
const ownerId = nodeEntityId(graphId, nodeId)
const ids = widgetIdsByNode.get(ownerId)
if (!ids) {
widgetIdsByNode.set(ownerId, [widgetId])
} else if (!ids.includes(widgetId)) {
ids.push(widgetId)
}
return widgets.get(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)
function getWidget(widgetId: WidgetEntityId): WidgetState | undefined {
return widgets.get(widgetId)
}
/**
* @deprecated Use `getWidgetState(widget.entityId)` or
* `readWidgetValue(widget.entityId)` from `src/world/widgetValueIO.ts` —
* the branded `WidgetEntityId` prevents producer/consumer drift that loose
* triples allow.
*/
function getWidget(
graphId: UUID,
nodeId: NodeId,
widgetName: string
): WidgetState | undefined {
return getWidgetStateMap(graphId).get(makeKey(nodeId, widgetName))
function getNodeWidgets(nodeId: NodeEntityId): WidgetState[] {
const ids = widgetIdsByNode.get(nodeId)
if (!ids) return []
const result: WidgetState[] = []
for (const widgetId of ids) {
const w = widgets.get(widgetId)
if (w) result.push(w)
}
return result
}
function getNodeWidgetsByName(
nodeId: NodeEntityId
): Map<string, WidgetState> {
const result = new Map<string, WidgetState>()
const ids = widgetIdsByNode.get(nodeId)
if (!ids) return result
for (const widgetId of ids) {
const w = widgets.get(widgetId)
if (!w) continue
const { name } = parseWidgetEntityId(widgetId)
result.set(name, w)
}
return result
}
function setValue(
graphId: UUID,
nodeId: NodeId,
widgetName: string,
widgetId: WidgetEntityId,
value: WidgetState['value']
): boolean {
const state = getWidgetStateMap(graphId).get(makeKey(nodeId, widgetName))
if (!state) return false
state.value = value
const widget = widgets.get(widgetId)
if (!widget) return false
widget.value = value
return true
}
function clearGraph(graphId: UUID): void {
graphWidgetStates.value.delete(graphId)
const branded = asGraphId(graphId)
for (const widgetId of widgets.keys()) {
if (isWidgetIdForGraph(branded, widgetId)) {
widgets.delete(widgetId)
}
}
for (const nodeId of widgetIdsByNode.keys()) {
if (isNodeIdForGraph(branded, nodeId)) {
widgetIdsByNode.delete(nodeId)
}
}
}
return {
@@ -99,6 +99,7 @@ export const useWidgetValueStore = defineStore('widgetValue', () => {
getWidget,
setValue,
getNodeWidgets,
getNodeWidgetsByName,
clearGraph
}
})

View File

@@ -29,7 +29,7 @@ import { app } from '@/scripts/app'
import { t } from '@/i18n'
import { parseNodeLocatorId } from '@/types/nodeIdentification'
import type { WidgetEntityId } from '@/world/entityIds'
import { widgetEntityId } from '@/world/entityIds'
import { deriveWidgetEntityId } from '@/world/entityIds'
type ImageNode = LGraphNode & { imgs: HTMLImageElement[] | undefined }
type VideoNode = LGraphNode & {
@@ -377,9 +377,7 @@ export function getWidgetEntityIdForNode(
widget: Pick<IBaseWidget, 'name' | 'entityId'>
): WidgetEntityId | undefined {
if (widget.entityId) return widget.entityId
const graphId = node.graph?.rootGraph.id
if (!graphId || node.id === -1) return undefined
return widgetEntityId(graphId, node.id, widget.name)
return deriveWidgetEntityId(node.graph?.rootGraph.id, node.id, widget.name)
}
export function isLoad3dNode(node: LGraphNode) {

View File

@@ -1,45 +1,22 @@
import { describe, expect, it } from 'vitest'
import { describe, expect, expectTypeOf, it } from 'vitest'
import type { WidgetEntityId } from './entityIds'
import type { UUID } from '@/utils/uuid'
import type { NodeEntityId, NodeId, WidgetEntityId } from './entityIds'
import {
asGraphId,
deriveWidgetEntityId,
isWidgetEntityId,
nodeEntityId,
parseWidgetEntityId,
widgetEntityId
} from './entityIds'
describe('widgetEntityId', () => {
const graphId = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890'
it('builds a deterministic id from its components', () => {
const id = widgetEntityId(graphId, 42, 'seed')
expect(id).toBe(`${graphId}:42:seed`)
})
it('produces equal ids for equal inputs', () => {
expect(widgetEntityId(graphId, 42, 'seed')).toBe(
widgetEntityId(graphId, 42, 'seed')
)
})
it('produces distinct ids when any component differs', () => {
const baseline = widgetEntityId(graphId, 42, 'seed')
expect(widgetEntityId(graphId, 43, 'seed')).not.toBe(baseline)
expect(widgetEntityId(graphId, 42, 'steps')).not.toBe(baseline)
const otherGraph = 'b1b2c3d4-e5f6-7890-abcd-ef1234567890'
expect(widgetEntityId(otherGraph, 42, 'seed')).not.toBe(baseline)
})
it('accepts string node ids', () => {
const id = widgetEntityId(graphId, 'node-7', 'value')
expect(id).toBe(`${graphId}:node-7:value`)
})
})
describe('parseWidgetEntityId', () => {
const graphId = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890'
const graphId = asGraphId('a3f2c1d8-4567-89ab-cdef-1234567890ab' as UUID)
it('round-trips a constructed id', () => {
const id = widgetEntityId(graphId, 42, 'seed')
it('round-trips a simple name', () => {
const id = widgetEntityId(graphId, 42 as NodeId, 'seed')
expect(parseWidgetEntityId(id)).toEqual({
graphId,
nodeId: '42',
@@ -47,29 +24,64 @@ describe('parseWidgetEntityId', () => {
})
})
it('preserves colons inside the name segment', () => {
const rawName = 'nested:label:with:colons'
const rawId = `${graphId}:42:${rawName}` as WidgetEntityId
expect(parseWidgetEntityId(rawId)).toEqual({
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', () => {
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: '42',
name: rawName
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('isWidgetEntityId', () => {
const graphId = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890'
const graphId = asGraphId('a1b2c3d4-e5f6-7890-abcd-ef1234567890' as UUID)
it('accepts ids built by the constructor', () => {
expect(isWidgetEntityId(widgetEntityId(graphId, 1, 'x'))).toBe(true)
expect(isWidgetEntityId(widgetEntityId(graphId, 1 as NodeId, 'x'))).toBe(
true
)
})
it('rejects strings without two colon-separated segments', () => {
it('rejects strings lacking the widget: prefix', () => {
expect(isWidgetEntityId('only-one-colon:42')).toBe(false)
expect(isWidgetEntityId('no-colons')).toBe(false)
expect(isWidgetEntityId(':leading-colon:name')).toBe(false)
expect(isWidgetEntityId('graph::name')).toBe(false)
expect(isWidgetEntityId(`${graphId}:42:seed`)).toBe(false)
expect(isWidgetEntityId(`node:${graphId}:42`)).toBe(false)
})
it('rejects strings with too few segments', () => {
expect(isWidgetEntityId('widget:abc')).toBe(false)
expect(isWidgetEntityId(`widget:${graphId}:42`)).toBe(false)
})
it('rejects non-strings', () => {
@@ -79,3 +91,60 @@ describe('isWidgetEntityId', () => {
expect(isWidgetEntityId({})).toBe(false)
})
})
describe('deriveWidgetEntityId', () => {
const graphId = asGraphId('e1d2c3b4-a5f6-1234-5678-90abcdef1234' as UUID)
it('builds an entity id when all inputs are present', () => {
const id = deriveWidgetEntityId(graphId, 5 as NodeId, 'seed')
expect(id).toBe(widgetEntityId(graphId, 5 as NodeId, 'seed'))
})
it('returns undefined when graphId is missing', () => {
expect(deriveWidgetEntityId(undefined, 5 as NodeId, 'seed')).toBeUndefined()
})
it('returns undefined when nodeId is undefined', () => {
expect(deriveWidgetEntityId(graphId, undefined, 'seed')).toBeUndefined()
})
it('returns undefined for the sentinel nodeId -1', () => {
expect(deriveWidgetEntityId(graphId, -1, 'seed')).toBeUndefined()
})
it('accepts a plain UUID for graphId', () => {
const plain = 'f0e1d2c3-b4a5-6789-0123-456789abcdef' as UUID
expect(deriveWidgetEntityId(plain, 1 as NodeId, 'x')).toBe(
widgetEntityId(plain, 1 as NodeId, 'x')
)
})
})
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', () => {
expectTypeOf<
WidgetEntityId extends NodeEntityId ? WidgetEntityId : never
>().toEqualTypeOf<never>()
expectTypeOf<
NodeEntityId extends WidgetEntityId ? NodeEntityId : never
>().toEqualTypeOf<never>()
})
})

View File

@@ -1,42 +1,109 @@
// TODO: Drop disable once NodeId becomes a branded EntityId owned by src/world/.
// eslint-disable-next-line import-x/no-restricted-paths
import type { NodeId } from '@/lib/litegraph/src/LGraphNode'
// TODO: Drop disable once UUID moves to src/utils/ (no litegraph coupling).
// eslint-disable-next-line import-x/no-restricted-paths
import type { UUID } from '@/lib/litegraph/src/utils/uuid'
/**
* 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 { UUID } from '@/utils/uuid'
import type { Brand } from './brand'
export type NodeId = number | string
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'>
const SEPARATOR = ':'
function graphWidgetPrefix(graphId: GraphId): string {
return `widget:${graphId}:`
}
export function widgetEntityId(
graphId: UUID,
graphId: UUID | GraphId,
nodeId: NodeId,
name: string
): WidgetEntityId {
return `${graphId}${SEPARATOR}${nodeId}${SEPARATOR}${name}` as WidgetEntityId
return `${graphWidgetPrefix(graphId as GraphId)}${nodeId}:${name}` as WidgetEntityId
}
/**
* Guarded factory for `WidgetEntityId`. Returns `undefined` when any input
* required to construct the id is missing — graphless widgets, unbound
* node ids (`-1`), or unknown node ids cannot have a valid entity id.
*
* Use this from call sites that hold raw widget identity (graphId, nodeId,
* name); use the `widget.entityId` getter directly when you already have a
* `BaseWidget` instance.
*/
export function deriveWidgetEntityId(
graphId: UUID | GraphId | undefined,
nodeId: NodeId | undefined,
name: string
): WidgetEntityId | undefined {
if (!graphId || nodeId === undefined || nodeId === -1) return undefined
return widgetEntityId(graphId, nodeId, name)
}
/**
* Parse a `WidgetEntityId` into its constituent parts.
*
* On-the-wire format: `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 — so widget names may contain
* colons (e.g. `images.image:0`). NodeId values containing colons split
* at the first colon; production NodeIds are scalar-shaped, so this is a
* documented edge case rather than a defect. Throws on malformed input
* so upstream cast bugs surface at the parse site.
*/
const WIDGET_ID_RE = /^widget:([^:]+):([^:]+):(.*)$/
export function parseWidgetEntityId(id: WidgetEntityId): {
graphId: UUID
graphId: GraphId
nodeId: NodeId
name: string
} {
const firstColon = id.indexOf(SEPARATOR)
const secondColon = id.indexOf(SEPARATOR, firstColon + 1)
const match = WIDGET_ID_RE.exec(id)
if (!match) {
throw new Error(`Malformed WidgetEntityId: ${id}`)
}
const [, graphId, nodeId, name] = match
return {
graphId: id.slice(0, firstColon),
nodeId: id.slice(firstColon + 1, secondColon),
name: id.slice(secondColon + 1)
graphId: graphId as GraphId,
nodeId: nodeId as NodeId,
name
}
}
export function isWidgetEntityId(value: unknown): value is WidgetEntityId {
if (typeof value !== 'string') return false
const firstColon = value.indexOf(SEPARATOR)
if (firstColon <= 0) return false
const secondColon = value.indexOf(SEPARATOR, firstColon + 1)
return secondColon > firstColon + 1
return typeof value === 'string' && WIDGET_ID_RE.test(value)
}
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))
}

View File

@@ -1,164 +0,0 @@
import { setActivePinia, createPinia } from 'pinia'
import { beforeEach, describe, expect, it } from 'vitest'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import { widgetEntityId } from './entityIds'
import {
ensureWidgetState,
getWidgetState,
readWidgetValue,
writeWidgetValue
} from './widgetValueIO'
describe('widgetValueIO', () => {
const graphA = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890'
const graphB = 'b1b2c3d4-e5f6-7890-abcd-ef1234567890'
beforeEach(() => {
setActivePinia(createPinia())
})
describe('ensureWidgetState', () => {
it('registers a new state when none exists', () => {
const id = widgetEntityId(graphA, 1, 'seed')
const state = ensureWidgetState(id, {
type: 'number',
value: 11,
options: {},
label: undefined,
serialize: true,
disabled: false
})
expect(state.value).toBe(11)
expect(state.nodeId).toBe('1')
expect(state.name).toBe('seed')
})
it('is idempotent — returns the same state on repeated calls', () => {
const id = widgetEntityId(graphA, 1, 'seed')
const init = {
type: 'number',
value: 11,
options: {},
label: undefined,
serialize: true,
disabled: false
}
const first = ensureWidgetState(id, init)
const second = ensureWidgetState(id, init)
expect(second).toBe(first)
})
it('does not overwrite an existing state with init values', () => {
const id = widgetEntityId(graphA, 1, 'seed')
const first = ensureWidgetState(id, {
type: 'number',
value: 11,
options: {},
label: undefined,
serialize: true,
disabled: false
})
first.value = 99
const second = ensureWidgetState(id, {
type: 'number',
value: 11,
options: {},
label: undefined,
serialize: true,
disabled: false
})
expect(second.value).toBe(99)
})
})
describe('readWidgetValue / writeWidgetValue', () => {
it('round-trips a value through the entity-id surface', () => {
const id = widgetEntityId(graphA, 1, 'seed')
ensureWidgetState(id, {
type: 'number',
value: 11,
options: {},
label: undefined,
serialize: true,
disabled: false
})
expect(readWidgetValue(id)).toBe(11)
expect(writeWidgetValue(id, 22)).toBe(true)
expect(readWidgetValue(id)).toBe(22)
})
it('returns false when writing to an unregistered id', () => {
const id = widgetEntityId(graphA, 1, 'seed')
expect(writeWidgetValue(id, 22)).toBe(false)
})
it('returns undefined when reading an unregistered id', () => {
const id = widgetEntityId(graphA, 1, 'seed')
expect(readWidgetValue(id)).toBeUndefined()
expect(getWidgetState(id)).toBeUndefined()
})
})
describe('isolation', () => {
it('keeps independent values across distinct entity ids', () => {
const id1 = widgetEntityId(graphA, 1, 'seed')
const id2 = widgetEntityId(graphA, 2, 'seed')
const init = {
type: 'number',
value: 0,
options: {},
label: undefined,
serialize: true,
disabled: false
}
ensureWidgetState(id1, init)
ensureWidgetState(id2, init)
writeWidgetValue(id1, 11)
writeWidgetValue(id2, 22)
expect(readWidgetValue(id1)).toBe(11)
expect(readWidgetValue(id2)).toBe(22)
})
it('isolates values across graph ids', () => {
const idA = widgetEntityId(graphA, 1, 'seed')
const idB = widgetEntityId(graphB, 1, 'seed')
const init = {
type: 'number',
value: 0,
options: {},
label: undefined,
serialize: true,
disabled: false
}
ensureWidgetState(idA, init)
ensureWidgetState(idB, init)
writeWidgetValue(idA, 11)
writeWidgetValue(idB, 22)
expect(readWidgetValue(idA)).toBe(11)
expect(readWidgetValue(idB)).toBe(22)
})
it('matches the legacy triple-keyed API for the same widget', () => {
const id = widgetEntityId(graphA, 1, 'seed')
const init = {
type: 'number',
value: 11,
options: {},
label: undefined,
serialize: true,
disabled: false
}
ensureWidgetState(id, init)
const viaLegacy = useWidgetValueStore().getWidget(graphA, '1', 'seed')
expect(viaLegacy).toBe(getWidgetState(id))
})
})
})

View File

@@ -1,42 +0,0 @@
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import type { WidgetState } from '@/stores/widgetValueStore'
import { parseWidgetEntityId } from './entityIds'
import type { WidgetEntityId } from './entityIds'
export function getWidgetState(
entityId: WidgetEntityId
): WidgetState | undefined {
const { graphId, nodeId, name } = parseWidgetEntityId(entityId)
return useWidgetValueStore().getWidget(graphId, nodeId, name)
}
export function readWidgetValue(
entityId: WidgetEntityId
): WidgetState['value'] | undefined {
return getWidgetState(entityId)?.value
}
export function writeWidgetValue(
entityId: WidgetEntityId,
value: WidgetState['value']
): boolean {
const { graphId, nodeId, name } = parseWidgetEntityId(entityId)
return useWidgetValueStore().setValue(graphId, nodeId, name, value)
}
type WidgetStateInit = Omit<WidgetState, 'nodeId' | 'name'>
export function ensureWidgetState(
entityId: WidgetEntityId,
init: WidgetStateInit
): WidgetState {
const existing = getWidgetState(entityId)
if (existing) return existing
const { graphId, nodeId, name } = parseWidgetEntityId(entityId)
return useWidgetValueStore().registerWidget(graphId, {
...init,
nodeId,
name
})
}

View File

@@ -0,0 +1,19 @@
export interface WidgetState<
TValue = unknown,
TType extends string = string,
TOptions extends object = object
> {
value?: TValue
label?: string
disabled?: boolean
type: TType
options: TOptions
/**
* Whether the widget value is persisted in the workflow JSON
* (`widgets_values`). Distinct from `IWidgetOptions.serialize`, which
* controls whether the value is included in the API prompt sent for
* execution. See `src/lib/litegraph/docs/WIDGET_SERIALIZATION.md`.
* @default true
*/
serialize?: boolean
}