Compare commits

...

16 Commits

Author SHA1 Message Date
DrJKL
bbad9e58b8 Merge remote-tracking branch 'origin/main' into drjkl/world-combo 2026-05-05 12:06:09 -07:00
DrJKL
945f959259 revert: drop #11811 runtime changes
Amp-Thread-ID: https://ampcode.com/threads/T-019df697-4700-72b8-9ef5-52f17aefb3c4
Co-authored-by: Amp <amp@ampcode.com>
2026-05-05 01:01:31 -07:00
DrJKL
d4268e50a8 docs(world): add entity-id strategy proposal for opaque UUIDs across all entity kinds
Amp-Thread-ID: https://ampcode.com/threads/T-019df4ea-b627-757c-97cd-d2443bf690dd
Co-authored-by: Amp <amp@ampcode.com>
2026-05-04 17:35:37 -07:00
DrJKL
207d3f5aad test: reset World singleton between vitest runs
The World is a module-level singleton; without an inter-test reset,
widgets registered via the widgetValueStore facade leak across tests.
This was masked while widgetValueStore held its own per-instance Map
(reset via Pinia), but the facade rewrite shifts ownership to World.

Add resetWorldInstance() to the global beforeEach so any test that
touches the widgetValueStore starts with a clean world.

Amp-Thread-ID: https://ampcode.com/threads/T-019df514-1f1c-7764-aa92-76b65210a74d
Co-authored-by: Amp <amp@ampcode.com>
2026-05-04 15:46:44 -07:00
DrJKL
7c4b44db78 refactor(world): move widget types to src/world/widgets/
- Move WidgetState type from widgetValueStore.ts to src/world/widgets/widgetState.ts
- Move widget component-key definitions to src/world/widgets/widgetComponents.ts
- Derive component bucket shapes from IBaseWidget via Pick<> instead of hand-rolling
- Re-export WidgetState from widgetValueStore for back-compat
- Convert slot from arrow expression to function declaration

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

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

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

Cross-links from ADR 0008 Supporting Documents table.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Amp-Thread-ID: https://ampcode.com/threads/T-019dd146-a3ad-734d-9825-0ab356454dd5
Co-authored-by: Amp <amp@ampcode.com>
2026-05-04 15:32:25 -07:00
DrJKL
9a39207b52 fix(subgraph): restore promoted widget instance state (#11811)
Squashed cherry-pick of PR #11811.

Amp-Thread-ID: https://ampcode.com/threads/T-019df514-1f1c-7764-aa92-76b65210a74d
Co-authored-by: Amp <amp@ampcode.com>
2026-05-04 15:28:06 -07:00
44 changed files with 4162 additions and 297 deletions

View File

@@ -0,0 +1,284 @@
{
"id": "2f54e2f0-6db4-4bdf-84a8-9c3ea3ec0123",
"revision": 0,
"last_node_id": 13,
"last_link_id": 9,
"nodes": [
{
"id": 11,
"type": "422723e8-4bf6-438c-823f-881ca81acead",
"pos": [120, 180],
"size": [400, 200],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [
{ "name": "clip", "type": "CLIP", "link": null },
{ "name": "model", "type": "MODEL", "link": null },
{ "name": "positive", "type": "CONDITIONING", "link": null },
{ "name": "negative", "type": "CONDITIONING", "link": null },
{ "name": "latent_image", "type": "LATENT", "link": null }
],
"outputs": [],
"properties": {},
"widgets_values": ["Alpha\n"]
},
{
"id": 12,
"type": "422723e8-4bf6-438c-823f-881ca81acead",
"pos": [420, 180],
"size": [400, 200],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
{ "name": "clip", "type": "CLIP", "link": null },
{ "name": "model", "type": "MODEL", "link": null },
{ "name": "positive", "type": "CONDITIONING", "link": null },
{ "name": "negative", "type": "CONDITIONING", "link": null },
{ "name": "latent_image", "type": "LATENT", "link": null }
],
"outputs": [],
"properties": {},
"widgets_values": ["Beta\n"]
},
{
"id": 13,
"type": "422723e8-4bf6-438c-823f-881ca81acead",
"pos": [720, 180],
"size": [400, 200],
"flags": {},
"order": 2,
"mode": 0,
"inputs": [
{ "name": "clip", "type": "CLIP", "link": null },
{ "name": "model", "type": "MODEL", "link": null },
{ "name": "positive", "type": "CONDITIONING", "link": null },
{ "name": "negative", "type": "CONDITIONING", "link": null },
{ "name": "latent_image", "type": "LATENT", "link": null }
],
"outputs": [],
"properties": {},
"widgets_values": ["Gamma\n"]
}
],
"links": [],
"groups": [],
"definitions": {
"subgraphs": [
{
"id": "422723e8-4bf6-438c-823f-881ca81acead",
"version": 1,
"state": {
"lastGroupId": 0,
"lastNodeId": 11,
"lastLinkId": 15,
"lastRerouteId": 0
},
"revision": 0,
"config": {},
"name": "New Subgraph",
"inputNode": {
"id": -10,
"bounding": [481.59912109375, 379.13336181640625, 120, 160]
},
"outputNode": {
"id": -20,
"bounding": [1121.59912109375, 379.13336181640625, 120, 40]
},
"inputs": [
{
"id": "0f07c10e-5705-4764-9b24-b69606c6dbcc",
"name": "text",
"type": "STRING",
"linkIds": [10],
"pos": { "0": 581.59912109375, "1": 399.13336181640625 }
},
{
"id": "214a5060-24dd-4299-ab78-8027dc5b9c59",
"name": "clip",
"type": "CLIP",
"linkIds": [11],
"pos": { "0": 581.59912109375, "1": 419.13336181640625 }
},
{
"id": "8ab94c5d-e7df-433c-9177-482a32340552",
"name": "model",
"type": "MODEL",
"linkIds": [12],
"pos": { "0": 581.59912109375, "1": 439.13336181640625 }
},
{
"id": "8a4cd719-8c67-473b-9b44-ac0582d02641",
"name": "positive",
"type": "CONDITIONING",
"linkIds": [13],
"pos": { "0": 581.59912109375, "1": 459.13336181640625 }
},
{
"id": "a78d6b3a-ad40-4300-b0a5-2cdbdb8dc135",
"name": "negative",
"type": "CONDITIONING",
"linkIds": [14],
"pos": { "0": 581.59912109375, "1": 479.13336181640625 }
},
{
"id": "4c7abe0c-902d-49ef-a5b0-cbf02b50b693",
"name": "latent_image",
"type": "LATENT",
"linkIds": [15],
"pos": { "0": 581.59912109375, "1": 499.13336181640625 }
}
],
"outputs": [],
"widgets": [],
"nodes": [
{
"id": 10,
"type": "CLIPTextEncode",
"pos": [661.59912109375, 314.13336181640625],
"size": [400, 200],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
{
"localized_name": "clip",
"name": "clip",
"type": "CLIP",
"link": 11
},
{
"localized_name": "text",
"name": "text",
"type": "STRING",
"widget": { "name": "text" },
"link": 10
}
],
"outputs": [
{
"localized_name": "CONDITIONING",
"name": "CONDITIONING",
"type": "CONDITIONING",
"links": null
}
],
"properties": {
"Node name for S&R": "CLIPTextEncode"
},
"widgets_values": [""]
},
{
"id": 11,
"type": "KSampler",
"pos": [674.1234741210938, 570.5839233398438],
"size": [270, 262],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [
{
"localized_name": "model",
"name": "model",
"type": "MODEL",
"link": 12
},
{
"localized_name": "positive",
"name": "positive",
"type": "CONDITIONING",
"link": 13
},
{
"localized_name": "negative",
"name": "negative",
"type": "CONDITIONING",
"link": 14
},
{
"localized_name": "latent_image",
"name": "latent_image",
"type": "LATENT",
"link": 15
}
],
"outputs": [
{
"localized_name": "LATENT",
"name": "LATENT",
"type": "LATENT",
"links": null
}
],
"properties": {
"Node name for S&R": "KSampler"
},
"widgets_values": [0, "randomize", 20, 8, "euler", "simple", 1]
}
],
"groups": [],
"links": [
{
"id": 10,
"origin_id": -10,
"origin_slot": 0,
"target_id": 10,
"target_slot": 1,
"type": "STRING"
},
{
"id": 11,
"origin_id": -10,
"origin_slot": 1,
"target_id": 10,
"target_slot": 0,
"type": "CLIP"
},
{
"id": 12,
"origin_id": -10,
"origin_slot": 2,
"target_id": 11,
"target_slot": 0,
"type": "MODEL"
},
{
"id": 13,
"origin_id": -10,
"origin_slot": 3,
"target_id": 11,
"target_slot": 1,
"type": "CONDITIONING"
},
{
"id": 14,
"origin_id": -10,
"origin_slot": 4,
"target_id": 11,
"target_slot": 2,
"type": "CONDITIONING"
},
{
"id": 15,
"origin_id": -10,
"origin_slot": 5,
"target_id": 11,
"target_slot": 3,
"type": "LATENT"
}
],
"extra": {}
}
]
},
"config": {},
"extra": {
"ds": {
"scale": 1,
"offset": [0, 0]
},
"frontendVersion": "1.24.1"
},
"version": 0.4
}

View File

@@ -7,6 +7,10 @@ import type {
} from '@/lib/litegraph/src/litegraph'
import type { ComfyWorkflow } from '@/platform/workflow/management/stores/comfyWorkflow'
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
import type {
ProxyWidgetTuple,
SerializedProxyWidgetTuple
} from '@/core/schemas/promotionSchema'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import { TestIds } from '@e2e/fixtures/selectors'
@@ -386,7 +390,7 @@ export class SubgraphHelper {
}
async getHostPromotedTupleSnapshot(): Promise<
{ hostNodeId: string; promotedWidgets: [string, string][] }[]
{ hostNodeId: string; promotedWidgets: SerializedProxyWidgetTuple[] }[]
> {
return this.page.evaluate(() => {
const graph = window.app!.canvas.graph!
@@ -401,15 +405,18 @@ export class SubgraphHelper {
: []
const promotedWidgets = proxyWidgets
.filter(
(entry): entry is [string, string] =>
(entry): entry is ProxyWidgetTuple =>
Array.isArray(entry) &&
entry.length >= 2 &&
typeof entry[0] === 'string' &&
typeof entry[1] === 'string'
)
.map(
([interiorNodeId, widgetName]) =>
[interiorNodeId, widgetName] as [string, string]
([sourceNodeId, serializedSourceWidgetName]) =>
[
sourceNodeId,
serializedSourceWidgetName
] satisfies SerializedProxyWidgetTuple
)
return {

View File

@@ -0,0 +1,40 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
const MULTI_INSTANCE_WORKFLOW =
'subgraphs/subgraph-multi-instance-promoted-text-values'
test.describe(
'Multi-instance subgraph promoted widget rendering in Vue mode',
{ tag: ['@subgraph', '@vue-nodes', '@widget'] },
() => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
})
test('Each subgraph instance renders its own promoted widget value, not the interior default', async ({
comfyPage
}) => {
const expectedByNodeId: Record<string, string> = {
'11': 'Alpha\n',
'12': 'Beta\n',
'13': 'Gamma\n'
}
await comfyPage.workflow.loadWorkflow(MULTI_INSTANCE_WORKFLOW)
await comfyPage.vueNodes.waitForNodes(3)
for (const [nodeId, expectedValue] of Object.entries(expectedByNodeId)) {
const subgraphNode = comfyPage.vueNodes.getNodeLocator(nodeId)
await expect(subgraphNode).toBeVisible()
const textarea = subgraphNode.getByRole('textbox', {
name: 'text',
exact: true
})
await expect(textarea).toHaveValue(expectedValue)
}
})
}
)

View File

@@ -14,6 +14,34 @@ import {
const DUPLICATE_IDS_WORKFLOW = 'subgraphs/subgraph-nested-duplicate-ids'
const LEGACY_PREFIXED_WORKFLOW =
'subgraphs/nested-subgraph-legacy-prefixed-proxy-widgets'
const LEGACY_THREE_TUPLE_WORKFLOW = 'subgraphs/nested-duplicate-widget-names'
const MULTI_INSTANCE_WORKFLOW =
'subgraphs/subgraph-multi-instance-promoted-text-values'
async function getPromotedHostWidgetValues(
comfyPage: ComfyPage,
nodeIds: string[]
) {
return comfyPage.page.evaluate((ids) => {
const graph = window.app!.canvas.graph!
return ids.map((id) => {
const node = graph.getNodeById(id)
if (
!node ||
typeof node.isSubgraphNode !== 'function' ||
!node.isSubgraphNode()
) {
return { id, values: [] as unknown[] }
}
return {
id,
values: (node.widgets ?? []).map((widget) => widget.value)
}
})
}, nodeIds)
}
async function expectPromotedWidgetsToResolveToInteriorNodes(
comfyPage: ComfyPage,
@@ -498,4 +526,63 @@ test.describe('Subgraph Serialization', { tag: ['@subgraph'] }, () => {
})
}
)
test(
'Legacy 3-tuple proxyWidgets entries serialize back to 2-tuples after load',
{ tag: '@vue-nodes' },
async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow(LEGACY_THREE_TUPLE_WORKFLOW)
const hostNode = comfyPage.vueNodes.getNodeLocator('4')
await expect(hostNode).toBeVisible()
const promotedTextbox = hostNode.getByRole('textbox', {
name: 'text',
exact: true
})
await expect(promotedTextbox).toHaveCount(1)
await expect(promotedTextbox).toHaveValue('22222222222')
await expect(hostNode.getByText('text', { exact: true })).toBeVisible()
const serializedProxyWidgets = await comfyPage.page.evaluate(() => {
const serialized = window.app!.graph!.serialize()
const hostNode = serialized.nodes.find((node) => node.id === 4)
const proxyWidgets = hostNode?.properties?.proxyWidgets
return Array.isArray(proxyWidgets) ? proxyWidgets : []
})
expect(serializedProxyWidgets).toEqual([['3', '3: 2: text']])
expect(
serializedProxyWidgets.every(
(entry) => Array.isArray(entry) && entry.length === 2
)
).toBe(true)
}
)
test('Multiple instances of the same subgraph keep distinct promoted widget values after load and reload', async ({
comfyPage
}) => {
const hostNodeIds = ['11', '12', '13']
const expectedValues = ['Alpha\n', 'Beta\n', 'Gamma\n']
await comfyPage.workflow.loadWorkflow(MULTI_INSTANCE_WORKFLOW)
const initialValues = await getPromotedHostWidgetValues(
comfyPage,
hostNodeIds
)
expect(initialValues.map(({ values }) => values[0])).toEqual(expectedValues)
await comfyPage.subgraph.serializeAndReload()
const reloadedValues = await getPromotedHostWidgetValues(
comfyPage,
hostNodeIds
)
expect(reloadedValues.map(({ values }) => values[0])).toEqual(
expectedValues
)
})
})

View File

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

View File

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

View File

