Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
64 commits
Select commit Hold shift + click to select a range
9f00d2d
fix(layout): project remote and concurrent layout updates
DrJKL Aug 4, 2026
d3bd9e0
fix(layout): harden layout ownership transactions
DrJKL Aug 4, 2026
7da41fb
fix(layout): abort rejected graph teardown
DrJKL Aug 4, 2026
4b5025a
fix(layout): enforce entity ownership with compensation
DrJKL Aug 4, 2026
b7c8e93
fix: re-check tab fallback after deferred error scans
DrJKL Aug 5, 2026
b7df258
fix(layout): preserve batch resize metadata
DrJKL Aug 6, 2026
5f1bc99
test: cover remote geometry materialization
DrJKL Aug 6, 2026
68a00e5
test: keep geometry fixtures test-local
DrJKL Aug 6, 2026
eccaa2f
fix(layout): read legacy node geometry
DrJKL Aug 6, 2026
d7788d7
refactor(layout): hide the Yjs document
DrJKL Aug 6, 2026
c9b6671
test: use typed layout ownership reads
DrJKL Aug 6, 2026
0fbeb8d
refactor(layout): keep Yjs test access out of the store
DrJKL Aug 6, 2026
ccd31a3
fix(layout): adopt materialized reroute geometry
DrJKL Aug 6, 2026
6143cfb
fix(layout): register retained reroutes on configure
DrJKL Aug 6, 2026
fd60caa
test: move remote reroute off Vue nodes
DrJKL Aug 6, 2026
437eaa2
refactor(layout): isolate lifecycle ownership
DrJKL Aug 6, 2026
4bb4ba7
fix(layout): detach pruned reroute layouts
DrJKL Aug 6, 2026
daaaf14
refactor(layout): keep lifecycle helpers private
DrJKL Aug 6, 2026
800c304
test(layout): simplify geometry regressions
DrJKL Aug 6, 2026
051f284
test(layout): await geometry injection
DrJKL Aug 6, 2026
619b784
test(layout): consolidate facade coverage
DrJKL Aug 6, 2026
95e4ee7
test(layout): curate command behavior
DrJKL Aug 6, 2026
ef0714b
test(layout): cover partial batch recovery
DrJKL Aug 6, 2026
1df0b41
fix(layout): defer released graph teardown
DrJKL Aug 6, 2026
4a4692b
test(layout): consolidate lifecycle coverage
DrJKL Aug 6, 2026
f5555cb
refactor(layout): remove dead facade methods
DrJKL Aug 6, 2026
086008e
fix(layout): harden CRDT ownership teardown
DrJKL Aug 6, 2026
8feb608
test: fix layout CI regressions
DrJKL Aug 7, 2026
9477a43
test(layout): cover remote group projection
DrJKL Aug 7, 2026
5ec6b2c
fix(layout): preserve replacement node position
DrJKL Aug 7, 2026
5ad7002
fix(layout): address ownership review findings
DrJKL Aug 7, 2026
f527925
refactor(layout): unify registration teardown
DrJKL Aug 7, 2026
5dce261
refactor(layout): unify registration lifecycle
DrJKL Aug 8, 2026
b2db253
refactor(layout): make root cleanup store-owned
DrJKL Aug 8, 2026
6556e8f
refactor(layout): linearize graph lifecycle
DrJKL Aug 8, 2026
dd87be0
refactor(layout): evict unrelated panel and guidance changes
DrJKL Aug 8, 2026
7392dc2
refactor(layout): collapse two-phase mutations into single transactio…
DrJKL Aug 8, 2026
b5d2cab
refactor(layout): replace saga compensation with fail-fast delayed te…
DrJKL Aug 8, 2026
2682a9e
refactor(layout): unify entity registration records
DrJKL Aug 8, 2026
48c0469
refactor(layout): reject empty ownership tokens at operation boundary
DrJKL Aug 8, 2026
8f1237b
refactor(layout): drop legacy node rect fallback
DrJKL Aug 8, 2026
d2891fc
test(layout): use production attach path for group setup
DrJKL Aug 8, 2026
2bd515e
test(layout): remove fabricated Yjs interleaving and internal-failure…
DrJKL Aug 8, 2026
9a52f98
fix(layout): only adopt persisted link topology with matching endpoints
DrJKL Aug 8, 2026
c46a210
docs: track pruned items and complexity lessons for layout safety PR
DrJKL Aug 8, 2026
81ca315
test: add expected-failure repros for node removal contract and subgr…
DrJKL Aug 8, 2026
382b146
refactor: extract graph entity ID allocation into dependency-free idA…
DrJKL Aug 8, 2026
70806e0
fix: make graph node removal callbacks transactional
DrJKL Aug 9, 2026
0333065
refactor: thread graph scope through topology APIs
DrJKL Aug 9, 2026
11ba40a
refactor: scope topology storage by owning graph
DrJKL Aug 9, 2026
b5464a6
refactor: rebuild owner topology on configure instead of adopting col…
DrJKL Aug 9, 2026
21bff55
refactor: replace floating links on completion instead of mutating en…
DrJKL Aug 9, 2026
0d1a1f0
refactor: replace links with fresh identities on structural slot changes
DrJKL Aug 9, 2026
6b71ddf
refactor: replace link identities during subgraph and node transforms
DrJKL Aug 9, 2026
ed92d92
refactor: enforce immutable identity on registered links
DrJKL Aug 9, 2026
8908f81
test: freeze layout registration contract before adapter consolidation
DrJKL Aug 9, 2026
1fd3c0d
refactor: consolidate layout registration behind typed public facade
DrJKL Aug 9, 2026
a608714
refactor: linearize layout detach by clearing local attachment before…
DrJKL Aug 9, 2026
1d7f565
refactor: eliminate ownership tokens from layout operations
DrJKL Aug 9, 2026
d1ff74f
refactor: rename layout registration vocabulary to attachment
DrJKL Aug 9, 2026
25ff2e7
docs: document tokenless ID-addressed layout operations in ADR 0003
DrJKL Aug 9, 2026
11c6666
chore: untrack agent working docs under gitignored temp/
DrJKL Aug 9, 2026
909d650
fix: resolve subgraph slot links from the registered link map
DrJKL Aug 9, 2026
13f2773
test: assert link topology across rewiring instead of stale identities
DrJKL Aug 9, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
156 changes: 156 additions & 0 deletions browser_tests/assets/subgraphs/subgraph-link-identity-collision.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
{
"id": "987de650-fd73-4f2d-a7d6-ef58909d56cb",
"revision": 0,
"last_node_id": 2,
"last_link_id": 0,
"nodes": [
{
"id": 1,
"type": "11111111-1111-4111-8111-111111111111",
"pos": [300, 300],
"size": [220, 80],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [{ "name": "input", "type": "*", "link": null }],
"outputs": [{ "name": "output", "type": "*", "links": null }],
"properties": {},
"widgets_values": []
},
{
"id": 2,
"type": "22222222-2222-4222-8222-222222222222",
"pos": [650, 300],
"size": [220, 80],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [{ "name": "input", "type": "*", "link": null }],
"outputs": [{ "name": "output", "type": "*", "links": null }],
"properties": {},
"widgets_values": []
}
],
"links": [],
"groups": [],
"definitions": {
"subgraphs": [
{
"id": "11111111-1111-4111-8111-111111111111",
"version": 1,
"state": {
"lastGroupId": 0,
"lastNodeId": 0,
"lastLinkId": 1,
"lastRerouteId": 101
},
"revision": 0,
"config": {},
"name": "Identity collision A",
"inputNode": {
"id": -10,
"bounding": [100, 200, 120, 60]
},
"outputNode": {
"id": -20,
"bounding": [500, 200, 120, 60]
},
"inputs": [
{
"id": "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
"name": "input",
"type": "*",
"linkIds": [1],
"pos": { "0": 200, "1": 220 }
}
],
"outputs": [
{
"id": "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb",
"name": "output",
"type": "*",
"linkIds": [1],
"pos": { "0": 520, "1": 220 }
}
],
"widgets": [],
"nodes": [],
"groups": [],
"links": [
{
"id": 1,
"origin_id": -10,
"origin_slot": 0,
"target_id": -20,
"target_slot": 0,
"type": "*",
"parentId": 101
}
],
"reroutes": [{ "id": 101, "pos": [300, 220], "linkIds": [1] }],
"extra": {}
},
{
"id": "22222222-2222-4222-8222-222222222222",
"version": 1,
"state": {
"lastGroupId": 0,
"lastNodeId": 0,
"lastLinkId": 1,
"lastRerouteId": 201
},
"revision": 0,
"config": {},
"name": "Identity collision B",
"inputNode": {
"id": -10,
"bounding": [100, 400, 120, 60]
},
"outputNode": {
"id": -20,
"bounding": [500, 400, 120, 60]
},
"inputs": [
{
"id": "cccccccc-cccc-4ccc-8ccc-cccccccccccc",
"name": "input",
"type": "*",
"linkIds": [1],
"pos": { "0": 200, "1": 420 }
}
],
"outputs": [
{
"id": "dddddddd-dddd-4ddd-8ddd-dddddddddddd",
"name": "output",
"type": "*",
"linkIds": [1],
"pos": { "0": 520, "1": 420 }
}
],
"widgets": [],
"nodes": [],
"groups": [],
"links": [
{
"id": 1,
"origin_id": -10,
"origin_slot": 0,
"target_id": -20,
"target_slot": 0,
"type": "*",
"parentId": 201
}
],
"reroutes": [{ "id": 201, "pos": [400, 420], "linkIds": [1] }],
"extra": {}
}
]
},
"config": {},
"extra": {
"ds": { "scale": 1, "offset": [0, 0] },
"frontendVersion": "1.24.1"
},
"version": 0.4
}
15 changes: 12 additions & 3 deletions browser_tests/tests/graph.spec.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import { expect } from '@playwright/test'

