Skip to content
Closed
Show file tree
Hide file tree
Changes from 31 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
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)
}
)
})
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 }
})
})
})
5 changes: 5 additions & 0 deletions src/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,11 @@

- User-friendly and actionable messages
- Proper error propagation
- Expected recoverable failures in mutation, lifecycle, workflow-loading, and
deserialization paths must preserve established result or sentinel contracts
and log enough context for diagnosis instead of throwing. Throws remain
appropriate for programmer errors, unrecoverable invariant violations, and
invalid input at trust boundaries when safe recovery is unavailable.

## Security

Expand Down
44 changes: 44 additions & 0 deletions src/components/rightSidePanel/RightSidePanel.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { render } from '@testing-library/vue'
import { createTestingPinia } from '@pinia/testing'
import { describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'

import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'

import RightSidePanel from './RightSidePanel.vue'

vi.mock('@/composables/graph/useGraphHierarchy', () => ({
useGraphHierarchy: () => ({ findParentGroup: vi.fn() })
}))

const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: {} }
})

describe('RightSidePanel', () => {
it('cancels a queued fallback when unmounted', async () => {
const pinia = createTestingPinia({
createSpy: vi.fn,
initialState: { rightSidePanel: { activeTab: 'info' } }
})
const { unmount } = render(RightSidePanel, {
global: {
plugins: [pinia, i18n],
stubs: {
EditableText: true,
Tab: true,
TabList: true,
Button: true
}
}
})
const rightSidePanelStore = useRightSidePanelStore()

unmount()
await Promise.resolve()

expect(rightSidePanelStore.openPanel).not.toHaveBeenCalled()
})
})
27 changes: 19 additions & 8 deletions src/components/rightSidePanel/RightSidePanel.vue
Original file line number Diff line number Diff line change
Expand Up @@ -223,14 +223,25 @@ const tabs = computed<RightSidePanelTabList>(() => {
return list
})

// Use global state for activeTab and ensure it's valid
watchEffect(() => {
if (
!tabs.value.some((tab) => tab.value === activeTab.value) &&
!(activeTab.value === 'subgraph' && isSingleSubgraphNode.value)
) {
rightSidePanelStore.openPanel(tabs.value[0].value)
}
function isActiveTabAvailable() {
return (
tabs.value.some((tab) => tab.value === activeTab.value) ||
(activeTab.value === 'subgraph' && isSingleSubgraphNode.value)
)
}

// Use global state for activeTab and ensure it's valid. Deferred scans
// (e.g. pasted-node missing-model detection) can restore the active tab
// within the same tick, so re-check before falling back.
watchEffect((onCleanup) => {
if (isActiveTabAvailable()) return
let cancelled = false
onCleanup(() => (cancelled = true))
queueMicrotask(() => {

@benjcooley benjcooley Aug 7, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SHOULD FIX — unrelated to this PR.

A right-side-panel tab fix has nothing to do with CRDT layout ownership and ships with no test. Please split it out.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The change remains in this PR, but the lifecycle defect is fixed in 5ad7002: effect cleanup cancels obsolete fallback microtasks, and a focused Testing Library test verifies no post-unmount openPanel call. This card now has proper protection.

if (!cancelled && !isActiveTabAvailable()) {
rightSidePanelStore.openPanel(tabs.value[0].value)
}
})
})

function resolveTitle() {
Expand Down
20 changes: 19 additions & 1 deletion src/components/rightSidePanel/errors/ErrorGroupList.vue
Original file line number Diff line number Diff line change
Expand Up @@ -326,6 +326,7 @@ import { cn } from '@comfyorg/tailwind-utils'

import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
import { useFocusNode } from '@/composables/canvas/useFocusNode'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { useTelemetry } from '@/platform/telemetry'
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
import { useManagerState } from '@/workbench/extensions/manager/composables/useManagerState'
Expand Down Expand Up @@ -353,6 +354,7 @@ import type { ErrorGroup } from './types'
import { isExecutionItemListGroup } from './executionItemList'
import { selectionEmphasisClass } from './selectionEmphasis'
import { useNodeReplacement } from '@/platform/nodeReplacement/useNodeReplacement'
import { transferNodeLayoutRegistration } from '@/renderer/core/layout/operations/graphLayoutRegistration'

interface ExecutionItemListEntry {
key: string
Expand All @@ -372,7 +374,23 @@ const { shouldShowManagerButtons, shouldShowInstallButton, openManager } =
const { missingNodePacks } = useMissingNodes()
const { isInstalling: isInstallingAll, installAllPacks: installAll } =
usePackInstall(() => missingNodePacks.value)
const { replaceGroup, replaceAllGroups } = useNodeReplacement()

function transferReplacementLayout(
node: LGraphNode,
replacement: LGraphNode
): void {
const result = transferNodeLayoutRegistration(node, replacement)
if (result === 'applied') return
console.error('Failed to transfer node layout registration', {
graphId: node.graph?.rootGraph.id,
nodeId: replacement.id,
result
})
}

const { replaceGroup, replaceAllGroups } = useNodeReplacement(
transferReplacementLayout
)

const searchQuery = ref('')
const expandedExecutionItemDetailKeys = ref(new Set<string>())
Expand Down
8 changes: 7 additions & 1 deletion src/lib/litegraph/src/LGraph.serialise.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe } from 'vitest'
import { beforeEach, describe, onTestFinished } from 'vitest'

import {
LGraph,
Expand Down Expand Up @@ -30,11 +30,15 @@ describe('LGraph Serialisation', () => {
expect(minimalGraph.groups[0].title).toEqual(groupTitle)

const serialised = JSON.stringify(minimalGraph.serialize())
minimalGraph.clear()
const deserialised = JSON.parse(serialised) as ISerialisedGraph

const copied = new LGraph(deserialised)
onTestFinished(() => copied.clear())
expect(copied.nodes.length).toBe(1)
expect(copied.nodes[0].title).toEqual(nodeTitle)
expect(copied.groups.length).toBe(1)
expect(copied.groups[0].title).toEqual(groupTitle)
})

test('registers connected links after a JSON round trip', ({ expect }) => {
Expand All @@ -48,6 +52,7 @@ describe('LGraph Serialisation', () => {

LiteGraph.registerNodeType('test/connected', ConnectedNode)
const graph = new LGraph()
onTestFinished(() => graph.clear())
const source = LiteGraph.createNode('test/connected', 'Source')!
const target = LiteGraph.createNode('test/connected', 'Target')!
graph.add(source)
Expand All @@ -65,6 +70,7 @@ describe('LGraph Serialisation', () => {
graph.clear()

const copied = new LGraph(JSON.parse(serialised) as ISerialisedGraph)
onTestFinished(() => copied.clear())
const copiedLink = useLinkStore().getInputSlotLink(
copied.rootGraph.id,
expectedLink.targetNodeId,
Expand Down
Loading
Loading