The linkStore is now the single source for slot connectivity in both
directions. The NodeInputSlot.link field and all runtime write sites are
gone; a read-only prototype getter derives the id from the store and
fires warnDeprecated as extension migration telemetry (no setter, so
writes throw). Wire format unchanged: serialization derives
inputs[].link from the store.
The mirror-carried association machinery is reworked rather than
papered over: fixLinkInputSlots consumes the serialized graph data
(where node.configure reorders inputs in place), dynamicWidgets' group
rebuilds carry slot-to-link association in a WeakMap refreshed from the
store, reorderSubgraphInputs captures outer links by pre-reorder index,
and link deduplication selects survivors from the store registration -
repairInputLinks is deleted because the derived view cannot be
inconsistent.
Extension migration: read via node.isInputConnected(slot) /
node.getInputLink(slot); mutate via node.connect() /
node.disconnectInput().
The linkStore is the single source for output-side connectivity. The
NodeOutputSlot.links field and all nine write sites are gone; a
read-only prototype getter derives the array from the store and fires
warnDeprecated as migration telemetry for extensions (no setter, so
writes throw). INodeOutputSlot keeps links as deprecated readonly so
'links in slot' discriminants still compile. Wire format is unchanged:
serialization derives outputs[].links from the store, and serialized
operators (linkFixer serialized branch, migrateReroute, unpackSubgraph
strip) are untouched; linkFixer now completes a missing input mirror
instead of deleting a store-registered link.
Also fixes createTestSubgraphNode minting duplicate node ids (the
fixture never reserved the id it assigned) and promoted-widget labels
not inheriting the source slot label - both previously masked by the
mirror or the id collision.
Extension migration: read via node.isOutputConnected(slot) /
node.getOutputNodes(slot); mutate via node.connect() /
node.disconnectOutput(). Design record: docs/architecture/
output-slot-connectivity.md Decision 6.
getOutputSlotLinks now returns ReadonlySet<LinkTopology> instead of link
ids: floating links draw ids from a separate counter, so a bare id cannot
be resolved safely through graph.links, and the migrated readers need
endpoints anyway. Migrated: minimap link extraction, slot-drag disconnect
check (one store query replaces a mirror read plus a slotFloatingLinks
scan), widget value propagation, and matchType link revalidation.
rerouteNode/widgetInputs/groupNode stay on the mirror deliberately - they
read inside connection callbacks whose behavior depends on mid-mutation
mirror staleness - as do the serialized-workflow repair tools. Design
record updated.
Adds a derived per-graph reverse index over link origins to linkStore,
exposing isOutputSlotConnected / getOutputSlotLinks as the output-side
mirror of the input queries. Target and origin slot keys are branded
separately so a key built for one index cannot be looked up in the other.
NodeSlots now passes connected to InputSlot and OutputSlot from the store;
the lg-slot--connected class reaches the dot via CSS, so SlotConnectionDot
needs no new prop. Design record in docs/architecture.
LGraph.clear() and configure() reassign the root graph id, so the badge
system now resolves it on every recompute instead of capturing it at
start; a captured id stranded the system on a dead bucket after
Clear Workflow, dropping every badge on nodes added afterwards.
LGraph.add registers the node in the badge store only after
onNodeAdded: the store write wakes the system watcher and queues Vue's
flush, which must not overtake the deferred paste scan that surfaces
missing-model errors before selection-scoped tabs recalculate.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Wrapper aggregation counts inner api nodes (the old unconditional
per-api-node badge basis) rather than resolved credits rows, so a
pending async price still yields 'Partner Nodes x N'; the label is
localized via nodeBadge.partnerNodesCount. useNodePricing now caches
labels per signature: a leaf's own read and its wrapper's
promoted-override read previously shared one cache slot and evicted
each other, re-scheduling evaluations forever once both ran in eager
system effects.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A per-node effect flushing between unregisterNode and its scope being
stopped could re-create the bucket key and resurrect a removed node's
registration. Row writes (registerBadge, setBadgesOfKind) now require
an existing registration; registerBadge returns undefined when refused.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Badges go straight to plain BadgeData rows in a dedicated store,
written by a reactive badge system; core badges materialize as rows
too, killing the dual derivation and positional slice(1). Adds badge
vocabulary to the domain glossary and repoints the node-data-store
badges row.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Decisions 1 (single NodeState object, proxy-returning registration) is
agreed; field set, slot scope, and consumer contract are drafted pending
review. Records the shipped inputs[].link reader migration.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Link geometry is still removed from the layout store in the current
node-removal flow; the target-architecture matrix now marks the
SlotConnection write as future work; the proto-ECS inventory narrows
LayoutStore's link source to geometry.
Adds the missing link topology store design record, amends ADR 0008
(linkStore/rerouteStore amendment, Link and Slot component notes, new
supporting-document rows), and corrects the migration plan, target
architecture, proto-ECS store inventory, lifecycle scenarios, entity
interactions, and entity problems docs to match the shipped stores:
target-input-slot keying, root-graph-scoped buckets, derived reroute
membership, deleted layout-store connectivity mirror, and deleted
slot._floatingLinks sets.
Share EMPTY_MEMBERSHIP from the store, stop creating buckets inside the
membership computed, align registerRerouteChain's graph param with
registerLinkTopology, unnest the dedup id allocator, tighten store test
assertions, and clarify the design record (updateEndpoint guard,
load-time orphan drop).
## Summary
Realign the ECS architecture docs and ADR 0008 with the shipped
direction (PR #12617): entity data lives in dedicated Pinia stores keyed
by string IDs, rather than one unified \"World\" registry addressed by
branded entity IDs.
## Changes
- **What**: Docs-only. Retarget the `docs/architecture/` set + ADR 0008
+ agent guidance from the single-World/branded-`*EntityId` model to the
dedicated-store model that PR #12617 actually shipped
(`widgetValueStore` keyed by `WidgetId`, `layoutStore`,
`nodeOutputStore`, `domWidgetStore`, `subgraphNavigationStore`,
`previewExposureStore`).
- `AGENTS.md` + `.agents/checks/adr-compliance.md`: point entity-data
guidance at dedicated stores; fix the inverted `world.getComponent`
compliance check (it flagged correct store-based code).
- `ADR 0008`: dated amendment note (stays Proposed); rewrite the World
section → dedicated stores, Branded ID design (`WidgetId` composite
string), migration strategy, render-loop, consequences, notes.
- `proto-ecs-stores.md`: flip the \"Unified World / branded IDs\"
framing from gap-to-close → target; replace the deleted
`PromotedWidgetViewManager` with the `input.widgetId` store-backed
model; fix key formats and store count.
- `ecs-target-architecture.md` / `ecs-lifecycle-scenarios.md` /
`ecs-migration-plan.md`: reframe all `world.*` APIs and `*EntityId`
brands to per-store APIs + string keys; mark already-shipped migration
phases done.
- `subgraph-boundaries-and-promotion.md` / `entity-interactions.md` /
`entity-problems.md`: scope-tagged store entries; swap removed
`PromotionStore` for `previewExposureStore`.
- `appendix-critical-analysis.md`: post-pivot status banner + resolution
notes on the critiques the pivot vindicated; still-open gaps (extension
callbacks, atomicity, Y.js↔ECS) left live.
- `appendix-ecs-pattern-survey.md`: supersede banner; keep the external
library survey (§1).
- Delete obsolete `ecs-world-command-api.md` (its command-pattern
argument folded into ADR 0008).
- **Breaking**: None (documentation only).
## Review Focus
- ADR 0008 stays **Proposed** with an amendment note rather than a new
superseding ADR — confirm that's the preferred mechanism vs. a fresh
ADR.
- Numeric per-kind brands (`NodeEntityId`, `LinkEntityId`, …) are
retained in ADR 0008 but explicitly marked aspirational/unshipped; only
`WidgetId` (composite string) reflects shipped code.
- `appendix-ecs-pattern-survey.md` §2–§4 are kept under a supersede
banner as historical record (they describe the deleted `src/world/`
substrate) rather than rewritten — confirm that's preferred over
deletion.
- Net −384 lines; no code or test changes.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
## Summary
Add the ECS pattern survey appendix to ADR 0008's companion-documents
table, and drop stale `PromotionStore` references across architecture
docs to reflect the ADR 0009 removal.
## Changes
- **What**:
- New `docs/architecture/appendix-ecs-pattern-survey.md` — surveys
bitECS, miniplex, koota, ECSY, Bevy, and Thyseus: patterns adopted,
departed from, and when to revisit.
- ADR 0008 companion table gains a row pointing at the new appendix.
- `docs/adr/0009-…/before-after-flows.md`,
`docs/architecture/ecs-target-architecture.md`,
`docs/architecture/entity-problems.md` — drop references to
`PromotionStore` / `usePromotionStore` (the legacy three-layer mechanism
is gone; promoted value widgets are now linked `SubgraphInput`s).
- `docs/architecture/subgraph-boundaries-and-promotion.md` — reframes
its "current mechanism" section as historical context with an explicit
"removed by ADR 0009" callout.
- **Breaking**: None — docs-only.
## Review Focus
Wording in the historical-context callout on
`subgraph-boundaries-and-promotion.md`. Everything else is a small
cleanup or a new standalone document.
---------
Co-authored-by: Amp <amp@ampcode.com>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
Co-authored-by: GitHub Action <action@github.com>
## Summary
Introduces **Subgraph Link Only Promotion** (ADR 0009) — a new model for
surfacing inner subgraph widgets on the parent SubgraphNode by
*promoting through links* rather than by duplicating widget state on the
host. Ships with the hygiene/refactor pass on the migration, store, and
event layers that the new model depends on.
## What changes
### Subgraph Link Only Promotion (ADR 0009)
Promoted widgets are defined by the link from a SubgraphNode input to
the interior node, not by a duplicated widget instance on the host.
Consequences:
- A SubgraphNode renders inner widgets purely as a **projection** of the
interior widgets and links — no host-side state to drift.
- **Per-host independence**: multiple instances of the same SubgraphNode
render and edit their own values without cross-talk.
- **Reversible promote/demote**: structural link operation, so demote
preserves host slots and external connections (#12278).
### Supporting refactors
- **Migration** — Planner/classifier/repair/quarantine helpers collapsed
into a single `proxyWidgetMigration` entry point with black-box
round-trip coverage. Honors the source-node-id disambiguator on
`proxyWidgets`, so deduplicated names (e.g. `text`, `text_1`) resolve to
the right interior widget.
- **Widget identity** — `appMode` unified on `WidgetEntityId`; promoted
widget state is keyed by entityId across the store, DOM, and migration
paths.
- **SubgraphNode** — 3-key promoted-view cache replaced with a single
version counter + explicit `invalidatePromotedViews()` at mutation
sites; `id === -1` sentinel removed.
- **Events** — `LGraph.trigger()` now dispatches node trigger payloads
through `this.events`, replacing a leaky `onTrigger` monkey-patch.
`SubgraphEditor` reactivity is driven from subgraph events instead of
imperative refresh.
- **Stores** — `appModeStore` migration helpers collapsed into
`upgradeAndValidateInput`; `nodeOutputStore.*ByExecutionId` derived from
the locator index; `previewExposureStore` cleanup and cycle-detection
double-warn fix.
- **Misc** — `Outcome` types consolidated; mutable accumulators replaced
with `flatMap`; new ESLint rule forbids litegraph imports under
`src/world/`.
### Tests
- Browser tests for promoted widgets retagged `@vue-nodes` and rewritten
to assert against the rendered Vue node DOM (via `getNodeLocator` /
`getByRole('textbox')` / `enterSubgraph`) instead of `page.evaluate`
graph introspection.
- Per-host widget independence asserted via DOM.
- Migration coverage moved to black-box round-trip tests.
- Added coverage for duplicate-named promoted widget identity (ADR 0009)
and the per-parent demote branch in `WidgetActions`.
## Review focus
- ADR 0009 conformance of the link-only promotion model.
- Disambiguator resolution path in `proxyWidgetMigration`.
- Single-version-counter promoted-view cache and its
`invalidatePromotedViews()` call sites.
- `LGraph.trigger()` event dispatch and the `AppModeWidgetList.vue`
migration off `onTrigger` (FE-667 tracks the remaining
`useGraphNodeManager` conversion).
## Breaking changes
None for users. Internal subgraph promotion APIs changed — see ADR 0009.
┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12197-feat-subgraph-link-only-widget-promotion-migration-store-hygiene-35e6d73d365081fd882cf3a69bc09956)
by [Unito](https://www.unito.io)
---------
Co-authored-by: Amp <amp@ampcode.com>
Co-authored-by: GitHub Action <action@github.com>
Co-authored-by: github-actions <github-actions@github.com>
Co-authored-by: Glary-Bot <glary-bot@users.noreply.github.com>
Co-authored-by: AustinMroz <austin@comfy.org>
## Summary
Harden the `ChangeTracker` lifecycle to eliminate the class of bugs
where an inactive workflow's tracker silently captures the wrong graph
state. Renames `checkState()` to `captureCanvasState()` with a
self-defending assertion, introduces `deactivate()` and
`prepareForSave()` lifecycle methods, and closes a latent undo-history
corruption bug discovered during code review.
## Background
ComfyUI supports multiple workflows open as tabs, but only one canvas
(`app.rootGraph`) exists at a time. When the user switches tabs, the old
workflow's graph is unloaded and the new one is loaded into this shared
canvas.
The old `checkState()` method serialized `app.rootGraph` into
`activeState` to track changes for undo/redo. It had no awareness of
*which* workflow it belonged to -- if called on an inactive tab's
tracker, it would capture the active tab's graph data and silently
overwrite the inactive workflow's state. This caused permanent data loss
(fixed in PR #10745 with caller-side `isActive` guards).
The caller-side guards were fragile: every new call site had to remember
to add the guard, and forgetting would reintroduce the same silent data
corruption. Additionally, `beforeLoadNewGraph` only called `store()`
(viewport/outputs) without `checkState()`, meaning canvas state could be
stale if a tab switch happened without a preceding mouseup event.
### Before (fragile)
```
saveWorkflow(workflow):
if (isActive(workflow)) <-- caller must remember this guard
workflow.changeTracker.checkState() <-- name implies "read", actually writes
...
beforeLoadNewGraph():
activeWorkflow.changeTracker.store() <-- only saves viewport, NOT graph state
```
### After (self-defending)
```
saveWorkflow(workflow):
workflow.changeTracker.prepareForSave() <-- handles active/inactive internally
...
beforeLoadNewGraph():
activeWorkflow.changeTracker.deactivate() <-- captures graph + viewport together
```
## Changes
- Rename `checkState` to `captureCanvasState` with active-tracker
assertion
- Add `deactivate()` and `prepareForSave()` lifecycle methods
- Fix undo-history corruption: `captureCanvasState()` guarded by
`_restoringState`
- Fix viewport regression during undo: `deactivate()` skips
`captureCanvasState()` during undo/redo but always calls `store()` to
preserve viewport (regression from PR #10247)
- Log inactive tracker warnings unconditionally at warn level (not
DEV-only)
- Deprecated `checkState()` wrapper for extension compatibility
- Rename `checkState` to `captureCanvasState` in
`useWidgetSelectActions` composable
- Add `appModeStore.ts` to manual call sites documentation
- Add `checkState()` deprecation note to architecture docs
- Add 16 unit tests covering all guard conditions, lifecycle methods,
and undo behavior
- Add E2E test: "Undo preserves viewport offset"
## New ChangeTracker Public API
| Method | Caller | Purpose |
|--------|--------|---------|
| `captureCanvasState()` | Event handlers, UI interactions | Snapshots
canvas into activeState, pushes undo. Asserts active tracker. |
| `deactivate()` | `beforeLoadNewGraph` only | `captureCanvasState()`
(skipped during undo/redo) + `store()`. Freezes state for tab switch. |
| `prepareForSave()` | Save paths only | Active: `captureCanvasState()`.
Inactive: no-op. |
| `checkState()` | **Deprecated** -- extensions only | Wrapper that
delegates to `captureCanvasState()` with deprecation warning. |
| `store()` | Internal to `deactivate()` | Saves viewport, outputs,
subgraph navigation. |
| `restore()` | `afterLoadNewGraph` | Restores viewport, outputs,
subgraph navigation. |
| `reset()` | `afterLoadNewGraph`, save | Resets initial state (marks as
"clean"). |
## Test plan
- [x] Unit tests: 16 tests covering all guard conditions, state capture,
undo queue behavior
- [x] E2E test: "Undo preserves viewport offset" verifies no viewport
drift on undo
- [x] E2E test: "Prevents captureCanvasState from corrupting workflow
state during tab switch"
- [x] Existing E2E: "Closing an inactive tab with save preserves its own
content"
- [ ] Manual: rapidly switch tabs during undo/redo, verify no viewport
drift
- [ ] Manual: verify extensions calling `checkState()` see deprecation
warning in console