import { toLinkId } from '@/types/linkId'
import { toNodeId } from '@/types/nodeId'

import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
Expand All @@ -17,8 +16,18 @@ test.describe('Graph', { tag: ['@smoke', '@canvas'] }, () => {
await expect
.poll(() =>
comfyPage.page.evaluate(
(linkId) => window.app!.graph!.links.get(linkId)?.target_slot,
toLinkId(1)
({ originId, originSlot, targetId }) =>
[...window.app!.graph!.links.values()].find(
(link) =>
link.origin_id === originId &&
link.origin_slot === originSlot &&
link.target_id === targetId
)?.target_slot,
{
originId: toNodeId(2),
originSlot: 0,
targetId: toNodeId(1)
}
)
)
.toBe(1)
Expand Down
39 changes: 39 additions & 0 deletions browser_tests/tests/nodeReplacement.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -259,4 +259,43 @@ test.describe('Node replacement', { tag: ['@node', '@ui'] }, () => {
})
})
}

test(
'Replacement keeps its position when enabling Vue Nodes',
{ tag: ['@vue-nodes'] },
async ({ comfyPage }) => {
test.slow()
await setupNodeReplacement(comfyPage, mockNodeReplacementsSingle)
await loadWorkflowAndOpenErrorsTab(
comfyPage,
'missing/node_replacement_simple'
)
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', false)
await comfyPage.nextFrame()

await getSwapNodesGroup(comfyPage.page)
.getByRole('button', { name: /replace node/i })
.click()

const [ksampler] = await comfyPage.nodeOps.getNodeRefsByTitle('KSampler')
await ksampler.dragBy({ x: 120, y: 90 })
await comfyPage.nextFrame()
const draggedTitlePosition = await ksampler.getTitlePosition()

await comfyPage.menu.topbar.setVueNodesEnabled(true)
await comfyPage.vueNodes.waitForNodes()

const { header } = await comfyPage.vueNodes.getFixtureByTitle('KSampler')
await expect
.poll(async () => {
const box = await header.boundingBox()
if (!box) return Number.POSITIVE_INFINITY
return Math.max(
Math.abs(box.x + box.width / 2 - draggedTitlePosition.x),
Math.abs(box.y + box.height / 2 - draggedTitlePosition.y)
)
})
.toBeLessThanOrEqual(2)
}
)
})
41 changes: 41 additions & 0 deletions browser_tests/tests/subgraph/subgraphLinkIdentity.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { expect } from '@playwright/test'