@@ -0,0 +1,637 @@
# Entity ID Strategy: Opaque IDs and Normalized Identity Components
_A design proposal for migrating all six entity kinds in the ECS taxonomy
of [ADR 0008](../adr/0008-entity-component-system.md) — Node, Link, Widget,
Slot, Reroute, Group — from primitive or content-addressed identifiers to
**opaque UUIDs**, with identity data normalized into per-entity components
and the necessary secondary indices owned by domain stores. This document
is a follow-on to ADR 0008 and the [ECS Pattern Survey](appendix-ecs-pattern-survey.md).
Status: proposed, pending sign-off before implementation._
The motivating defect is in
[src/world/entityIds.ts](../../src/world/entityIds.ts) (the slice-1
identity contract for widgets and node containers), but the design
question generalizes: **identity should be a component, not encoded into
the entity-ID string, and entity IDs should be opaque across all kinds.**
This document defines that strategy uniformly so subsequent ECS migration
slices (per the [migration plan](ecs-migration-plan.md)) extend the same
pattern instead of recreating the choice for each kind.
---
## 1. Context
### 1.1 What ADR 0008 specifies
ADR 0008 §"Branded ID Design" specifies branded **numeric** IDs for all
six entity kinds:
```ts
type NodeEntityId = number & { readonly __brand: 'NodeEntityId' }
type LinkEntityId = number & { readonly __brand: 'LinkEntityId' }
type WidgetEntityId = number & { readonly __brand: 'WidgetEntityId' }
type SlotEntityId = number & { readonly __brand: 'SlotEntityId' }
type RerouteEntityId = number & { readonly __brand: 'RerouteEntityId' }
type GroupEntityId = number & { readonly __brand: 'GroupEntityId' }
type GraphId = string & { readonly __brand: 'GraphId' }
```
The ADR notes that widgets and slots currently lack independent IDs and
proposes to assign synthetic IDs at entity creation time via an
auto-incrementing counter (matching the pattern used by `lastNodeId`,
`lastLinkId` in `LGraphState`).
### 1.2 What the slice-1 implementation actually did
The first ECS slice ([src/world/](../../src/world/)) covers **widgets**
and **node containers**, and it departed from ADR 0008's "numeric ID"
contract in two ways:
1. IDs are **strings**, not numbers (`Brand<string, ...>`).
2. IDs are **content-addressed**`widgetEntityId(g, n, w)` is computed
from `(graphId, nodeId, widgetName)`, not minted from a counter:
```
node:${graphId}:${nodeId}
widget:${graphId}:${nodeId}:${name}
```
The departure was deliberate — content-addressing gives "an entity viewed
at different subgraph depths shares state" for free — but the rationale
was never recorded in an architecture doc, and it leaves the wire format
unauthorized by ADR 0008.
### 1.3 Three problems with the slice-1 scheme
1. **Silent corruption when nodeId contains a colon.**
`parseWidgetEntityId`'s regex `/^widget:([^:]+):([^:]+):(.*)$/` captures
`nodeId` as `[^:]+`, so a string nodeId like `"sg:42"` produces
`widget:g:sg:42:name`, which the regex mis-parses as `(g, sg, 42:name)`.
`widgetValueStore.getNodeWidgetsByName` then keys the returned `Map` by
the wrong name. The defect is latent today — verified: every production
producer stringifies a bare local NodeId — but the type
`NodeId = number | string` makes no runtime guarantee, and any future
migration toward `NodeLocatorId` (e.g. `<subgraph-uuid>:<id>`) trips
the bug.
2. **Identity is the address.** Renaming a widget mints a new entity
(different name → different `widgetEntityId`); rewriting `nodeId`
mints a new entity. There is no concept of "the widget formerly known
as X" — per-aspect components attach to the address-derived ID and
are abandoned at rename. For features that need stable identity
across relabels (promoted widgets, subgraph reparenting, CRDT
replication of label changes), this is a structural mismatch.
3. **Identity is recovered by parsing.** The regex is the only path by
which `getNodeWidgetsByName` knows which widget has which name. The
data has no representation in the World — it lives only in the string.
Every consumer that needs the name must round-trip through the
parser.
### 1.4 Why this generalizes
The same three problems apply, in principle, to every other entity kind
that lacks an independent ID. **Slot** identity today is `(parent node,
direction, index)`; if we encoded that as a colon-joined string, the same
parser hazards reappear. **Link** identity is `LinkId = number` (already
opaque), but its endpoints `(origin_id, origin_slot, target_id,
target_slot)` are content-shaped — and any identity migration will face
the same "do we encode this into the ID, or normalize it into a
component?" choice. The design decision is the same across all six
kinds; this document makes it once.
---
## 2. Decision
Adopt the **opaque-entity-id + identity-as-component** pattern for all
six entity kinds. The pattern matches what mainstream ECS libraries
converge on (Flecs `(ChildOf, parent)` pairs, Bevy `Parent`+`Children`,
koota `relation()`, EnTT `relationship.parent`, miniplex
`entity.livesOn`); see §5 for the survey citations.
### 2.1 Entity IDs are opaque UUIDs across all kinds
```ts
// src/world/entityIds.ts (proposed)
export type NodeEntityId = Brand<string, 'NodeEntityId'>
export type LinkEntityId = Brand<string, 'LinkEntityId'>
export type WidgetEntityId = Brand<string, 'WidgetEntityId'>
export type SlotEntityId = Brand<string, 'SlotEntityId'>
export type RerouteEntityId = Brand<string, 'RerouteEntityId'>
export type GroupEntityId = Brand<string, 'GroupEntityId'>
export type EntityId =
| NodeEntityId
| LinkEntityId
| WidgetEntityId
| SlotEntityId
| RerouteEntityId
| GroupEntityId
export const mintNodeId = (): NodeEntityId =>
crypto.randomUUID() as NodeEntityId
export const mintLinkId = (): LinkEntityId =>
crypto.randomUUID() as LinkEntityId
export const mintWidgetId = (): WidgetEntityId =>
crypto.randomUUID() as WidgetEntityId
export const mintSlotId = (): SlotEntityId =>
crypto.randomUUID() as SlotEntityId
export const mintRerouteId = (): RerouteEntityId =>
crypto.randomUUID() as RerouteEntityId
export const mintGroupId = (): GroupEntityId =>
crypto.randomUUID() as GroupEntityId
```
UUIDs (rather than numeric counters) for three reasons. First, no
coordination required — `crypto.randomUUID` is universally available and
collision-resistant without `LGraphState.lastWidgetId++` bookkeeping.
Second, they're stable across CRDT replication (per ADR 0003) without an
ID-mapping layer. Third, the `Brand<string, ...>` type machinery in
[src/world/brand.ts](../../src/world/brand.ts) already phantom-types over
`string`, so retaining string concrete types keeps zero friction with
existing component declarations.
This **amends ADR 0008 §"Branded ID Design"**: the `& { readonly __brand: ... }`
discipline is preserved, but the underlying type is `string` (UUID) for
all kinds, not `number`.
**Deletions from current code:** `nodeEntityId`, `widgetEntityId`,
`parseWidgetEntityId`, `graphNodePrefix`, `graphWidgetPrefix`,
`isNodeIdForGraph`, `isWidgetIdForGraph`, the regex. The wire-format
concept goes away.
**Litegraph-numeric IDs are preserved as data** — `LGraphState.lastNodeId`
and friends continue to assign legacy numeric IDs that workflow JSON
serialization depends on (per ADR 0008 §"The internal ECS model and the
serialization format are deliberately separate concerns"). The legacy
numeric ID becomes a field on the entity's identity component (§2.2),
not the entity's identity itself.
### 2.2 Identity components per entity kind
Each entity kind gets a "BelongsTo"-style component carrying its
structural relationships (parent pointers) and any legacy identifiers
needed for serialization parity. This is the canonical "parent pointer
on child" pattern; see §3.1 and §5.
#### Node
```ts
export const NodeBelongsTo = defineComponentKey<
{
graphId: GraphId
litegraphNodeId: NodeId // legacy `LGraphNode.id`, kept for serialization
},
NodeEntityId
>('NodeBelongsTo')
```
Nodes belong to a graph (their containing `LGraph` or `Subgraph`).
`litegraphNodeId` preserves the local-to-graph numeric ID that
`workflowSchema.json` round-tripping requires.
#### Link
```ts
export const LinkBelongsTo = defineComponentKey<
{
graphId: GraphId
litegraphLinkId: LinkId // legacy `LLink.id`
},
LinkEntityId
>('LinkBelongsTo')
export const LinkEndpoints = defineComponentKey<
{
origin: SlotEntityId
target: SlotEntityId
type: ISlotType
},
LinkEntityId
>('LinkEndpoints')
```
`LinkEndpoints` (already proposed in ADR 0008 §"Component Decomposition")
becomes the structural relationship; under the new scheme its fields are
opaque `SlotEntityId`s, not `(node_id, slot_index)` tuples.
#### Widget
```ts
export const WidgetBelongsTo = defineComponentKey<
{
graphId: GraphId
nodeId: NodeEntityId // parent pointer
name: string // identity within the parent
},
WidgetEntityId
>('WidgetBelongsTo')
```
The widget-on-node parent pointer is the bug-relevant case. `name`
identifies the widget within its parent node and replaces the
parser-recovered name in `widgetEntityId`'s wire format.
#### Slot
```ts
export const SlotBelongsTo = defineComponentKey<
{
graphId: GraphId
nodeId: NodeEntityId // parent pointer
direction: 'input' | 'output'
index: number // ordinal within the parent's input/output array
name: string
},
SlotEntityId
>('SlotBelongsTo')
```
Slots are an extreme case of "identity is currently the address" —
today's identity is literally `(node, direction, index)`, with the index
shifting whenever a slot is inserted or removed. Under opaque UUIDs, slot
identity persists across reorderings; the `index` field becomes a
position in an ordered children list, not a name.
#### Reroute
```ts
export const RerouteBelongsTo = defineComponentKey<
{
graphId: GraphId
parentRerouteId: RerouteEntityId | null // optional reroute parent
litegraphRerouteId: RerouteId // legacy `Reroute.id`
},
RerouteEntityId
>('RerouteBelongsTo')
```
Reroutes can chain (`Reroute.parentId`); the parent-pointer pattern
applies recursively. Reroutes also reference the link they belong to via
`RerouteLinks` (ADR 0008 §"Reroute"); under opaque IDs that becomes a
list of `LinkEntityId`s.
#### Group
```ts
export const GroupBelongsTo = defineComponentKey<
{
graphId: GraphId
litegraphGroupId: number // legacy `LGraphGroup` numeric id
},
GroupEntityId
>('GroupBelongsTo')
export const GroupChildren = defineComponentKey<
{ entityIds: (NodeEntityId | RerouteEntityId)[] },
GroupEntityId
>('GroupChildren')
```
Groups own a heterogeneous children list (nodes and reroutes within
their bounds). `GroupChildren` is the denormalized cache (§2.3); each
member also carries an optional `MemberOf` back-pointer if downward
iteration is hot (it isn't currently).
### 2.3 Children lists on parents — denormalized caches
Where downward iteration is hot, the parent gets a denormalized
children-list component. The existing
[`WidgetComponentContainer`](../../src/world/widgets/widgetComponents.ts)
on the node side is the canonical example; this generalizes to:
| Parent | Children component | Hot path justifying it |
| ------ | ------------------------------------------------------------------------------------- | ---------------------------------- |
| Node | `WidgetComponentContainer { widgetIds: [...] }` | Per-frame Vue-node rendering |
| Node | `SlotChildren { inputs: [...], outputs: [...] }` | Connection drawing, hit-testing |
| Graph | `GraphMembers { nodeIds: [...], linkIds: [...], rerouteIds: [...], groupIds: [...] }` | Top-level rendering, `clearGraph` |
| Group | `GroupChildren { entityIds: [...] }` | Group-bounding-box render |
| Link | `RerouteLinks { rerouteIds: [...] }` | Path rendering along reroute chain |
These are caches **derived from** the per-child `*BelongsTo` parent
pointer. They exist for performance and are maintained by the same
mutation API that owns the parent pointer (§2.4); callers outside that
API never write to them directly. This is the Bevy
`Parent`+`Children`-symmetric-management discipline.
### 2.4 Secondary indices live in domain stores
Under opaque UUIDs, the question "given some content-shaped key, find
the entity" is no longer answered by computing a string. Each domain
store keeps a private forward index whose key is built with
`makeCompositeKey` (already in
[src/utils/compositeKey.ts](../../src/utils/compositeKey.ts)) so the
encoding is injective by construction:
| Store | Forward index | Key shape |
| --------------------- | -------------------- | ------------------------------------------- |
| `widgetValueStore` | `widgetByAddress` | `ckey([graphId, nodeId, widgetName])` |
| `widgetValueStore` | `nodeByAddress` | `ckey([graphId, litegraphNodeId])` |
| (slot store, future) | `slotByAddress` | `ckey([graphId, nodeId, direction, index])` |
| (link store, future) | `linkByLitegraphId` | `ckey([graphId, litegraphLinkId])` |
| (group store, future) | `groupByLitegraphId` | `ckey([graphId, litegraphGroupId])` |
Plus per-graph entity sets for O(set-size) bulk clear, replacing the
slice-1 `isWidgetIdForGraph` startsWith-scan:
```ts
const widgetsByGraph = new Map<GraphId, Set<WidgetEntityId>>()
const nodesByGraph = new Map<GraphId, Set<NodeEntityId>>()
// (and analogous per-kind sets in each store as kinds migrate)
```
All indices are private and mutated only by the owning store's public
methods (`registerWidget` / `clearGraph` / a future `unregisterWidget`).
This is the centralized-mutation discipline that Bevy's hierarchy
plugin enforces through `Commands` extension methods — and that the ECS
literature unanimously identifies as the precondition for safely
denormalizing hierarchy state.
### 2.5 Surface area summary
| Layer | Primitive | Role |
| ----------------------------- | --------------------------------------------- | -------------------------------------- |
| `entityIds.ts` | `mint{Node,Link,Widget,Slot,Reroute,Group}Id` | Opaque ID generation |
| domain `*Components.ts` files | `*BelongsTo` components | Parent pointers (identity) |
| domain `*Components.ts` files | `*Children` / `*Container` components | Denormalized caches |
| domain stores | `*ByAddress` indices | Forward content-address lookup |
| domain stores | `*ByGraph` indices | Per-graph bulk clear |
| domain store public methods | `register*` / `clearGraph` / `unregister*` | Sole writers to indices and components |
---
## 3. Why this is the right shape
### 3.1 It's what the ECS literature converges on
Three independent surveys (Flecs/Bevy/EnTT, koota/miniplex, and
normalization-tradeoff literature) agree on the same finding:
**store the parent pointer on the child, add a denormalized children
list on the parent only when downward iteration is hot, and centralize
all mutations behind a small API**. This document proposes exactly that
pattern — uniformly across all six entity kinds.
### 3.2 It removes parsers as an attack surface — for every kind
`parseWidgetEntityId` does not exist in the new scheme. The temptation
to write a `parseSlotEntityId` (which would face the same hazards on
slot names) is removed before it appears. Identity is read structurally
from components.
### 3.3 It decouples address from identity
Today, `widget(g, n, "old-name")` and `widget(g, n, "new-name")` are
different entities. Slot reordering today changes slot identity
(because identity = ordinal index). Reparenting today rotates entity IDs
across graphs. Under opaque UUIDs none of these mutations rotates
identity — they're component edits. This unlocks features that need
identity continuity (promoted-widget identity preservation,
slot-reorder-without-reconnection, label-swapping in CRDT replication)
without touching the substrate.
### 3.4 It aligns with ADRs 0003 and 0008
ADR 0003's command pattern requires that all mutations be serializable
and replayable. Opaque UUIDs are easier to serialize coherently — they
don't carry a graphId-prefix that needs rewriting on subgraph remount,
and they don't depend on the serialization format encoding identity
tuples identically across versions. Component-resident identity makes
command shapes ("set `WidgetBelongsTo.name` on `<uuid>`") trivially
expressible.
ADR 0008's "world is the source of truth, serialization is a
translation" principle is reinforced: the World holds opaque UUIDs and
identity components; the SerializationSystem maps them to/from the
`workflowSchema.json` format that uses litegraph-numeric IDs.
### 3.5 It absorbs the colon-collision fix as a side effect
The bug that motivates this document is solved twice over by the design:
the parser is gone, AND the address index uses `makeCompositeKey`
(already injective). No assert, no regex, no caveat.
---
## 4. Costs and risks
### 4.1 Index hygiene — across more stores
For each migrated kind, the per-store invariant is the same: indices
must stay in lockstep with the World. The discipline that makes this
safe is API-confinement: **all mutation goes through the owning store's
public methods**. Direct `world.setComponent` calls bypass the indices
and corrupt them.
Mitigations (apply per-store):
- Document the contract in the file's top doc-comment.
- Add a unit test that asserts the indices match the World contents
after a randomized sequence of register/clear operations.
- Optionally, add a debug-build-only `world.assertConsistentIndices()`
check.
### 4.2 Debuggability of opaque IDs
UUIDs in stack traces require reading the corresponding `*BelongsTo`
component to recover the human-meaningful identity. This applies to
every kind, not just widgets.
Mitigations:
- Provide a `describeEntity(id)` helper per store that returns the
identity tuple for logging.
- Vue DevTools / Pinia inspector should surface the `*BelongsTo`
components for registered entities.
### 4.3 Migration effort, by kind
The slices below mirror the [migration plan](ecs-migration-plan.md)'s
incremental approach. Each kind ships independently:
| Kind | Status today | Migration cost |
| -------------- | ---------------------------------------------------- | ----------------------------------------------------------------------------------------------- |
| Widget | In World (slice 1, content-addressed strings) | High: rewrite `entityIds.ts`, `widgetValueStore.ts`, ~6 test files. ~+250/-80 prod, ~+100 test. |
| Node container | In World (slice 1, content-addressed strings) | Bundled with widget migration; same surface area. |
| Slot | Not in World; lives on `LGraphNode.{inputs,outputs}` | Per ADR 0008 migration plan. New: `slotEntityId`, `SlotBelongsTo`, slot store. |
| Link | Not in World; lives on `LGraph.links` | New: `linkEntityId`, `LinkBelongsTo`, `LinkEndpoints` (slot-keyed). |
| Reroute | Not in World; lives on `LGraph.reroutes` | New: `rerouteEntityId`, `RerouteBelongsTo`, `RerouteLinks`. |
| Group | Not in World; lives on `LGraph._groups` | New: `groupEntityId`, `GroupBelongsTo`, `GroupChildren`. |
The widget+node migration is the only slice that touches existing World
code and existing tests. All other kinds are greenfield extensions — the
strategy is fixed by this document so each slice executes mechanically
without re-litigating identity-format choices.
### 4.4 Re-registration after `clearGraph`
If `clearGraph(g)` cleans World components but leaves stale entries in
the per-store address indices, a subsequent `register*(g, ...)` returns
an old UUID and re-binds components on a "ghost" entity. This is
functionally equivalent to today's content-addressed re-use, but the
discipline must be enforced: **`clearGraph(g)` MUST clear all index
entries for graph `g` before returning.** Per-store unit tests should
include a `clear → re-register` round-trip case.
### 4.5 Tests that hand-construct entity IDs
Slice-1 tests synthesize `WidgetEntityId`/`NodeEntityId` strings directly
(e.g. `defineComponentKey<...>` typing assertions in
[world.test.ts](../../src/world/world.test.ts)). These will need to mint
UUIDs instead. The change is mechanical but touches several test files
and will recur for each subsequent slice.
### 4.6 Litegraph-numeric ID coexistence
`workflowSchema.json` round-tripping requires preserving the numeric IDs
emitted by `LGraphState.lastNodeId++` etc. Under opaque-UUID identity
those numbers become **data** on the `*BelongsTo` component
(`litegraphNodeId`, `litegraphLinkId`, `litegraphGroupId`,
`litegraphRerouteId`). The SerializationSystem reads them when emitting
JSON and assigns matching values when re-hydrating. This is the same
"World ID ≠ wire ID" decoupling that ADR 0008 requires.
---
## 5. Cross-references
### 5.1 ECS library survey
Detailed comparison in [appendix-ecs-pattern-survey.md](appendix-ecs-pattern-survey.md).
The pattern this document proposes — opaque IDs + parent-pointer-on-child
- denormalized children + centralized mutation — is what koota,
miniplex, EnTT, and Bevy all converge on for hierarchy modeling. Flecs
goes further by encoding the parent into the archetype, which JS ECSes
don't have access to without significant substrate work.
### 5.2 Source citations for §2 and §3.1
- Flecs hierarchies and pairs:
[Hierarchies Manual](https://www.flecs.dev/flecs/md_docs_2HierarchiesManual.html),
[Relationships Manual](https://www.flecs.dev/flecs/md_docs_2Relationships.html)
- Bevy parent/children components:
[bevy_hierarchy docs](https://docs.rs/bevy_hierarchy),
[Bevy Cheat Book hierarchy](https://bevy-cheatbook.github.io/fundamentals/hierarchy.html)
- EnTT relationship guidance:
skypjack, [ECS back and forth, part 4](https://skypjack.github.io/2019-09-25-ecs_baf_part_4/)
- koota relation primitive:
[koota/relation source](https://github.com/pmndrs/koota/blob/main/packages/core/src/relation/relation.ts)
- miniplex entity-reference pattern:
[miniplex README](https://github.com/hmans/miniplex)
- Normalization tradeoffs:
Mertens, [Building Games in ECS with Entity Relationships](https://ajmmertens.medium.com/building-games-in-ecs-with-entity-relationships-657275ba2c6c);
IceFall Games,
[Managing game object hierarchy in an ECS](https://mtnphil.wordpress.com/2014/06/09/managing-game-object-hierarchy-in-an-entity-component-system/)
### 5.3 Related ADRs and architecture docs
- [ADR 0003: Centralized Layout Management with CRDT](../adr/0003-crdt-based-layout-system.md) —
command-pattern requirement that opaque IDs simplify
- [ADR 0008: Entity Component System](../adr/0008-entity-component-system.md) —
this document amends §"Branded ID Design" by reinterpreting "numeric"
as "opaque UUID string" across all six entity kinds, and adds
`*BelongsTo` identity components to §"Component Decomposition"
- [ECS Pattern Survey](appendix-ecs-pattern-survey.md) — substrate-level
comparison
- [ECS Lifecycle Scenarios](ecs-lifecycle-scenarios.md) — concrete
before/after walkthroughs that this document's identity model affects
- [ECS Migration Plan](ecs-migration-plan.md) — the slice progression
this document's per-kind sections track against
- [ECS Target Architecture](ecs-target-architecture.md) — the world-shape
this document refines for identity
---
## 6. Migration plan
Two horizons: a near-term implementation slice that ships now, and a
strategy locked in for subsequent ADR 0008 slices.
### 6.1 Near-term: widgets and node containers (slice 1.5)
Ship as a single PR after the colon-collision consolidation
([entityIds-consolidation.md](../../temp/plans/entityIds-consolidation.md))
lands and is verified. Sequencing rationale: the consolidation is a
defensive 1-file fix that does not commit to this larger architectural
direction. If this proposal is rejected or amended, the consolidation
still stands as a strict improvement over the current state.
Phases inside the PR (single commit boundary, but reviewable as separate
hunks):
1. Add `WidgetBelongsTo`, `NodeBelongsTo` components and the four
indices in `widgetValueStore`. Populate them in `registerWidget`
alongside the existing entity-ID derivation. The string-format
`widgetEntityId` continues to exist; the new components are written
redundantly. All tests still pass.
2. Switch `getWidget` / `getNodeWidgets` / `getNodeWidgetsByName` to
read identity from `WidgetBelongsTo` instead of parsing the entity
ID. Delete `parseWidgetEntityId`. Delete the regex.
3. Switch `clearGraph` to consume `widgetsByGraph` / `nodesByGraph`
instead of `entitiesWith` + `isWidgetIdForGraph`. Delete the
`isXForGraph` helpers.
4. Replace `widgetEntityId` / `nodeEntityId` constructors with
`mintWidgetId` / `mintNodeId`. Switch the entity-ID format from
content-addressed strings to UUIDs.
5. Update test fixtures that hand-construct entity-ID strings.
Each phase ends in a green test suite. Quality gates:
`pnpm test:unit && pnpm typecheck && pnpm lint && pnpm knip`.
### 6.2 Subsequent slices: slots, links, reroutes, groups
Per the [migration plan](ecs-migration-plan.md), each remaining kind
moves into the World in its own slice. For each, the strategy is fixed
by this document:
1. Add the kind's `mint*Id` to `entityIds.ts` and the `*EntityId` brand.
2. Define the `*BelongsTo` identity component plus any `*Children` /
`*Endpoints` structural components per §2.2.
3. Create (or extend) the domain store that owns the per-graph and
forward-address indices, with the centralized-mutation API discipline.
4. Add the bridge layer (per ADR 0008 §"Migration Strategy" step 2) so
existing OOP consumers continue to read from the litegraph class
while ECS readers read from the World.
5. Migrate consumers piecewise per ADR 0008 §"Migration Strategy"
steps 35.
No new identity-format decisions are made per slice. The colon-collision
risk class is closed for the entire ECS, not just for widgets.
---
## 7. Open questions
1. **Should widgets and node containers ship in one PR or two?** Widget
identity is the bug-relevant case; node identity is symmetric but not
load-bearing. Splitting reduces review surface but leaves the
substrate temporarily inconsistent (widgets opaque, node containers
content-addressed). Recommendation: one PR, since both live in
`widgetValueStore` and the indices interlock.
2. **Should `*BelongsTo.name` / `*BelongsTo.index` be mutable post-creation?**
Today, rename mints a new entity; reorder rotates slot identity.
Under opaque IDs these mutations could either (a) preserve identity
(mutate component, rewrite address index) or (b) preserve current
semantics (delete + remint). (a) is the new capability this refactor
unlocks, but adopting it changes downstream behavior; should be a
deliberate follow-up per kind.
3. **Do we want explicit `Is{Widget,Node,Slot,…}` marker components?**
koota uses tag traits this way so `world.entitiesWith(IsWidget)` is
the canonical "iterate all widgets" query, replacing the implicit
`entitiesWith(WidgetComponentValue)` pattern. Optional ergonomic
improvement, not required by the bug fix; decide per slice.
4. **Where does name/ordinal uniqueness within a parent belong?**
For widgets: `(graphId, nodeId, name)` uniqueness is enforced by
`widgetByAddress`. For slots: `(graphId, nodeId, direction, index)`
uniqueness is enforced by `slotByAddress`. We should decide per kind
whether second-registration is a no-op (`getOrRegister` semantics),
an overwrite, or an error.
5. **Counter-based vs UUID-based minting.** ADR 0008 originally proposed
counter-based (`lastWidgetId++`). UUIDs are simpler (no shared state,
no CRDT mapping) but slightly larger and unpleasant in stack traces.
Recommendation: UUIDs for now (matching this document's §2.1); revisit
if profiling or debuggability demands otherwise.

View File

@@ -5,6 +5,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import { computed, nextTick, watch } from 'vue'
import { useGraphNodeManager } from '@/composables/graph/useGraphNodeManager'
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
import { createPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetView'
import { BaseWidget, LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
import {
@@ -187,7 +188,7 @@ describe('Widget slotMetadata reactivity on link disconnect', () => {
expect(onChange).toHaveBeenCalledTimes(1)
})
it('updates slotMetadata for promoted widgets where SafeWidgetData.name differs from input.widget.name', async () => {
it('updates slotMetadata for promoted widgets where SafeWidgetData.displayName differs from input.widget.name', async () => {
// Set up a subgraph with an interior node that has a "prompt" widget.
// createPromotedWidgetView resolves against this interior node.
const subgraph = createTestSubgraph()
@@ -201,7 +202,7 @@ describe('Widget slotMetadata reactivity on link disconnect', () => {
// Create a PromotedWidgetView with identityName="value" (subgraph input
// slot name) and sourceWidgetName="prompt" (interior widget name).
// PromotedWidgetView.name returns "value" (identity), safeWidgetMapper
// sets SafeWidgetData.name to sourceWidgetName ("prompt").
// sets SafeWidgetData.displayName to sourceWidgetName ("prompt").
const promotedView = createPromotedWidgetView(
subgraphNode,
'10',
@@ -224,7 +225,7 @@ describe('Widget slotMetadata reactivity on link disconnect', () => {
const { vueNodeData } = useGraphNodeManager(graph)
const nodeData = vueNodeData.get(String(hostNode.id))
// SafeWidgetData.name is "prompt" (sourceWidgetName), but the
// SafeWidgetData.displayName is "prompt" (sourceWidgetName), but the
// input slot widget name is "value" — slotName bridges this gap.
const widgetData = nodeData?.widgets?.find((w) => w.name === 'prompt')
expect(widgetData).toBeDefined()
@@ -471,10 +472,15 @@ describe('Nested promoted widget mapping', () => {
expect(mappedWidget).toBeDefined()
expect(mappedWidget?.type).toBe('combo')
expect(mappedWidget?.storeName).toBe('picker')
expect(mappedWidget?.storeNodeId).toBe(
expect(mappedWidget?.name).toBe('picker')
expect(mappedWidget?.sourceNodeLocatorId).toBe(
`${subgraphNodeB.subgraph.id}:${innerNode.id}`
)
expect(mappedWidget?.source).toEqual({
sourceNodeId: String(innerNode.id),
sourceWidgetName: 'picker',
disambiguatingSourceNodeId: undefined
})
})
it('keeps linked and independent same-name promotions as distinct sources', () => {
@@ -516,7 +522,7 @@ describe('Nested promoted widget mapping', () => {
expect(promotedWidgets).toHaveLength(2)
expect(
new Set(promotedWidgets?.map((widget) => widget.storeNodeId))
new Set(promotedWidgets?.map((widget) => widget.sourceNodeLocatorId))
).toEqual(
new Set([
`${subgraph.id}:${linkedNode.id}`,
@@ -580,7 +586,7 @@ describe('Nested promoted widget mapping', () => {
expect(promotedWidgets).toHaveLength(2)
expect(
new Set(promotedWidgets?.map((widget) => widget.storeNodeId))
new Set(promotedWidgets?.map((widget) => widget.sourceNodeLocatorId))
).toEqual(
new Set([
`${outerSubgraphNode.subgraph.id}:${firstTextNode.id}`,
@@ -590,6 +596,98 @@ describe('Nested promoted widget mapping', () => {
})
})
describe('safeWidgetMapper per-instance widget identity', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
})
it('populates nodeId with the SubgraphNode instance id and instanceWidgetName with the view storeName for promoted widgets', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'text', type: 'STRING' }]
})
const interiorNode = new LGraphNode('CLIPTextEncode')
const interiorInput = interiorNode.addInput('text', 'STRING')
interiorNode.addWidget('text', 'text', '', () => undefined, {})
interiorInput.widget = { name: 'text' }
subgraph.add(interiorNode)
subgraph.inputNode.slots[0].connect(interiorInput, interiorNode)
const subgraphNode = createTestSubgraphNode(subgraph, { id: 100 })
subgraphNode._internalConfigureAfterSlots()
const graph = subgraphNode.graph as LGraph
graph.add(subgraphNode)
const promotedView = subgraphNode.widgets[0]
if (!promotedView || !isPromotedWidgetView(promotedView)) {
throw new Error('Expected first widget to be a promoted view')
}
const expectedStoreName = promotedView.storeName
const { vueNodeData } = useGraphNodeManager(graph)
const widgetData = vueNodeData
.get(String(subgraphNode.id))
?.widgets?.find((w) => w.name === 'text')
expect(widgetData).toBeDefined()
expect(widgetData?.nodeId).toBe(String(subgraphNode.id))
expect(widgetData?.instanceWidgetName).toBe(expectedStoreName)
})
it('does not set nodeId or instanceWidgetName for non-promoted widgets', () => {
const graph = new LGraph()
const node = new LGraphNode('test')
node.addWidget('number', 'steps', 20, () => undefined, {})
graph.add(node)
const { vueNodeData } = useGraphNodeManager(graph)
const widgetData = vueNodeData
.get(String(node.id))
?.widgets?.find((w) => w.name === 'steps')
expect(widgetData).toBeDefined()
expect(widgetData?.nodeId).toBeUndefined()
expect(widgetData?.instanceWidgetName).toBeUndefined()
})
it('produces distinct nodeId values for two SubgraphNode instances of one definition', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'text', type: 'STRING' }]
})
const interiorNode = new LGraphNode('CLIPTextEncode')
const interiorInput = interiorNode.addInput('text', 'STRING')
interiorNode.addWidget('text', 'text', '', () => undefined, {})
interiorInput.widget = { name: 'text' }
subgraph.add(interiorNode)
subgraph.inputNode.slots[0].connect(interiorInput, interiorNode)
const instanceA = createTestSubgraphNode(subgraph, { id: 100 })
instanceA._internalConfigureAfterSlots()
const graph = instanceA.graph as LGraph
graph.add(instanceA)
const instanceB = createTestSubgraphNode(subgraph, {
id: 200,
parentGraph: graph
})
instanceB._internalConfigureAfterSlots()
graph.add(instanceB)
const { vueNodeData } = useGraphNodeManager(graph)
const widgetA = vueNodeData
.get(String(instanceA.id))
?.widgets?.find((w) => w.name === 'text')
const widgetB = vueNodeData
.get(String(instanceB.id))
?.widgets?.find((w) => w.name === 'text')
expect(widgetA?.nodeId).toBe('100')
expect(widgetB?.nodeId).toBe('200')
// Both share the same definition so instanceWidgetName matches —
// only nodeId distinguishes them.
expect(widgetA?.instanceWidgetName).toBe(widgetB?.instanceWidgetName)
})
})
describe('Promoted widget sourceExecutionId', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))

View File

@@ -57,6 +57,9 @@ export interface SafeWidgetData {
storeNodeId?: NodeId
name: string
storeName?: string
instanceWidgetName?: string
source?: PromotedWidgetSource
sourceNodeLocatorId?: string
type: string
/** Callback to invoke when widget value changes (wraps LiteGraph callback + triggerDraw) */
callback?: ((value: unknown) => void) | undefined

View File

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

View File

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

View File

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

View File

@@ -24,6 +24,7 @@ export interface PromotedWidgetView extends IBaseWidget {
* origin.
*/
readonly disambiguatingSourceNodeId?: string
readonly storeName: string
}
export function isPromotedWidgetView(

View File

@@ -21,11 +21,9 @@ import { createPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetVi
import type { PromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetView'
import { resolveConcretePromotedWidget } from '@/core/graph/subgraph/resolveConcretePromotedWidget'
import { resolvePromotedWidgetSource } from '@/core/graph/subgraph/resolvePromotedWidgetSource'
import type { SerializedProxyWidgetTuple } from '@/core/schemas/promotionSchema'
import { usePromotionStore } from '@/stores/promotionStore'
import {
stripGraphPrefix,
useWidgetValueStore
} from '@/stores/widgetValueStore'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import {
createTestRootGraph,
@@ -75,7 +73,7 @@ function setupSubgraph(
function setPromotions(
subgraphNode: SubgraphNode,
entries: [string, string][]
entries: SerializedProxyWidgetTuple[]
) {
usePromotionStore().setPromotions(
subgraphNode.rootGraph.id,
@@ -263,7 +261,7 @@ describe(createPromotedWidgetView, () => {
expect(view.linkedWidgets?.[0].name).toBe('control_after_generate')
})
test('value is store-backed via widgetValueStore', () => {
test('value reads from per-instance store cell, leaving interior unchanged', () => {
const [subgraphNode, innerNodes] = setupSubgraph(1)
const innerNode = firstInnerNode(innerNodes)
innerNode.addWidget('text', 'myWidget', 'initial', () => {})
@@ -273,18 +271,19 @@ describe(createPromotedWidgetView, () => {
'myWidget'
)
// Value should read from the store (which was populated by addWidget)
// Value should read from interior default initially
expect(view.value).toBe('initial')
// Setting value through the view updates the store
// Setting value through the view writes to the per-instance cell
view.value = 'updated'
expect(view.value).toBe('updated')
// The interior widget reads from the same store
expect(innerNode.widgets![0].value).toBe('updated')
// Interior widget is NOT mutated by promoted-view writes —
// each instance has its own override; the interior remains the default.
expect(innerNode.widgets![0].value).toBe('initial')
})
test('value falls back to interior widget when store entry is missing', () => {
test('value falls back to interior widget when no store entry exists', () => {
const [subgraphNode, innerNodes] = setupSubgraph(1)
const innerNode = firstInnerNode(innerNodes)
const fallbackWidgetShape = {
@@ -305,12 +304,11 @@ describe(createPromotedWidgetView, () => {
'myWidget'
)
// Read falls back to the interior widget when no store cell exists
expect(view.value).toBe('initial')
view.value = 'updated'
expect(fallbackWidget.value).toBe('updated')
})
test('value setter falls back to host widget when linked states are unavailable', () => {
test('value setter writes to per-instance store cell without mutating the interior', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'string_a', type: '*' }]
})
@@ -327,12 +325,12 @@ describe(createPromotedWidgetView, () => {
const linkedView = promotedWidgets(subgraphNode)[0]
if (!linkedView) throw new Error('Expected a linked promoted widget')
const widgetValueStore = useWidgetValueStore()
vi.spyOn(widgetValueStore, 'getWidget').mockReturnValue(undefined)
linkedView.value = 'updated'
expect(linkedNode.widgets?.[0].value).toBe('updated')
// Per-instance read returns the override
expect(linkedView.value).toBe('updated')
// Interior widget stays at its original default
expect(linkedNode.widgets?.[0].value).toBe('initial')
})
test('label falls back to displayName then widgetName', () => {
@@ -475,17 +473,80 @@ describe(createPromotedWidgetView, () => {
expect(view.hidden).toBe(true)
})
test('label setter persists to widget state', () => {
test('label setter propagates to the bound subgraph slot', () => {
// Real promotion fixture: produce a view with a genuine slot binding
// (input._widget === view AND input._subgraphSlot set), exercising the
// production path rather than asserting the rejected widget-state
// implementation detail. Mirrors the pattern at "defers promotions
// while subgraph node id is -1 and flushes on add" earlier in this file.
const subgraph = createTestSubgraph({
inputs: [{ name: 'myWidget', type: '*' }]
})
const subgraphNode = createTestSubgraphNode(subgraph, { id: 41 })
subgraphNode.graph?.add(subgraphNode)
const innerNode = new LGraphNode('InnerNode')
const innerInput = innerNode.addInput('myWidget', '*')
innerNode.addWidget('text', 'myWidget', 'val', () => {})
innerInput.widget = { name: 'myWidget' }
subgraph.add(innerNode)
subgraph.inputNode.slots[0].connect(innerInput, innerNode)
subgraphNode._internalConfigureAfterSlots()
const view = subgraphNode.widgets[0] as PromotedWidgetView | undefined
if (!view) throw new Error('Expected a promoted view')
view.label = 'Renamed'
expect(view.label).toBe('Renamed')
// Slot-side persistence is the durable home for label state.
const slot = subgraphNode.inputs.find(
(i) => i._widget === view
)?._subgraphSlot
expect(slot?.label).toBe('Renamed')
})
test('label binding uses the exact promoted view instance when same source widget is promoted twice', () => {
const [subgraphNode, innerNodes] = setupSubgraph(1)
const innerNode = firstInnerNode(innerNodes)
innerNode.addWidget('text', 'myWidget', 'val', () => {})
const view = createPromotedWidgetView(
innerNode.addWidget('text', 'shared', 'value', () => {})
subgraphNode.addInput('slot_a', '*')
subgraphNode.addInput('slot_b', '*')
const viewA = createPromotedWidgetView(
subgraphNode,
String(innerNode.id),
'myWidget'
'shared',
'Slot A'
)
view.label = 'Renamed'
expect(view.label).toBe('Renamed')
const viewB = createPromotedWidgetView(
subgraphNode,
String(innerNode.id),
'shared',
'Slot B'
)
if (!subgraphNode.inputs[0] || !subgraphNode.inputs[1]) {
throw new Error('Expected two subgraph inputs')
}
subgraphNode.inputs[0]._widget = viewA
Object.defineProperty(subgraphNode.inputs[0], '_subgraphSlot', {
value: { name: 'slot_a', label: 'A' },
configurable: true,
writable: true
})
subgraphNode.inputs[1]._widget = viewB
Object.defineProperty(subgraphNode.inputs[1], '_subgraphSlot', {
value: { name: 'slot_b', label: 'B' },
configurable: true,
writable: true
})
expect(viewA.label).toBe('A')
expect(viewB.label).toBe('B')
})
test('value getter handles number values via isWidgetValue', () => {
@@ -515,16 +576,7 @@ describe(createPromotedWidgetView, () => {
test('value setter handles object values via isWidgetValue', () => {
const [subgraphNode, innerNodes] = setupSubgraph(1)
const innerNode = firstInnerNode(innerNodes)
const fallbackWidget = {
name: 'objWidget',
type: 'text',
value: 'old',
options: {}
} as unknown as IBaseWidget
innerNode.widgets = [fallbackWidget]
const widgetValueStore = useWidgetValueStore()
vi.spyOn(widgetValueStore, 'getWidget').mockReturnValue(undefined)
innerNode.addWidget('text', 'objWidget', 'old', () => {})
const view = createPromotedWidgetView(
subgraphNode,
@@ -534,7 +586,10 @@ describe(createPromotedWidgetView, () => {
const objValue = { key: 'data' }
view.value = objValue
expect(fallbackWidget.value).toBe(objValue)
// Per-instance cell holds the new object value
expect(view.value).toEqual(objValue)
// Interior widget unchanged by promoted-view write
expect(innerNode.widgets![0].value).toBe('old')
})
test('onPointerDown returns true when interior widget onPointerDown handles it', () => {
@@ -876,17 +931,21 @@ describe('SubgraphNode.widgets getter', () => {
linkedView.value = 'shared-value'
// Both linked nodes share the same SubgraphInput slot, so the value
// propagates to all connected widgets via getLinkedInputWidgets().
expect(linkedNodeA.widgets?.[0]?.value).toBe('shared-value')
expect(linkedNodeB.widgets?.[0]?.value).toBe('shared-value')
// Per-instance promoted-view writes do NOT mutate any interior
// widget — interior cells remain the immutable defaults, and the
// promoted view reads its per-instance store cell.
expect(linkedView.value).toBe('shared-value')
expect(linkedNodeA.widgets?.[0]?.value).toBe('a')
expect(linkedNodeB.widgets?.[0]?.value).toBe('b')
expect(promotedNode.widgets?.[0]?.value).toBe('independent')
promotedView.value = 'independent-updated'
expect(linkedNodeA.widgets?.[0]?.value).toBe('shared-value')
expect(linkedNodeB.widgets?.[0]?.value).toBe('shared-value')
expect(promotedNode.widgets?.[0]?.value).toBe('independent-updated')
expect(promotedView.value).toBe('independent-updated')
expect(linkedView.value).toBe('shared-value')
expect(linkedNodeA.widgets?.[0]?.value).toBe('a')
expect(linkedNodeB.widgets?.[0]?.value).toBe('b')
expect(promotedNode.widgets?.[0]?.value).toBe('independent')
})
test('duplicate-name promoted views map slot linkage by view identity', () => {
@@ -1223,13 +1282,19 @@ describe('SubgraphNode.widgets getter', () => {
firstView.value = 'first-updated'
secondView.value = 'second-updated'
expect(firstNode.widgets?.[0].value).toBe('first-updated')
expect(secondNode.widgets?.[0].value).toBe('second-updated')
// Per-instance views carry distinct overrides
expect(firstView.value).toBe('first-updated')
expect(secondView.value).toBe('second-updated')
// Interior widgets remain at their originally-registered defaults
expect(firstNode.widgets?.[0].value).toBe('first-initial')
expect(secondNode.widgets?.[0].value).toBe('second-initial')
subgraphNode.serialize()
expect(firstNode.widgets?.[0].value).toBe('first-updated')
expect(secondNode.widgets?.[0].value).toBe('second-updated')
expect(firstView.value).toBe('first-updated')
expect(secondView.value).toBe('second-updated')
expect(firstNode.widgets?.[0].value).toBe('first-initial')
expect(secondNode.widgets?.[0].value).toBe('second-initial')
})
test('renaming an input updates linked promoted view display names', () => {
@@ -1548,14 +1613,22 @@ describe('SubgraphNode.widgets getter', () => {
independentView.value = 'independent-value'
linkedView.value = 'shared-linked'
const widgetStore = useWidgetValueStore()
const getValue = (nodeId: string) =>
widgetStore.getWidget(graph.id, stripGraphPrefix(nodeId), 'string_a')
?.value
// Per-instance views carry their own values; both linked and
// independent promoted views read from their per-instance store cells
// and do not contaminate each other.
expect(linkedView.value).toBe('shared-linked')
expect(independentView.value).toBe('independent-value')
expect(getValue('20')).toBe('shared-linked')
expect(getValue('18')).toBe('shared-linked')
expect(getValue('19')).toBe('independent-value')
// Per-instance cells are owned by the SubgraphNode itself at
// `(graphId, hostNode.id, *)`. Each PromotedWidgetView is a first-class
// widget on the SubgraphNode, so writes land in the natural
// (graphId, nodeId, *) namespace and are isolated from interior cells.
const cellValues = useWidgetValueStore()
.getNodeWidgets(graph.id, hostNode.id)
.map((cell) => cell.value)
expect(cellValues).toEqual(
expect.arrayContaining(['shared-linked', 'independent-value'])
)
})
test('fixture refreshes duplicate fallback after linked representative recovers', () => {
@@ -2022,14 +2095,17 @@ describe('three-level nested value propagation', () => {
setActivePinia(createTestingPinia({ stubActions: false }))
})
test('value set at outermost level propagates to concrete widget', () => {
test('value set at outermost level is visible through the promoted view', () => {
const { concreteNode, subgraphNodeA } = createThreeLevelNestedSubgraph()
expect(subgraphNodeA.widgets).toHaveLength(1)
expect(subgraphNodeA.widgets[0].value).toBe(100)
subgraphNodeA.widgets[0].value = 200
expect(concreteNode.widgets![0].value).toBe(200)
// Per-instance read returns the override
expect(subgraphNodeA.widgets[0].value).toBe(200)
// Concrete interior widget remains the unmutated default
expect(concreteNode.widgets![0].value).toBe(100)
})
test('type resolves correctly through all three layers', () => {
@@ -2108,8 +2184,10 @@ describe('three-level nested value propagation', () => {
widgets[1].value = 'updated-second'
// Interior widgets are not mutated by promoted-view writes
expect(firstTextNode.widgets?.[0]?.value).toBe('11111111111')
expect(secondTextNode.widgets?.[0]?.value).toBe('updated-second')
expect(secondTextNode.widgets?.[0]?.value).toBe('22222222222')
// Per-instance disambiguated views read correctly from their own cells
expect(widgets[0].value).toBe('11111111111')
expect(widgets[1].value).toBe('updated-second')
})
@@ -2156,13 +2234,14 @@ describe('multi-link representative determinism for input-based promotion', () =
// Read returns the first link's value
expect(widgets[0].value).toBe('first-val')
// Write propagates to all linked nodes
// Write goes to the per-instance store cell only — interior
// widgets stay at their original construction defaults.
widgets[0].value = 'updated'
expect(firstNode.widgets![0].value).toBe('updated')
expect(secondNode.widgets![0].value).toBe('updated')
expect(thirdNode.widgets![0].value).toBe('updated')
expect(firstNode.widgets![0].value).toBe('first-val')
expect(secondNode.widgets![0].value).toBe('second-val')
expect(thirdNode.widgets![0].value).toBe('third-val')
// Repeated reads are still deterministic
// Repeated reads through the view return the per-instance override
expect(widgets[0].value).toBe('updated')
})
})
@@ -2277,14 +2356,17 @@ describe('promoted combo rendering', () => {
expect(renderedText).toContain('a')
})
test('value updates propagate through two promoted input layers', () => {
test('value updates are visible at the outer layer without mutating the interior', () => {
const { comboWidget, subgraphNodeB } = createTwoLevelNestedSubgraph()
comboWidget.computedDisabled = true
const promotedWidget = subgraphNodeB.widgets[0]
expect(promotedWidget.value).toBe('a')
promotedWidget.value = 'b'
expect(comboWidget.value).toBe('b')
// Per-instance read returns the override
expect(promotedWidget.value).toBe('b')
// The deepest interior combo widget remains at its default
expect(comboWidget.value).toBe('a')
const fillText = vi.fn()
const ctx = createInspectableCanvasContext(fillText)
@@ -2677,4 +2759,134 @@ describe('DOM widget promotion', () => {
'dom-widget-widgetB'
)
})
test('storeName uses a delimiter that cannot collide with widget names containing forward slashes', () => {
const [subgraphNode, innerNodes] = setupSubgraph(1)
const inner = firstInnerNode(innerNodes)
inner.addWidget('text', 'a/b', 'first', () => {})
inner.addWidget('text', 'a', 'second', () => {})
// Same source node ⇒ identical prefix. With a `/` separator the
// suffix `a/b/c` (widget="a/b", disambig="c") collides with
// `a/b/c` (widget="a", disambig="b/c") — they are distinct widgets
// but produce the same storeName key.
const viewA = createPromotedWidgetView(
subgraphNode,
String(inner.id),
'a/b',
undefined,
'c'
)
const viewB = createPromotedWidgetView(
subgraphNode,
String(inner.id),
'a',
undefined,
'b/c'
)
const storeNameA = (viewA as unknown as { storeName: string }).storeName
const storeNameB = (viewB as unknown as { storeName: string }).storeName
expect(storeNameA).not.toBe(storeNameB)
})
test('value setter is a no-op while the SubgraphNode is unattached (id === -1)', () => {
const [subgraphNode, innerNodes] = setupSubgraph(1)
const innerNode = firstInnerNode(innerNodes)
innerNode.addWidget('text', 'preAttach', 'initial', () => {})
const view = createPromotedWidgetView(
subgraphNode,
String(innerNode.id),
'preAttach'
)
// Detach the SubgraphNode so its id reverts to the pre-attach sentinel.
Object.assign(subgraphNode, { id: -1 })
view.value = 'should-not-persist'
// No cell registered at id -1
const cells = useWidgetValueStore().getNodeWidgets(
subgraphNode.rootGraph.id,
-1
)
expect(cells).toHaveLength(0)
// Read still falls back to the interior default
expect(view.value).toBe('initial')
})
test('label setter is a no-op while the SubgraphNode is unattached (id === -1)', () => {
const [subgraphNode, innerNodes] = setupSubgraph(1)
const innerNode = firstInnerNode(innerNodes)
innerNode.addWidget('text', 'preAttachLabel', 'initial', () => {})
const view = createPromotedWidgetView(
subgraphNode,
String(innerNode.id),
'preAttachLabel'
)
Object.assign(subgraphNode, { id: -1 })
view.label = 'My Label'
const cells = useWidgetValueStore().getNodeWidgets(
subgraphNode.rootGraph.id,
-1
)
expect(cells).toHaveLength(0)
})
test('label setter materializes a per-instance cell when no slot is bound', () => {
const [subgraphNode, innerNodes] = setupSubgraph(1)
const innerNode = firstInnerNode(innerNodes)
innerNode.addWidget('text', 'labelOnly', 'initial', () => {})
const view = createPromotedWidgetView(
subgraphNode,
String(innerNode.id),
'labelOnly'
)
view.label = 'My Label'
// Without a bound subgraph slot the per-instance cell is the only
// place the new label can live; the setter must materialize it so
// the rename actually takes effect (the getter falls back to
// state?.label when no slot is found).
const cells = useWidgetValueStore().getNodeWidgets(
subgraphNode.rootGraph.id,
subgraphNode.id
)
expect(cells).toHaveLength(1)
expect(cells[0].label).toBe('My Label')
expect(view.label).toBe('My Label')
})
test('label setter updates an existing per-instance cell when a value override is present', () => {
const [subgraphNode, innerNodes] = setupSubgraph(1)
const innerNode = firstInnerNode(innerNodes)
innerNode.addWidget('text', 'valueAndLabel', 'initial', () => {})
const view = createPromotedWidgetView(
subgraphNode,
String(innerNode.id),
'valueAndLabel'
)
// Triggering a value write materialises the cell (legitimate ownership).
view.value = 'override'
// Now setting a label should update the existing cell, not create a new one.
view.label = 'My Label'
const cells = useWidgetValueStore().getNodeWidgets(
subgraphNode.rootGraph.id,
subgraphNode.id
)
expect(cells).toHaveLength(1)
expect(cells[0].label).toBe('My Label')
})
})

View File

@@ -116,6 +116,10 @@ class PromotedWidgetView implements IPromotedWidgetView {
return this.identityName ?? this.sourceWidgetName
}
get storeName(): string {
return this.name
}
get y(): number {
return this.yValue
}

View File

@@ -103,7 +103,7 @@ describe('Subgraph proxyWidgets', () => {
expect(subgraphNode.widgets[0].name).toBe('widgetB')
expect(subgraphNode.widgets[1].name).toBe('widgetA')
})
test('Will mirror changes to value', () => {
test('Promoted view falls back to interior; promoted writes do not mutate interior', () => {
const [subgraphNode, innerNodes, innerIds] = setupSubgraph(1)
innerNodes[0].addWidget('text', 'stringWidget', 'value', () => {})
usePromotionStore().setPromotions(
@@ -113,10 +113,16 @@ describe('Subgraph proxyWidgets', () => {
)
expect(subgraphNode.widgets.length).toBe(1)
expect(subgraphNode.widgets[0].value).toBe('value')
// Direct interior edits remain visible through the view (no override)
innerNodes[0].widgets![0].value = 'test'
expect(subgraphNode.widgets[0].value).toBe('test')
// Promoted-view writes route to the per-instance cell only —
// the interior widget is NOT mutated.
subgraphNode.widgets[0].value = 'test2'
expect(innerNodes[0].widgets![0].value).toBe('test2')
expect(subgraphNode.widgets[0].value).toBe('test2')
expect(innerNodes[0].widgets![0].value).toBe('test')
})
test('Will not modify position or sizing of existing widgets', () => {
const [subgraphNode, innerNodes, innerIds] = setupSubgraph(1)
@@ -253,7 +259,7 @@ describe('Subgraph proxyWidgets', () => {
expect(subgraphNode.widgets).toHaveLength(0)
})
test('serialize does not produce widgets_values for promoted views', () => {
test('serialize stores widgets_values for promoted views', () => {
const [subgraphNode, innerNodes, innerIds] = setupSubgraph(1)
innerNodes[0].addWidget('text', 'stringWidget', 'value', () => {})
usePromotionStore().setPromotions(
@@ -265,9 +271,7 @@ describe('Subgraph proxyWidgets', () => {
const serialized = subgraphNode.serialize()
// SubgraphNode doesn't set serialize_widgets, so widgets_values is absent.
// Even if it were set, views have serialize: false and would be skipped.
expect(serialized.widgets_values).toBeUndefined()
expect(serialized.widgets_values).toEqual(['value'])
})
test('serialize preserves proxyWidgets in properties', () => {
@@ -291,6 +295,28 @@ describe('Subgraph proxyWidgets', () => {
])
})
test('serialize() does not mutate the live properties.proxyWidgets', () => {
const [subgraphNode, innerNodes, innerIds] = setupSubgraph(1)
innerNodes[0].addWidget('text', 'widgetA', 'a', () => {})
innerNodes[0].addWidget('text', 'widgetB', 'b', () => {})
usePromotionStore().setPromotions(
subgraphNode.rootGraph.id,
subgraphNode.id,
[
{ sourceNodeId: innerIds[0], sourceWidgetName: 'widgetA' },
{ sourceNodeId: innerIds[0], sourceWidgetName: 'widgetB' }
]
)
const before = subgraphNode.properties.proxyWidgets
const beforeSnapshot = JSON.parse(JSON.stringify(before))
subgraphNode.serialize()
expect(subgraphNode.properties.proxyWidgets).toBe(before)
expect(subgraphNode.properties.proxyWidgets).toStrictEqual(beforeSnapshot)
})
test('multi-link representative is deterministic across repeated reads', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'shared_input', type: '*' }]
@@ -371,9 +397,11 @@ describe('Subgraph proxyWidgets', () => {
expect(subgraphNodeA.widgets[0].type).toBe('number')
expect(subgraphNodeA.widgets[0].value).toBe(42)
// Setting value at outermost level propagates to concrete widget
// Value set at outermost level lives in the per-instance cell.
// Read returns the override; concrete interior widget stays unchanged.
subgraphNodeA.widgets[0].value = 99
expect(concreteNode.widgets![0].value).toBe(99)
expect(subgraphNodeA.widgets[0].value).toBe(99)
expect(concreteNode.widgets![0].value).toBe(42)
})
test('removeWidget cleans up promotion and input, then re-promote works', () => {

View File

@@ -3,10 +3,20 @@ import { fromZodError } from 'zod-validation-error'
import type { NodeProperty } from '@/lib/litegraph/src/LGraphNode'
export const serializedProxyWidgetTupleSchema = z.tuple([
z.string(),
z.string()
])
export type SerializedProxyWidgetTuple = z.infer<
typeof serializedProxyWidgetTupleSchema
>
const proxyWidgetTupleSchema = z.union([
z.tuple([z.string(), z.string(), z.string()]),
z.tuple([z.string(), z.string()])
serializedProxyWidgetTupleSchema
])
export type ProxyWidgetTuple = z.infer<typeof proxyWidgetTupleSchema>
const proxyWidgetsPropertySchema = z.array(proxyWidgetTupleSchema)
type ProxyWidgetsProperty = z.infer<typeof proxyWidgetsPropertySchema>

View File

@@ -1027,4 +1027,29 @@ describe('Zero UUID handling in configure', () => {
subgraph.configure(subgraphData)
expect(subgraph.id).toBe(zeroUuid)
})
describe('trigger() events bus', () => {
it('dispatches node:slot-label:changed on graph.events when trigger() is called', () => {
const graph = new LGraph()
const received: Array<{ type: string; nodeId: NodeId }> = []
graph.events.addEventListener('node:slot-label:changed', (e) => {
received.push(e.detail)
})
graph.trigger('node:slot-label:changed', { nodeId: 42 })
expect(received.length).toBe(1)
expect(received[0]).toEqual({
type: 'node:slot-label:changed',
nodeId: 42
})
})
it('still invokes the legacy onTrigger field for backward compatibility', () => {
const graph = new LGraph()
const received: Array<{ type: string }> = []
graph.onTrigger = (event) => received.push(event)
graph.trigger('node:slot-label:changed', { nodeId: 7 })
expect(received.length).toBe(1)
expect(received[0].type).toBe('node:slot-label:changed')
})
})
})

View File

@@ -1,5 +1,6 @@
import { describe, expect, it } from 'vitest'
import type { SerializedProxyWidgetTuple } from '@/core/schemas/promotionSchema'
import { LGraph, LGraphNode, createUuidv4 } from '@/lib/litegraph/src/litegraph'
import { remapClipboardSubgraphNodeIds } from '@/lib/litegraph/src/LGraphCanvas'
import type {
@@ -11,7 +12,7 @@ import type {
function createSerialisedNode(
id: number,
type: string,
proxyWidgets?: Array<[string, string]>
proxyWidgets?: SerializedProxyWidgetTuple[]
): ISerialisedNode {
return {
id,

View File

@@ -2,6 +2,7 @@ import type { LGraph } from '@/lib/litegraph/src/LGraph'
import type { LLink, ResolvedConnection } from '@/lib/litegraph/src/LLink'
import type { ReadOnlyRect } from '@/lib/litegraph/src/interfaces'
import type { Subgraph } from '@/lib/litegraph/src/subgraph/Subgraph'
import type { LGraphTriggerEvent } from '@/lib/litegraph/src/types/graphTriggers'
import type {
ExportedSubgraph,
ISerialisedGraph,
@@ -9,6 +10,23 @@ import type {
} from '@/lib/litegraph/src/types/serialisation'
export interface LGraphEventMap {
'node:slot-label:changed': Extract<
LGraphTriggerEvent,
{ type: 'node:slot-label:changed' }
>
'node:slot-links:changed': Extract<
LGraphTriggerEvent,
{ type: 'node:slot-links:changed' }
>
'node:slot-errors:changed': Extract<
LGraphTriggerEvent,
{ type: 'node:slot-errors:changed' }
>
'node:property:changed': Extract<
LGraphTriggerEvent,
{ type: 'node:property:changed' }
>
configuring: {
/** The data that was used to configure the graph. */
data: ISerialisedGraph | SerialisableGraph

View File

@@ -0,0 +1,584 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type {
ExportedSubgraphInstance,
ISlotType,
Subgraph
} from '@/lib/litegraph/src/litegraph'
import {
LGraphNode,
LiteGraph,
SubgraphNode
} from '@/lib/litegraph/src/litegraph'
import {
createTestSubgraph,
createTestSubgraphNode,
resetSubgraphFixtureState
} from './__fixtures__/subgraphHelpers'
/**
* Registers a minimal SubgraphNode subclass for a subgraph definition so that
* `LiteGraph.createNode(subgraphId)` (which is invoked by `LGraphNode.clone`)
* succeeds in tests.
*/
function registerSubgraphNodeType(subgraph: Subgraph): void {
const instanceData: ExportedSubgraphInstance = {
id: -1,
type: subgraph.id,
pos: [0, 0],
size: [100, 100],
inputs: [],
outputs: [],
flags: {},
order: 0,
mode: 0
}
const node = class extends SubgraphNode {
constructor() {
super(subgraph.rootGraph, subgraph, instanceData)
}
}
Object.defineProperty(node, 'title', { value: subgraph.name })
LiteGraph.registerNodeType(subgraph.id, node)
}
function createNodeWithWidget(
title: string,
widgetValue: number = 42,
slotType: ISlotType = 'number'
) {
const node = new LGraphNode(title)
const input = node.addInput('value', slotType)
node.addOutput('out', slotType)
const widget = node.addWidget('number', 'widget', widgetValue, () => {}, {
min: 0,
max: 100,
step: 1
})
input.widget = { name: widget.name }
return { node, widget, input }
}
const registeredTypes: string[] = []
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
resetSubgraphFixtureState()
})
afterEach(() => {
for (const type of registeredTypes) {
LiteGraph.unregisterNodeType(type)
}
registeredTypes.length = 0
})
describe('SubgraphNode multi-instance widget isolation', () => {
it('preserves per-instance widget values after configure', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'value', type: 'number' }]
})
const { node } = createNodeWithWidget('TestNode', 0)
subgraph.add(node)
subgraph.inputNode.slots[0].connect(node.inputs[0], node)
const instance1 = createTestSubgraphNode(subgraph, { id: 201 })
const instance2 = createTestSubgraphNode(subgraph, { id: 202 })
// Simulate what LGraph.configure does: call configure with different widgets_values
instance1.configure({
id: 201,
type: subgraph.id,
pos: [100, 100],
size: [200, 100],
inputs: [],
outputs: [],
mode: 0,
order: 0,
flags: {},
properties: { proxyWidgets: [['-1', 'widget']] },
widgets_values: [10]
})
instance2.configure({
id: 202,
type: subgraph.id,
pos: [400, 100],
size: [200, 100],
inputs: [],
outputs: [],
mode: 0,
order: 1,
flags: {},
properties: { proxyWidgets: [['-1', 'widget']] },
widgets_values: [20]
})
const widgets1 = instance1.widgets!
const widgets2 = instance2.widgets!
expect(widgets1.length).toBeGreaterThan(0)
expect(widgets2.length).toBeGreaterThan(0)
expect(widgets1[0].value).toBe(10)
expect(widgets2[0].value).toBe(20)
expect(widgets1[0].serializeValue!(instance1, 0)).toBe(10)
expect(widgets2[0].serializeValue!(instance2, 0)).toBe(20)
expect(instance1.serialize().widgets_values).toEqual([10])
expect(instance2.serialize().widgets_values).toEqual([20])
})
it('round-trips per-instance widget values through serialize and configure', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'value', type: 'number' }]
})
const { node } = createNodeWithWidget('TestNode', 0)
subgraph.add(node)
subgraph.inputNode.slots[0].connect(node.inputs[0], node)
const originalInstance = createTestSubgraphNode(subgraph, { id: 301 })
originalInstance.configure({
id: 301,
type: subgraph.id,
pos: [100, 100],
size: [200, 100],
inputs: [],
outputs: [],
mode: 0,
order: 0,
flags: {},
properties: { proxyWidgets: [['-1', 'widget']] },
widgets_values: [33]
})
const serialized = originalInstance.serialize()
const restoredInstance = createTestSubgraphNode(subgraph, { id: 302 })
restoredInstance.configure({
...serialized,
id: 302,
type: subgraph.id
})
const restoredWidget = restoredInstance.widgets?.[0]
expect(restoredWidget?.value).toBe(33)
expect(restoredWidget?.serializeValue?.(restoredInstance, 0)).toBe(33)
})
it('keeps fresh sibling instances isolated before save or reload', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'value', type: 'number' }]
})
const { node } = createNodeWithWidget('TestNode', 7)
subgraph.add(node)
subgraph.inputNode.slots[0].connect(node.inputs[0], node)
const instance1 = createTestSubgraphNode(subgraph, { id: 401 })
const instance2 = createTestSubgraphNode(subgraph, { id: 402 })
instance1.graph!.add(instance1)
instance2.graph!.add(instance2)
const widget1 = instance1.widgets?.[0]
const widget2 = instance2.widgets?.[0]
expect(widget1?.value).toBe(7)
expect(widget2?.value).toBe(7)
widget1!.value = 10
expect(widget1?.value).toBe(10)
expect(widget2?.value).toBe(7)
expect(widget1?.serializeValue?.(instance1, 0)).toBe(10)
expect(widget2?.serializeValue?.(instance2, 0)).toBe(7)
})
it('keeps per-instance override sticky when the inner source widget changes directly', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'value', type: 'number' }]
})
const { node, widget } = createNodeWithWidget('TestNode', 0)
subgraph.add(node)
subgraph.inputNode.slots[0].connect(node.inputs[0], node)
const originalInstance = createTestSubgraphNode(subgraph, { id: 601 })
originalInstance.configure({
id: 601,
type: subgraph.id,
pos: [100, 100],
size: [200, 100],
inputs: [],
outputs: [],
mode: 0,
order: 0,
flags: {},
properties: { proxyWidgets: [['-1', 'widget']] },
widgets_values: [33]
})
const serialized = originalInstance.serialize()
const restoredInstance = createTestSubgraphNode(subgraph, { id: 602 })
restoredInstance.configure({
...serialized,
id: 602,
type: subgraph.id
})
expect(restoredInstance.widgets?.[0].value).toBe(33)
widget.value = 45
// Override remains sticky — interior change does not leak across the
// per-instance boundary.
expect(restoredInstance.widgets?.[0].value).toBe(33)
expect(
restoredInstance.widgets?.[0].serializeValue?.(restoredInstance, 0)
).toBe(33)
})
it('preserves per-instance values when reconfigured without widgets_values', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'value', type: 'number' }]
})
const { node, widget } = createNodeWithWidget('TestNode', 5)
subgraph.add(node)
subgraph.inputNode.slots[0].connect(node.inputs[0], node)
const instance = createTestSubgraphNode(subgraph, { id: 701 })
instance.graph!.add(instance)
const promotedWidget = instance.widgets?.[0]
promotedWidget!.value = 11
widget.value = 17
const serialized = instance.serialize()
delete serialized.widgets_values
instance.configure({
...serialized,
id: instance.id,
type: subgraph.id
})
// Symmetric with super.configure(): absent widgets_values is a no-op
// for widget values; the per-instance override is preserved.
expect(instance.widgets?.[0].value).toBe(11)
expect(instance.widgets?.[0].serializeValue?.(instance, 0)).toBe(11)
})
it('skips non-serializable source widgets during serialize', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'value', type: 'number' }]
})
const { node, widget } = createNodeWithWidget('TestNode', 10)
subgraph.add(node)
subgraph.inputNode.slots[0].connect(node.inputs[0], node)
// Mark the source widget as non-persistent (e.g. preview widget)
widget.serialize = false
const instance = createTestSubgraphNode(subgraph, { id: 501 })
instance.configure({
id: 501,
type: subgraph.id,
pos: [100, 100],
size: [200, 100],
inputs: [],
outputs: [],
mode: 0,
order: 0,
flags: {},
properties: { proxyWidgets: [['-1', 'widget']] },
widgets_values: []
})
const serialized = instance.serialize()
expect(serialized.widgets_values).toBeUndefined()
})
it('ignores sparse widgets_values holes when restoring promoted widget instances', () => {
const subgraph = createTestSubgraph({
inputs: [
{ name: 'first', type: 'number' },
{ name: 'second', type: 'number' }
]
})
const firstNode = new LGraphNode('First')
const firstInput = firstNode.addInput('first', 'number')
const firstWidget = firstNode.addWidget('number', 'first', 5, () => {})
firstInput.widget = { name: 'first' }
firstWidget.serialize = false
const secondNode = new LGraphNode('Second')
const secondInput = secondNode.addInput('second', 'number')
secondNode.addWidget('number', 'second', 9, () => {})
secondInput.widget = { name: 'second' }
subgraph.add(firstNode)
subgraph.add(secondNode)
subgraph.inputNode.slots[0].connect(firstNode.inputs[0], firstNode)
subgraph.inputNode.slots[1].connect(secondNode.inputs[0], secondNode)
const instance = createTestSubgraphNode(subgraph, { id: 701 })
const widgetsValues = new Array<number | undefined>(2)
widgetsValues[1] = 11
instance.configure({
id: 701,
type: subgraph.id,
pos: [0, 0],
size: [200, 100],
inputs: [],
outputs: [],
mode: 0,
order: 0,
flags: {},
properties: {
proxyWidgets: [
['-1', 'first'],
['-1', 'second']
]
},
widgets_values: widgetsValues
})
expect(instance.widgets[0].value).toBe(5)
expect(instance.widgets[1].value).toBe(11)
})
it('ignores configure replay for promoted widgets whose concrete source is non-serializable', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'value', type: 'number' }]
})
const { node, widget } = createNodeWithWidget('TestNode', 10)
widget.serialize = false
subgraph.add(node)
subgraph.inputNode.slots[0].connect(node.inputs[0], node)
const instance = createTestSubgraphNode(subgraph, { id: 502 })
instance.configure({
id: 502,
type: subgraph.id,
pos: [100, 100],
size: [200, 100],
inputs: [],
outputs: [],
mode: 0,
order: 0,
flags: {},
properties: { proxyWidgets: [['-1', 'widget']] },
widgets_values: [33]
})
expect(instance.widgets[0].value).toBe(10)
widget.value = 14
expect(instance.widgets[0].value).toBe(14)
expect(instance.widgets[0].serializeValue?.(instance, 0)).toBe(14)
})
it('serializes nested promoted widgets from the concrete source widget serialize state', () => {
const leafSubgraph = createTestSubgraph({
inputs: [{ name: 'value', type: 'number' }]
})
const concreteNode = new LGraphNode('ConcreteNode')
const concreteInput = concreteNode.addInput('value', 'number')
const concreteWidget = concreteNode.addWidget(
'number',
'value',
5,
() => {}
)
concreteInput.widget = { name: 'value' }
leafSubgraph.add(concreteNode)
leafSubgraph.inputNode.slots[0].connect(concreteInput, concreteNode)
const middleNode = createTestSubgraphNode(leafSubgraph, { id: 901 })
const middleSubgraph = createTestSubgraph({
inputs: [{ name: 'value', type: 'number' }]
})
middleSubgraph.add(middleNode)
middleNode._internalConfigureAfterSlots()
middleSubgraph.inputNode.slots[0].connect(middleNode.inputs[0], middleNode)
const innerHostNode = createTestSubgraphNode(middleSubgraph, { id: 902 })
const outerSubgraph = createTestSubgraph({
inputs: [{ name: 'value', type: 'number' }]
})
outerSubgraph.add(innerHostNode)
innerHostNode._internalConfigureAfterSlots()
outerSubgraph.inputNode.slots[0].connect(
innerHostNode.inputs[0],
innerHostNode
)
const outerHostNode = createTestSubgraphNode(outerSubgraph, { id: 903 })
outerHostNode.graph!.add(outerHostNode)
outerHostNode.widgets[0].value = 123
expect(outerHostNode.serialize().widgets_values).toEqual([123])
concreteWidget.serialize = false
expect(outerHostNode.serialize().widgets_values).toBeUndefined()
})
it('does not clobber super.serialize() values when a concrete source widget is non-serializable', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'value', type: 'number' }]
})
const { node, widget } = createNodeWithWidget('TestNode', 0)
// Mark the concrete source widget non-serializable so the merge loop skips
// index 0, letting super's value survive.
widget.serialize = false
subgraph.add(node)
subgraph.inputNode.slots[0].connect(node.inputs[0], node)
const instance = createTestSubgraphNode(subgraph, { id: 801 })
instance.graph!.add(instance)
// Stub super.serialize to simulate a native widget contributing a
// positional value at index 0 (a slot the promoted view would own
// if it were serializable, but this view skips because serialize:false).
const SuperProto = Object.getPrototypeOf(Object.getPrototypeOf(instance))
const originalSerialize = SuperProto.serialize as () => {
widgets_values?: unknown[]
}
vi.spyOn(SuperProto, 'serialize').mockImplementationOnce(
function (this: typeof instance) {
const out = originalSerialize.call(this)
out.widgets_values = ['native-value']
return out
}
)
const out = instance.serialize()
expect(out.widgets_values?.[0]).toBe('native-value')
})
it('round-trips Date widget values via structuredClone (preserves type)', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'value', type: 'number' }]
})
const { node } = createNodeWithWidget('TestNode', 0)
subgraph.add(node)
subgraph.inputNode.slots[0].connect(node.inputs[0], node)
const instance = createTestSubgraphNode(subgraph, { id: 901 })
instance.graph!.add(instance)
const date = new Date('2025-01-01T00:00:00.000Z')
instance.widgets[0].value = { when: date }
const out = instance.serialize()
const cloned = out.widgets_values?.[0] as { when: Date } | undefined
expect(cloned?.when).toBeInstanceOf(Date)
expect(cloned?.when.getTime()).toBe(date.getTime())
})
it('preserves per-instance promoted widget values across LGraphNode.clone (copy/paste)', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'value', type: 'number' }]
})
const { node } = createNodeWithWidget('TestNode', 0)
subgraph.add(node)
subgraph.inputNode.slots[0].connect(node.inputs[0], node)
registerSubgraphNodeType(subgraph)
registeredTypes.push(subgraph.id)
const original = createTestSubgraphNode(subgraph, { id: 501 })
original.configure({
id: 501,
type: subgraph.id,
pos: [100, 100],
size: [200, 100],
inputs: [],
outputs: [],
mode: 0,
order: 0,
flags: {},
properties: { proxyWidgets: [['-1', 'widget']] },
widgets_values: ['per-instance-value']
})
expect(original.widgets[0].value).toBe('per-instance-value')
// LGraphNode.clone() invokes LiteGraph.createNode (id = -1), strips the id
// from the serialized data, then calls configure(data). The clone then
// needs to be added to a graph to receive a real id.
const clone = original.clone() as SubgraphNode | null
expect(clone).toBeTruthy()
if (!clone) throw new Error('clone failed')
original.graph!.add(clone)
expect(clone.id).not.toBe(-1)
expect(clone.widgets[0].value).toBe('per-instance-value')
expect(clone.widgets[0].serializeValue?.(clone, 0)).toBe(
'per-instance-value'
)
})
it('clears deferred widget replay when reconfigured without widgets_values before attach', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'value', type: 'number' }]
})
const { node } = createNodeWithWidget('TestNode', 5)
subgraph.add(node)
subgraph.inputNode.slots[0].connect(node.inputs[0], node)
const detached = createTestSubgraphNode(subgraph, { id: -1 })
detached.configure({
id: -1,
type: subgraph.id,
pos: [100, 100],
size: [200, 100],
inputs: [],
outputs: [],
mode: 0,
order: 0,
flags: {},
properties: { proxyWidgets: [['-1', 'widget']] },
widgets_values: [33]
})
detached.configure({
id: -1,
type: subgraph.id,
pos: [100, 100],
size: [200, 100],
inputs: [],
outputs: [],
mode: 0,
order: 0,
flags: {},
properties: { proxyWidgets: [['-1', 'widget']] }
})
detached.graph!.add(detached)
expect(detached.id).not.toBe(-1)
expect(detached.widgets[0].value).toBe(5)
expect(detached.widgets[0].serializeValue?.(detached, 0)).toBe(5)
})
})

View File

@@ -8,6 +8,7 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { parseProxyWidgets } from '@/core/schemas/promotionSchema'
import { duplicateSubgraphNodeIds } from '@/lib/litegraph/src/__fixtures__/duplicateSubgraphNodeIds'
import {
LGraph,
@@ -522,15 +523,19 @@ describe('SubgraphSerialization - Data Integrity', () => {
const subgraphB = graph.subgraphs.get(DUPLICATE_ID_SUBGRAPH_B)!
const subgraphBIds = new Set(subgraphB.nodes.map((node) => String(node.id)))
const rootProxyWidgetsA = graph.getNodeById(102)?.properties?.proxyWidgets
expect(Array.isArray(rootProxyWidgetsA)).toBe(true)
for (const entry of rootProxyWidgetsA as string[][]) {
const rootProxyWidgetsA = parseProxyWidgets(
graph.getNodeById(102)?.properties?.proxyWidgets
)
expect(rootProxyWidgetsA.length).toBeGreaterThan(0)
for (const entry of rootProxyWidgetsA) {
expect(subgraphAIds.has(String(entry[0]))).toBe(true)
}
const rootProxyWidgetsB = graph.getNodeById(103)?.properties?.proxyWidgets
expect(Array.isArray(rootProxyWidgetsB)).toBe(true)
for (const entry of rootProxyWidgetsB as string[][]) {
const rootProxyWidgetsB = parseProxyWidgets(
graph.getNodeById(103)?.properties?.proxyWidgets
)
expect(rootProxyWidgetsB.length).toBeGreaterThan(0)
for (const entry of rootProxyWidgetsB) {
expect(subgraphBIds.has(String(entry[0]))).toBe(true)
}

View File

@@ -2,6 +2,7 @@ import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it } from 'vitest'
import { parseProxyWidgets } from '@/core/schemas/promotionSchema'
import type {
ISlotType,
Subgraph,
@@ -409,7 +410,10 @@ describe('SubgraphWidgetPromotion', () => {
String(samplerNode.id)
)
expect(hostNode.properties.proxyWidgets).toStrictEqual([
[String(nestedNode.id), 'noise_seed', String(samplerNode.id)]
[
String(nestedNode.id),
`${nestedNode.id}: ${samplerNode.id}: noise_seed`
]
])
})
@@ -430,8 +434,9 @@ describe('SubgraphWidgetPromotion', () => {
// serialize() syncs the promotion store into properties.proxyWidgets
const serialized = hostNode.serialize()
const originalProxyWidgets = serialized.properties!
.proxyWidgets as string[][]
const originalProxyWidgets = parseProxyWidgets(
serialized.properties?.proxyWidgets
)
expect(originalProxyWidgets.length).toBeGreaterThan(0)
expect(
@@ -441,7 +446,9 @@ describe('SubgraphWidgetPromotion', () => {
// Simulate clone: create a second SubgraphNode configured from serialized data
const cloneNode = createTestSubgraphNode(subgraph)
cloneNode.configure(serialized)
const cloneProxyWidgets = cloneNode.properties.proxyWidgets as string[][]
const cloneProxyWidgets = parseProxyWidgets(
cloneNode.properties.proxyWidgets
)
expect(cloneProxyWidgets.length).toBeGreaterThan(0)
expect(

View File

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

View File

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

View File

@@ -129,25 +129,22 @@ describe('NodeWidgets', () => {
const duplicateA = createMockWidget({
name: 'string_a',
type: 'text',
nodeId: '5e0670b8-ea2c-4fb6-8b73-a1100a2d4f8f:19',
storeNodeId: '5e0670b8-ea2c-4fb6-8b73-a1100a2d4f8f:19',
storeName: 'string_a',
nodeId: 'host-node',
instanceWidgetName: 'source:19:string_a',
slotName: 'string_a'
})
const duplicateB = createMockWidget({
name: 'string_a',
type: 'text',
nodeId: '5e0670b8-ea2c-4fb6-8b73-a1100a2d4f8f:19',
storeNodeId: '5e0670b8-ea2c-4fb6-8b73-a1100a2d4f8f:19',
storeName: 'string_a',
nodeId: 'host-node',
instanceWidgetName: 'source:19:string_a',
slotName: 'string_a'
})
const distinct = createMockWidget({
name: 'string_a',
type: 'text',
nodeId: '5e0670b8-ea2c-4fb6-8b73-a1100a2d4f8f:20',
storeNodeId: '5e0670b8-ea2c-4fb6-8b73-a1100a2d4f8f:20',
storeName: 'string_a',
nodeId: 'host-node',
instanceWidgetName: 'source:20:string_a',
slotName: 'string_a'
})
const nodeData = createMockNodeData('SubgraphNode', [
@@ -165,18 +162,16 @@ describe('NodeWidgets', () => {
const hiddenDuplicate = createMockWidget({
name: 'string_a',
type: 'text',
nodeId: '5e0670b8-ea2c-4fb6-8b73-a1100a2d4f8f:19',
storeNodeId: '5e0670b8-ea2c-4fb6-8b73-a1100a2d4f8f:19',
storeName: 'string_a',
nodeId: 'host-node',
instanceWidgetName: 'source:19:string_a',
slotName: 'string_a',
options: { hidden: true }
})
const visibleDuplicate = createMockWidget({
name: 'string_a',
type: 'text',
nodeId: '5e0670b8-ea2c-4fb6-8b73-a1100a2d4f8f:19',
storeNodeId: '5e0670b8-ea2c-4fb6-8b73-a1100a2d4f8f:19',
storeName: 'string_a',
nodeId: 'host-node',
instanceWidgetName: 'source:19:string_a',
slotName: 'string_a',
options: { hidden: false }
})
@@ -194,17 +189,15 @@ describe('NodeWidgets', () => {
const textWidget = createMockWidget({
name: 'string_a',
type: 'text',
nodeId: '5e0670b8-ea2c-4fb6-8b73-a1100a2d4f8f:19',
storeNodeId: '5e0670b8-ea2c-4fb6-8b73-a1100a2d4f8f:19',
storeName: 'string_a',
nodeId: 'host-node',
instanceWidgetName: 'source:19:string_a',
slotName: 'string_a'
})
const comboWidget = createMockWidget({
name: 'string_a',
type: 'combo',
nodeId: '5e0670b8-ea2c-4fb6-8b73-a1100a2d4f8f:19',
storeNodeId: '5e0670b8-ea2c-4fb6-8b73-a1100a2d4f8f:19',
storeName: 'string_a',
nodeId: 'host-node',
instanceWidgetName: 'source:19:string_a',
slotName: 'string_a'
})
const nodeData = createMockNodeData('SubgraphNode', [
@@ -217,24 +210,20 @@ describe('NodeWidgets', () => {
expect(container.querySelectorAll('.lg-node-widget')).toHaveLength(2)
})
it('keeps unresolved same-name promoted entries distinct by source execution identity', () => {
it('keeps same-name promoted entries distinct by instance widget identity', () => {
const firstTransientEntry = createMockWidget({
nodeId: undefined,
storeNodeId: undefined,
name: 'string_a',
storeName: 'string_a',
instanceWidgetName: 'source:18:string_a',
slotName: 'string_a',
type: 'text',
sourceExecutionId: '65:18'
type: 'text'
})
const secondTransientEntry = createMockWidget({
nodeId: undefined,
storeNodeId: undefined,
name: 'string_a',
storeName: 'string_a',
instanceWidgetName: 'source:19:string_a',
slotName: 'string_a',
type: 'text',
sourceExecutionId: '65:19'
type: 'text'
})
const nodeData = createMockNodeData('SubgraphNode', [
firstTransientEntry,
@@ -250,17 +239,15 @@ describe('NodeWidgets', () => {
const firstPromoted = createMockWidget({
name: 'text',
type: 'text',
nodeId: 'outer-subgraph:1',
storeNodeId: 'outer-subgraph:1',
storeName: 'text',
nodeId: 'host-node',
instanceWidgetName: 'promoted:text:1',
slotName: 'text'
})
const secondPromoted = createMockWidget({
name: 'text',
type: 'text',
nodeId: 'outer-subgraph:2',
storeNodeId: 'outer-subgraph:2',
storeName: 'text',
nodeId: 'host-node',
instanceWidgetName: 'promoted:text:2',
slotName: 'text'
})
@@ -302,8 +289,16 @@ describe('NodeWidgets', () => {
it('keeps AppInput ids mapped to node identity for selection', () => {
const nodeData = createMockNodeData('TestNode', [
createMockWidget({ nodeId: 'test_node', name: 'seed_a', type: 'text' }),
createMockWidget({ nodeId: 'test_node', name: 'seed_b', type: 'text' })
createMockWidget({
nodeId: 'test_node',
name: 'seed_a',
type: 'text'
}),
createMockWidget({
nodeId: 'test_node',
name: 'seed_b',
type: 'text'
})
])
const { container } = render(NodeWidgets, {
@@ -333,6 +328,6 @@ describe('NodeWidgets', () => {
el.getAttribute('data-id')
)
expect(ids).toStrictEqual(['test_node', 'test_node'])
expect(ids).toStrictEqual(['1', '1'])
})
})

View File

@@ -14,6 +14,7 @@ import { usePromotionStore } from '@/stores/promotionStore'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import { makeCompositeKey } from '@/utils/compositeKey'
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
useCanvasStore: () => ({
@@ -42,10 +43,10 @@ const createMockWidget = (
})
describe('getWidgetIdentity', () => {
it('returns stable dedupeIdentity for widgets with storeNodeId', () => {
it('returns stable dedupeIdentity for widgets with nodeId and instanceWidgetName', () => {
const widget = createMockWidget({
storeNodeId: 'subgraph:19',
storeName: 'text',
nodeId: 'subgraph:19',
instanceWidgetName: 'text',
slotName: 'text',
type: 'text'
})
@@ -54,24 +55,26 @@ describe('getWidgetIdentity', () => {
expect(renderKey).toBe(dedupeIdentity)
})
it('returns transient renderKey for widgets without stable identity', () => {
it('returns transient renderKey when neither widget nor caller provide a stable identity', () => {
const widget = createMockWidget({
nodeId: undefined,
storeNodeId: undefined,
sourceExecutionId: undefined
})
const { dedupeIdentity, renderKey } = getWidgetIdentity(widget, '5', 3)
const { dedupeIdentity, renderKey } = getWidgetIdentity(
widget,
undefined,
3
)
expect(dedupeIdentity).toBeUndefined()
expect(renderKey).toBe('transient:5:test_widget:test_widget:combo:3')
expect(renderKey).toBe('transient::test_widget:test_widget:combo:3')
})
it('uses sourceExecutionId for identity when no nodeId', () => {
it('uses sourceExecutionId for identity when no node id is available', () => {
const widget = createMockWidget({
nodeId: undefined,
storeNodeId: undefined,
sourceExecutionId: '65:18'
})
const { dedupeIdentity } = getWidgetIdentity(widget, '1', 0)
const { dedupeIdentity } = getWidgetIdentity(widget, undefined, 0)
expect(dedupeIdentity).toBe('exec:65:18:test_widget:test_widget:combo')
})
})
@@ -211,9 +214,9 @@ describe('computeProcessedWidgets borderStyle', () => {
const promotedWidget = createMockWidget({
name: 'text',
type: 'combo',
nodeId: 'inner-subgraph:1',
storeNodeId: 'inner-subgraph:1',
storeName: 'text',
nodeId: '3',
instanceWidgetName: 'promoted:text:1',
source: { sourceNodeId: '1', sourceWidgetName: 'text' },
slotName: 'text'
})
@@ -247,13 +250,58 @@ describe('computeProcessedWidgets borderStyle', () => {
).toBe(true)
})
it('uses disambiguatingSourceNodeId when checking promoted border styling', () => {
const promotedWidget = createMockWidget({
name: 'text',
type: 'combo',
nodeId: '3',
instanceWidgetName: 'promoted:text:1',
source: {
sourceNodeId: '3',
sourceWidgetName: 'text',
disambiguatingSourceNodeId: '1'
},
slotName: 'text'
})
usePromotionStore().promote('graph-test', '4', {
sourceNodeId: '3',
sourceWidgetName: 'text',
disambiguatingSourceNodeId: '1'
})
const result = computeProcessedWidgets({
nodeData: {
id: '3',
type: 'SubgraphNode',
widgets: [promotedWidget],
title: 'Test',
mode: 0,
selected: false,
executing: false,
inputs: [],
outputs: []
},
graphId: 'graph-test',
showAdvanced: false,
isGraphReady: false,
rootGraph: null,
ui: noopUi
})
expect(
result.some((w) => w.simplified.borderStyle?.includes('promoted'))
).toBe(true)
expect(result[0]?.id).toBe('3')
})
it('does not apply promoted border styling to outermost widgets', () => {
const promotedWidget = createMockWidget({
name: 'text',
type: 'combo',
nodeId: 'inner-subgraph:1',
storeNodeId: 'inner-subgraph:1',
storeName: 'text',
nodeId: '4',
instanceWidgetName: 'promoted:text:1',
source: { sourceNodeId: '1', sourceWidgetName: 'text' },
slotName: 'text'
})
@@ -323,8 +371,7 @@ describe('computeProcessedWidgets borderStyle', () => {
name: 'text',
type: 'combo',
nodeId: '1',
storeNodeId: '1',
storeName: 'text',
instanceWidgetName: 'text',
slotName: 'text',
options: { hidden: true }
})
@@ -333,8 +380,7 @@ describe('computeProcessedWidgets borderStyle', () => {
name: 'text',
type: 'combo',
nodeId: '1',
storeNodeId: '1',
storeName: 'text',
instanceWidgetName: 'text',
slotName: 'text'
})
@@ -362,6 +408,162 @@ describe('computeProcessedWidgets borderStyle', () => {
})
})
describe('per-instance value lookup for promoted widgets', () => {
const GRAPH_ID = 'graph-test'
const INTERIOR_NODE_ID = '5'
const INTERIOR_WIDGET_NAME = 'text'
const STORE_NAME = makeCompositeKey([
INTERIOR_NODE_ID,
INTERIOR_WIDGET_NAME,
''
])
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
})
function buildPromotedWidget(instanceId: string): SafeWidgetData {
return createMockWidget({
name: INTERIOR_WIDGET_NAME,
type: 'text',
slotName: INTERIOR_WIDGET_NAME,
nodeId: instanceId,
instanceWidgetName: STORE_NAME,
source: {
sourceNodeId: INTERIOR_NODE_ID,
sourceWidgetName: INTERIOR_WIDGET_NAME
},
sourceNodeLocatorId: `subgraph-def:${INTERIOR_NODE_ID}`
})
}
function processInstance(
instanceId: string,
widget: SafeWidgetData
): ReturnType<typeof computeProcessedWidgets> {
return computeProcessedWidgets({
nodeData: {
id: instanceId,
type: 'SubgraphNode',
widgets: [widget],
title: 'Subgraph Instance',
mode: 0,
selected: false,
executing: false,
inputs: [],
outputs: []
},
graphId: GRAPH_ID,
showAdvanced: false,
isGraphReady: false,
rootGraph: null,
ui: noopUi
})
}
it('reads per-instance values from cellNodeId/cellName cells when multiple instances share a definition', () => {
const widgetValueStore = useWidgetValueStore()
widgetValueStore.registerWidget(GRAPH_ID, {
nodeId: '100',
name: STORE_NAME,
type: 'text',
value: 'valueA',
options: {}
})
widgetValueStore.registerWidget(GRAPH_ID, {
nodeId: '200',
name: STORE_NAME,
type: 'text',
value: 'valueB',
options: {}
})
const [instanceA] = processInstance('100', buildPromotedWidget('100'))
const [instanceB] = processInstance('200', buildPromotedWidget('200'))
expect(instanceA.value).toBe('valueA')
expect(instanceB.value).toBe('valueB')
})
it('produces distinct dedupe identities per instance so duplicate-pruning does not collapse them', () => {
const widgetA = buildPromotedWidget('100')
const widgetB = buildPromotedWidget('200')
const identityA = getWidgetIdentity(widgetA, '100', 0)
const identityB = getWidgetIdentity(widgetB, '200', 0)
expect(identityA.dedupeIdentity).not.toBe(identityB.dedupeIdentity)
})
it('falls back to interior cell value when per-instance cell is absent for a promoted widget', () => {
const widgetValueStore = useWidgetValueStore()
widgetValueStore.registerWidget(GRAPH_ID, {
nodeId: INTERIOR_NODE_ID,
name: INTERIOR_WIDGET_NAME,
type: 'text',
value: 'interior-value',
options: {}
})
const promotedWidget = createMockWidget({
name: INTERIOR_WIDGET_NAME,
type: 'text',
slotName: INTERIOR_WIDGET_NAME,
nodeId: '57',
instanceWidgetName: 'STORE',
source: {
sourceNodeId: INTERIOR_NODE_ID,
sourceWidgetName: INTERIOR_WIDGET_NAME
}
})
const [result] = processInstance('57', promotedWidget)
expect(result.value).toBe('interior-value')
})
})
describe('ordinary widget dedupe', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
})
it('does not collapse duplicate ordinary widget refs in node.widgets[]', () => {
// Ordinary widgets do NOT carry their own identity (no widget.nodeId,
// no instanceWidgetName). When a host node legitimately renders the
// same widget twice, both must produce distinct entries with distinct
// renderKeys.
const ordinaryWidget = createMockWidget({
name: 'seed',
type: 'number',
nodeId: undefined,
instanceWidgetName: undefined
})
const result = computeProcessedWidgets({
nodeData: {
id: '1',
type: 'TestNode',
widgets: [ordinaryWidget, ordinaryWidget],
title: 'Test',
mode: 0,
selected: false,
executing: false,
inputs: [],
outputs: []
},
graphId: 'graph-test',
showAdvanced: false,
isGraphReady: false,
rootGraph: null,
ui: noopUi
})
expect(result).toHaveLength(2)
expect(result[0].renderKey).not.toBe(result[1].renderKey)
})
})
describe('createWidgetUpdateHandler (via computeProcessedWidgets)', () => {
const GRAPH_ID = 'graph-test'
const NODE_ID = '1'

View File

@@ -4,12 +4,22 @@ import { setActivePinia } from 'pinia'
import { nextTick } from 'vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
import type { PromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
import { LGraphNode as LiteGraphNode } from '@/lib/litegraph/src/litegraph'
import type { LGraph, SubgraphNode } from '@/lib/litegraph/src/litegraph'
import type { LGraphNode, NodeId } from '@/lib/litegraph/src/LGraphNode'
import {
createTestSubgraph,
createTestSubgraphNode,
resetSubgraphFixtureState
} from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers'
import type { LoadedComfyWorkflow } from '@/platform/workflow/management/stores/comfyWorkflow'
import { ComfyWorkflow as ComfyWorkflowClass } from '@/platform/workflow/management/stores/comfyWorkflow'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { app } from '@/scripts/app'
import type { ChangeTracker } from '@/scripts/changeTracker'
import { usePromotionStore } from '@/stores/promotionStore'
import { createMockChangeTracker } from '@/utils/__tests__/litegraphTestUtils'
const mockEmptyWorkflowDialog = vi.hoisted(() => {
@@ -26,7 +36,12 @@ const mockEmptyWorkflowDialog = vi.hoisted(() => {
vi.mock('@/scripts/app', () => ({
app: {
rootGraph: { extra: {}, nodes: [{ id: 1 }], events: new EventTarget() }
rootGraph: {
extra: {},
nodes: [{ id: 1, isSubgraphNode: () => false }],
events: new EventTarget(),
getNodeById: () => undefined
}
}
}))
@@ -47,6 +62,16 @@ vi.mock('@/renderer/core/canvas/canvasStore', () => ({
vi.mock('@/components/builder/useEmptyWorkflowDialog', () => ({
useEmptyWorkflowDialog: () => mockEmptyWorkflowDialog
}))
vi.mock('@/stores/domWidgetStore', () => ({
useDomWidgetStore: () => ({
widgetStates: new Map(),
setPositionOverride: vi.fn(),
clearPositionOverride: vi.fn()
})
}))
vi.mock('@/services/litegraphService', () => ({
useLitegraphService: () => ({ updatePreviews: () => ({}) })
}))
const mockSettings = vi.hoisted(() => {
const store: Record<string, unknown> = {}
@@ -68,6 +93,35 @@ vi.mock('@/platform/settings/settingStore', () => ({
import { useAppModeStore } from './appModeStore'
function createPromotedWidgetFixture(hostId: number): {
graph: LGraph
host: SubgraphNode
promoted: PromotedWidgetView
} {
const subgraph = createTestSubgraph({
inputs: [{ name: 'value', type: '*' }]
})
const inner = new LiteGraphNode('Inner')
const input = inner.addInput('value', '*')
inner.addWidget('text', 'value', 'a', () => {})
input.widget = { name: 'value' }
subgraph.add(inner)
subgraph.inputNode.slots[0].connect(input, inner)
const host = createTestSubgraphNode(subgraph, { id: hostId })
host._internalConfigureAfterSlots()
host.graph!.add(host)
usePromotionStore().setPromotions(host.rootGraph.id, host.id, [
{ sourceNodeId: String(inner.id), sourceWidgetName: 'value' }
])
const promoted = host.widgets.find(isPromotedWidgetView)
if (!promoted) throw new Error('Expected promoted widget view')
return { graph: host.graph!, host, promoted }
}
function createBuilderWorkflow(
activeMode: string = 'builder:inputs'
): LoadedComfyWorkflow {
@@ -106,10 +160,13 @@ describe('appModeStore', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
resetSubgraphFixtureState()
vi.mocked(app.rootGraph).extra = {}
mockResolveNode.mockReturnValue(undefined)
mockSettings.reset()
vi.mocked(app.rootGraph).nodes = [{ id: 1 } as LGraphNode]
vi.mocked(app.rootGraph).nodes = [
{ id: 1, isSubgraphNode: () => false } as LGraphNode
]
workflowStore = useWorkflowStore()
store = useAppModeStore()
vi.clearAllMocks()
@@ -258,6 +315,105 @@ describe('appModeStore', () => {
expect(store.selectedInputs).toEqual([[1, 'prompt', { height: 150 }]])
})
it('loadSelections rewrites legacy promoted tuples to host node id and storeName', async () => {
const { graph, host, promoted } = createPromotedWidgetFixture(500)
const { resolveNode: actualResolveNode } = (await vi.importActual(
'@/utils/litegraphUtil'
)) as {
resolveNode: (nodeId: NodeId, graph: LGraph) => LGraphNode | undefined
}
const originalRootGraph = app.rootGraph
mockResolveNode.mockImplementation((id) => actualResolveNode(id, graph))
Object.defineProperty(app, 'rootGraph', { value: graph, writable: true })
try {
store.loadSelections({
inputs: [[promoted.sourceNodeId, promoted.sourceWidgetName]],
outputs: []
})
expect(store.selectedInputs).toEqual([[host.id, promoted.storeName]])
} finally {
Object.defineProperty(app, 'rootGraph', {
value: originalRootGraph,
writable: true
})
}
})
it('preserves selected promoted-widget identity per instance across save/reload', async () => {
// Build one subgraph definition with one promoted widget,
// then create two SubgraphNode instances of that definition.
const subgraph = createTestSubgraph({
inputs: [{ name: 'value', type: '*' }]
})
const inner = new LiteGraphNode('Inner')
const input = inner.addInput('value', '*')
inner.addWidget('text', 'value', 'a', () => {})
input.widget = { name: 'value' }
subgraph.add(inner)
subgraph.inputNode.slots[0].connect(input, inner)
const hostA = createTestSubgraphNode(subgraph, { id: 701 })
hostA._internalConfigureAfterSlots()
hostA.graph!.add(hostA)
const hostB = createTestSubgraphNode(subgraph, { id: 702 })
hostB._internalConfigureAfterSlots()
hostB.graph!.add(hostB)
const promotionStore = usePromotionStore()
promotionStore.setPromotions(hostA.rootGraph.id, hostA.id, [
{ sourceNodeId: String(inner.id), sourceWidgetName: 'value' }
])
promotionStore.setPromotions(hostB.rootGraph.id, hostB.id, [
{ sourceNodeId: String(inner.id), sourceWidgetName: 'value' }
])
const promotedA = hostA.widgets.find(isPromotedWidgetView)
const promotedB = hostB.widgets.find(isPromotedWidgetView)
if (!promotedA || !promotedB) throw new Error('Expected promoted views')
// Precondition: storeNames are equal (interior identity matches),
// host ids differ.
expect(promotedA.storeName).toBe(promotedB.storeName)
expect(hostA.id).not.toBe(hostB.id)
const { resolveNode: actualResolveNode } = (await vi.importActual(
'@/utils/litegraphUtil'
)) as {
resolveNode: (nodeId: NodeId, graph: LGraph) => LGraphNode | undefined
}
const graph = hostA.graph!
const originalRootGraph = app.rootGraph
mockResolveNode.mockImplementation((id) => actualResolveNode(id, graph))
Object.defineProperty(app, 'rootGraph', { value: graph, writable: true })
try {
store.loadSelections({
inputs: [
[hostA.id, promotedA.storeName],
[hostB.id, promotedB.storeName]
],
outputs: []
})
expect(store.selectedInputs).toHaveLength(2)
expect(store.selectedInputs).toEqual(
expect.arrayContaining([
[hostA.id, promotedA.storeName],
[hostB.id, promotedB.storeName]
])
)
} finally {
Object.defineProperty(app, 'rootGraph', {
value: originalRootGraph,
writable: true
})
}
})
it('keeps inputs for existing nodes even if widget is missing', async () => {
const node1 = mockNode(1)
mockResolveNode.mockImplementation((id) =>
@@ -443,6 +599,18 @@ describe('appModeStore', () => {
})
})
describe('removeSelectedInput', () => {
it('uses host node id and promoted storeName', () => {
const { host, promoted } = createPromotedWidgetFixture(601)
store.selectedInputs = [[host.id, promoted.storeName]]
store.removeSelectedInput(promoted, host)
expect(store.selectedInputs).toEqual([])
})
})
describe('autoEnableVueNodes', () => {
it('enables Vue nodes when entering select mode with them disabled', async () => {
mockSettings.store['Comfy.VueNodes.Enabled'] = false

View File

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

View File

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

View File

@@ -0,0 +1,25 @@
import { describe, expect, it } from 'vitest'
import { makeCompositeKey } from './compositeKey'
describe('makeCompositeKey', () => {
it('produces a stable string for a tuple of values', () => {
expect(makeCompositeKey(['a', 'b', 'c'])).toBe('["a","b","c"]')
})
it('distinguishes tuples whose joined parts collide', () => {
// Without an injective encoding, ['ab', 'c'] and ['a', 'bc'] could collide.
expect(makeCompositeKey(['ab', 'c'])).not.toBe(
makeCompositeKey(['a', 'bc'])
)
})
it('handles empty parts and undefined slots', () => {
expect(makeCompositeKey(['x', '', 'y'])).toBe('["x","","y"]')
expect(makeCompositeKey(['x', undefined, 'y'])).toBe('["x",null,"y"]')
})
it('preserves part order', () => {
expect(makeCompositeKey(['1', '2'])).not.toBe(makeCompositeKey(['2', '1']))
})
})

10
src/utils/compositeKey.ts Normal file
View File

@@ -0,0 +1,10 @@
/**
* Build an opaque composite-key string from a tuple of values, suitable for
* use as a Map or Set key. Uses JSON.stringify so the encoding is injective
* across arbitrary string inputs (no separator collision possible). Keep the
* format opaque at consumer boundaries — do not parse it externally except
* in modules that own the round-trip (e.g. favoritedWidgetsStore).
*/
export function makeCompositeKey(parts: readonly unknown[]): string {
return JSON.stringify(parts)
}

View File

@@ -1,11 +1,63 @@
import { describe, expect, it } from 'vitest'
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
import { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
import { createTestSubgraph } from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers'
import {
createTestSubgraph,
createTestSubgraphNode,
resetSubgraphFixtureState
} from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers'
import { usePromotionStore } from '@/stores/promotionStore'
import { resolveNode } from './litegraphUtil'
import { resolveNode, resolveNodeWidget } from './litegraphUtil'
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
useCanvasStore: () => ({})
}))
vi.mock('@/stores/domWidgetStore', () => ({
useDomWidgetStore: () => ({
widgetStates: new Map(),
setPositionOverride: vi.fn(),
clearPositionOverride: vi.fn()
})
}))
vi.mock('@/services/litegraphService', () => ({
useLitegraphService: () => ({ updatePreviews: () => ({}) })
}))
function createPromotedWidgetFixture(hostId: number) {
const subgraph = createTestSubgraph({
inputs: [{ name: 'value', type: '*' }]
})
const inner = new LGraphNode('Inner')
const input = inner.addInput('value', '*')
inner.addWidget('text', 'value', 'a', () => {})
input.widget = { name: 'value' }
subgraph.add(inner)
subgraph.inputNode.slots[0].connect(input, inner)
const host = createTestSubgraphNode(subgraph, { id: hostId })
host._internalConfigureAfterSlots()
host.graph!.add(host)
usePromotionStore().setPromotions(host.rootGraph.id, host.id, [
{ sourceNodeId: String(inner.id), sourceWidgetName: 'value' }
])
const promoted = host.widgets.find(isPromotedWidgetView)
if (!promoted) throw new Error('Expected promoted widget view')
return { host, promoted }
}
describe('resolveNode', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
resetSubgraphFixtureState()
})
it('returns undefined when graph is null', () => {
expect(resolveNode(1, null)).toBeUndefined()
})
@@ -67,4 +119,43 @@ describe('resolveNode', () => {
const targetNode = sg2._nodes[0]
expect(resolveNode(targetNode.id, rootGraph)).toBe(targetNode)
})
it('resolves promoted widget by host node id and storeName', () => {
const { host, promoted } = createPromotedWidgetFixture(501)
const [resolvedNode, resolvedWidget] = resolveNodeWidget(
host.id,
promoted.storeName,
host.graph!
)
expect(resolvedNode).toBe(host)
expect(resolvedWidget).toBe(promoted)
})
it('keeps legacy fallback for saved promoted widget source tuples', () => {
const { host, promoted } = createPromotedWidgetFixture(502)
const [resolvedNode, resolvedWidget] = resolveNodeWidget(
promoted.sourceNodeId,
promoted.sourceWidgetName,
host.graph!
)
expect(resolvedNode).toBe(host)
expect(resolvedWidget).toBe(promoted)
})
it('keeps legacy fallback for saved promoted widget source tuples with numeric node ids', () => {
const { host, promoted } = createPromotedWidgetFixture(503)
const [resolvedNode, resolvedWidget] = resolveNodeWidget(
Number(promoted.sourceNodeId),
promoted.sourceWidgetName,
host.graph!
)
expect(resolvedNode).toBe(host)
expect(resolvedWidget).toBe(promoted)
})
})

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

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

View File

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

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

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

View File

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

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

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

View File

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

View File

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

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

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

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

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

View File

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

View File

@@ -1,7 +1,15 @@
import '@testing-library/jest-dom/vitest'
import { vi } from 'vitest'
import { beforeEach, vi } from 'vitest'
import 'vue'
import { resetWorldInstance } from '@/world/worldInstance'
// The World is a module-level singleton; reset it between tests so widget
// state registered via the widgetValueStore facade does not leak across tests.
beforeEach(() => {
resetWorldInstance()
})
// Mock @sparkjsdev/spark which uses WASM that doesn't work in Node.js
vi.mock('@sparkjsdev/spark', () => ({
SplatMesh: class SplatMesh {