import { toLinkId } from '@/types/linkId'

import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'

test.describe('Subgraph link topology identity', { tag: ['@subgraph'] }, () => {
test('different definitions keep their own reroute chains when link IDs and endpoints collide', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-link-identity-collision'
)

const collidingLinkId = toLinkId(1)
const topology = await comfyPage.page.evaluate((linkId) => {
const graph = window.app!.canvas.graph!
return graph.nodes
.filter((node) => node.isSubgraphNode())
.map((node) => ({
definitionId: node.subgraph.id,
parentId: node.subgraph._links.get(linkId)?.parentId,
rerouteIds: [...node.subgraph.reroutes.keys()]
}))
.sort((a, b) => a.definitionId.localeCompare(b.definitionId))
}, collidingLinkId)

expect(topology).toEqual([
{
definitionId: '11111111-1111-4111-8111-111111111111',
parentId: 101,
rerouteIds: [101]
},
{
definitionId: '22222222-2222-4222-8222-222222222222',
parentId: 201,
rerouteIds: [201]
}
])
})
})
5 changes: 3 additions & 2 deletions browser_tests/tests/vueNodes/rerouteGeometry.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,12 @@ import { toRerouteId } from '@/types/rerouteId'

test.describe('Native reroute geometry', { tag: '@vue-nodes' }, () => {
test('survives subgraph navigation', async ({ comfyPage }) => {
const REROUTE_ID = toRerouteId(1)
await comfyPage.workflow.loadWorkflow(
'reroute/single-native-reroute-default-workflow'
)
await comfyPage.canvasOps.expectRootReroutePositions({
[toRerouteId(1)]: { x: 372.67, y: 415.33 }
[REROUTE_ID]: { x: 372.67, y: 415.33 }
})

const ksampler = await comfyPage.nodeOps.getNodeRefById('3')
Expand All @@ -18,7 +19,7 @@ test.describe('Native reroute geometry', { tag: '@vue-nodes' }, () => {
await comfyPage.subgraph.exitViaBreadcrumb()

await comfyPage.canvasOps.expectRootReroutePositions({
[toRerouteId(1)]: { x: 372.67, y: 415.33 }
[REROUTE_ID]: { x: 372.67, y: 415.33 }
})
})
})
19 changes: 18 additions & 1 deletion docs/adr/0003-crdt-based-layout-system.md
Original file line number Diff line number Diff line change
Expand Up @@ -158,7 +158,24 @@ Entity geometry registers and unregisters with the entity that owns it
(`LGraph.add` / `LGraph.remove`) rather than being seeded per graph on renderer
entry. All three entity types key by `makeScopedLayoutKey(rootGraphId, id)`, so
a root graph's teardown is one `clearGraph`; graphs sharing that bucket drop
their entries individually through `unregisterAllGraphLayout`.
their entries individually through `detachAllGraphLayout`.

### Amendment (2026-08-09)

Layout commands are purely ID-addressed. The `registrationId` ownership
tokens formerly carried by `LayoutOperation` variants, stored in Yjs entity
records, and compared by the store before applying mutations are removed
entirely: create operations are first-wins (a colliding create is a no-op),
updates and deletes apply iff the `(rootGraphId, entityId)` key exists and
the value actually changes, and `rejected` signals only synchronous
reentrancy. Entity IDs are treated as immutable, root-unique primary keys,
which makes the key itself sufficient authority. The only lifecycle metadata
retained is a private, tokenless `{ graphId, id }` attachment descriptor per
live instance (WeakMaps in `graphLayoutAttachment.ts`), captured at attach
time; detach clears the descriptor before issuing the store delete and
restores it only if the delete is rejected. Since tokens never entered
workflow JSON or any persistence, no data migration applies, and replicated
state shrinks by one field per entity.

## Notes

Expand Down
7 changes: 4 additions & 3 deletions src/components/builder/AppModeWidgetList.vue
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import { api } from '@/scripts/api'
import { app } from '@/scripts/app'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { useLinkStore } from '@/stores/linkStore'
import { graphScopeOf } from '@/types/graphScopeId'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import { useAppModeStore } from '@/stores/appModeStore'
import { parseImageWidgetValue } from '@/utils/imageUtil'
Expand Down Expand Up @@ -80,10 +81,10 @@ function ensureSelectedWidgetState(
}

function isWidgetInputLinked(node: LGraphNode, widgetName: string): boolean {
const graphId = node.graph?.rootGraph.id
const graph = node.graph
const slot = node.inputs?.findIndex((i) => i.widget?.name === widgetName)
if (!graphId || slot === undefined || slot < 0) return false
return linkStore.isInputSlotConnected(graphId, node.id, slot)
if (!graph || slot === undefined || slot < 0) return false
return linkStore.isInputSlotConnected(graphScopeOf(graph), node.id, slot)
}

const mappedSelections = computed((): WidgetEntry[] => {
Expand Down
28 changes: 19 additions & 9 deletions src/components/rightSidePanel/parameters/WidgetItem.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import type { INodeInputSlot } from '@/lib/litegraph/src/interfaces'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { useLinkStore } from '@/stores/linkStore'
import { toOwningGraphId, toRootGraphId } from '@/types/graphScopeId'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import { widgetId } from '@/types/widgetId'
import WidgetItem from './WidgetItem.vue'
Expand Down Expand Up @@ -77,7 +78,10 @@ function createMockNode(overrides: Partial<LGraphNode> = {}): LGraphNode {
id: 1,
type: 'TestNode',
isSubgraphNode: () => false,
graph: { rootGraph: { id: 'test-graph-id' } },
graph: {
id: 'test-graph-id',
rootGraph: { id: 'test-graph-id' }
},
...overrides
})
}
Expand Down Expand Up @@ -238,14 +242,20 @@ describe('WidgetItem', () => {
const { container } = renderWidgetItem(widget, node)
expect(getStubWidget(container).options.disabled).toBeUndefined()

useLinkStore().registerLink('test-graph-id', {
id: toLinkId(1),
originNodeId: toNodeId(2),
originSlot: 0,
targetNodeId: node.id,
targetSlot: 0,
type: 'INT'
})
useLinkStore().registerLink(
{
rootGraphId: toRootGraphId('test-graph-id'),
owningGraphId: toOwningGraphId('test-graph-id')
},
{
id: toLinkId(1),
originNodeId: toNodeId(2),
originSlot: 0,
targetNodeId: node.id,
targetSlot: 0,
type: 'INT'
}
)
await nextTick()

expect(getStubWidget(container).options.disabled).toBe(true)
Expand Down
Loading
Loading