Skip to content

Commit f8ec87d

Browse files
authored
Fix changeTracker modified state (#1481)
* Add jsondiffpatch * Add logs * Add graphDiff helper * Fix changeTracker * Add loglevel * Add playwright test * Fix jest test * nit * nit * Fix test url * nit
1 parent c12f059 commit f8ec87d

File tree

8 files changed

+171
-11
lines changed

8 files changed

+171
-11
lines changed

browser_tests/changeTracker.spec.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,43 @@ async function afterChange(comfyPage: ComfyPage) {
1616
}
1717

1818
test.describe('Change Tracker', () => {
19+
test.describe('Undo/Redo', () => {
20+
test.beforeEach(async ({ comfyPage }) => {
21+
await comfyPage.setSetting('Comfy.UseNewMenu', 'Top')
22+
})
23+
24+
// Flaky https://github.com/Comfy-Org/ComfyUI_frontend/pull/1481
25+
// The collapse can be recognized as several changes.
26+
test.skip('Can undo multiple operations', async ({ comfyPage }) => {
27+
function isModified() {
28+
return comfyPage.page.evaluate(async () => {
29+
return window['app'].extensionManager.workflow.activeWorkflow
30+
.isModified
31+
})
32+
}
33+
34+
await comfyPage.menu.topbar.saveWorkflow('undo-redo-test')
35+
expect(await isModified()).toBe(false)
36+
37+
const node = (await comfyPage.getFirstNodeRef())!
38+
await node.click('collapse')
39+
await expect(node).toBeCollapsed()
40+
expect(await isModified()).toBe(true)
41+
42+
await comfyPage.ctrlB()
43+
await expect(node).toBeBypassed()
44+
expect(await isModified()).toBe(true)
45+
46+
await comfyPage.ctrlZ()
47+
await expect(node).not.toBeBypassed()
48+
expect(await isModified()).toBe(true)
49+
50+
await comfyPage.ctrlZ()
51+
await expect(node).not.toBeCollapsed()
52+
expect(await isModified()).toBe(false)
53+
})
54+
})
55+
1956
test('Can group multiple change actions into a single transaction', async ({
2057
comfyPage
2158
}) => {

browser_tests/fixtures/ComfyPage.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -167,7 +167,7 @@ export class ComfyPage {
167167
}
168168

169169
async setupUser(username: string) {
170-
const res = await this.request.get(`${this.url}/users`)
170+
const res = await this.request.get(`${this.url}/api/users`)
171171
if (res.status() !== 200)
172172
throw new Error(`Failed to retrieve users: ${await res.text()}`)
173173

@@ -181,7 +181,7 @@ export class ComfyPage {
181181
}
182182

183183
async createUser(username: string) {
184-
const resp = await this.request.post(`${this.url}/users`, {
184+
const resp = await this.request.post(`${this.url}/api/users`, {
185185
data: { username }
186186
})
187187

package-lock.json

Lines changed: 44 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,9 @@
7979
"axios": "^1.7.4",
8080
"dotenv": "^16.4.5",
8181
"fuse.js": "^7.0.0",
82+
"jsondiffpatch": "^0.6.0",
8283
"lodash": "^4.17.21",
84+
"loglevel": "^1.9.2",
8385
"pinia": "^2.1.7",
8486
"primeicons": "^7.0.0",
8587
"primevue": "^4.0.5",

src/scripts/app.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2363,7 +2363,7 @@ export class ComfyApp {
23632363
}
23642364
await this.#invokeExtensionsAsync('afterConfigureGraph', missingNodeTypes)
23652365
// @ts-expect-error zod types issue. Will be fixed after we enable ts-strict
2366-
workflowService.afterLoadNewGraph(workflow, this.graph.serialize())
2366+
await workflowService.afterLoadNewGraph(workflow, this.graph.serialize())
23672367
requestAnimationFrame(() => {
23682368
this.graph.setDirtyCanvas(true, true)
23692369
})

src/scripts/changeTracker.ts

Lines changed: 73 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ import type { ComfyWorkflowJSON } from '@/types/comfyWorkflow'
77
import type { ExecutedWsMessage } from '@/types/apiTypes'
88
import { useExecutionStore } from '@/stores/executionStore'
99
import _ from 'lodash'
10+
import * as jsondiffpatch from 'jsondiffpatch'
11+
import log from 'loglevel'
1012

1113
function clone(obj: any) {
1214
try {
@@ -20,6 +22,10 @@ function clone(obj: any) {
2022
return JSON.parse(JSON.stringify(obj))
2123
}
2224

25+
const logger = log.getLogger('ChangeTracker')
26+
// Change to debug for more verbose logging
27+
logger.setLevel('info')
28+
2329
export class ChangeTracker {
2430
static MAX_HISTORY = 50
2531
/**
@@ -29,6 +35,10 @@ export class ChangeTracker {
2935
undoQueue: ComfyWorkflowJSON[] = []
3036
redoQueue: ComfyWorkflowJSON[] = []
3137
changeCount: number = 0
38+
/**
39+
* Whether the redo/undo restoring is in progress.
40+
*/
41+
private restoringState: boolean = false
3242

3343
ds?: { scale: number; offset: [number, number] }
3444
nodeOutputs?: Record<string, any>
@@ -55,8 +65,12 @@ export class ChangeTracker {
5565
* Save the current state as the initial state.
5666
*/
5767
reset(state?: ComfyWorkflowJSON) {
68+
// Do not reset the state if we are restoring.
69+
if (this.restoringState) return
70+
71+
logger.debug('Reset State')
5872
this.activeState = state ?? this.activeState
59-
this.initialState = this.activeState
73+
this.initialState = clone(this.activeState)
6074
}
6175

6276
store() {
@@ -85,6 +99,13 @@ export class ChangeTracker {
8599
this.initialState,
86100
this.activeState
87101
)
102+
if (workflow.isModified) {
103+
const diff = ChangeTracker.graphDiff(
104+
this.initialState,
105+
this.activeState
106+
)
107+
logger.debug('Graph diff:', diff)
108+
}
88109
}
89110
}
90111

@@ -101,6 +122,8 @@ export class ChangeTracker {
101122
if (this.undoQueue.length > ChangeTracker.MAX_HISTORY) {
102123
this.undoQueue.shift()
103124
}
125+
logger.debug('Diff detected. Undo queue length:', this.undoQueue.length)
126+
104127
this.activeState = clone(currentState)
105128
this.redoQueue.length = 0
106129
api.dispatchEvent(
@@ -114,21 +137,38 @@ export class ChangeTracker {
114137
const prevState = source.pop()
115138
if (prevState) {
116139
target.push(this.activeState!)
117-
await this.app.loadGraphData(prevState, false, false, this.workflow, {
118-
showMissingModelsDialog: false,
119-
showMissingNodesDialog: false
120-
})
121-
this.activeState = prevState
122-
this.updateModified()
140+
this.restoringState = true
141+
try {
142+
await this.app.loadGraphData(prevState, false, false, this.workflow, {
143+
showMissingModelsDialog: false,
144+
showMissingNodesDialog: false
145+
})
146+
this.activeState = prevState
147+
this.updateModified()
148+
} finally {
149+
this.restoringState = false
150+
}
123151
}
124152
}
125153

126154
async undo() {
127155
await this.updateState(this.undoQueue, this.redoQueue)
156+
logger.debug(
157+
'Undo. Undo queue length:',
158+
this.undoQueue.length,
159+
'Redo queue length:',
160+
this.redoQueue.length
161+
)
128162
}
129163

130164
async redo() {
131165
await this.updateState(this.redoQueue, this.undoQueue)
166+
logger.debug(
167+
'Redo. Undo queue length:',
168+
this.undoQueue.length,
169+
'Redo queue length:',
170+
this.redoQueue.length
171+
)
132172
}
133173

134174
async undoRedo(e: KeyboardEvent) {
@@ -198,6 +238,7 @@ export class ChangeTracker {
198238

199239
// If our active element is some type of input then handle changes after they're done
200240
if (ChangeTracker.bindInput(app, bindInputEl)) return
241+
logger.debug('checkState on keydown')
201242
changeTracker.checkState()
202243
})
203244
},
@@ -207,34 +248,40 @@ export class ChangeTracker {
207248
window.addEventListener('keyup', (e) => {
208249
if (keyIgnored) {
209250
keyIgnored = false
251+
logger.debug('checkState on keyup')
210252
checkState()
211253
}
212254
})
213255

214256
// Handle clicking DOM elements (e.g. widgets)
215257
window.addEventListener('mouseup', () => {
258+
logger.debug('checkState on mouseup')
216259
checkState()
217260
})
218261

219262
// Handle prompt queue event for dynamic widget changes
220263
api.addEventListener('promptQueued', () => {
264+
logger.debug('checkState on promptQueued')
221265
checkState()
222266
})
223267

224268
api.addEventListener('graphCleared', () => {
269+
logger.debug('checkState on graphCleared')
225270
checkState()
226271
})
227272

228273
// Handle litegraph clicks
229274
const processMouseUp = LGraphCanvas.prototype.processMouseUp
230275
LGraphCanvas.prototype.processMouseUp = function (e) {
231276
const v = processMouseUp.apply(this, [e])
277+
logger.debug('checkState on processMouseUp')
232278
checkState()
233279
return v
234280
}
235281
const processMouseDown = LGraphCanvas.prototype.processMouseDown
236282
LGraphCanvas.prototype.processMouseDown = function (e) {
237283
const v = processMouseDown.apply(this, [e])
284+
logger.debug('checkState on processMouseDown')
238285
checkState()
239286
return v
240287
}
@@ -251,13 +298,15 @@ export class ChangeTracker {
251298
callback(v)
252299
checkState()
253300
}
301+
logger.debug('checkState on prompt')
254302
return prompt.apply(this, [title, value, extendedCallback, event])
255303
}
256304

257305
// Handle litegraph context menu for COMBO widgets
258306
const close = LiteGraph.ContextMenu.prototype.close
259307
LiteGraph.ContextMenu.prototype.close = function (e: MouseEvent) {
260308
const v = close.apply(this, [e])
309+
logger.debug('checkState on contextMenuClose')
261310
checkState()
262311
return v
263312
}
@@ -267,6 +316,7 @@ export class ChangeTracker {
267316
LiteGraph.LGraph.prototype.onNodeAdded = function (node: LGraphNode) {
268317
const v = onNodeAdded?.apply(this, [node])
269318
if (!app?.configuringGraph) {
319+
logger.debug('checkState on onNodeAdded')
270320
checkState()
271321
}
272322
return v
@@ -357,4 +407,20 @@ export class ChangeTracker {
357407

358408
return false
359409
}
410+
411+
static graphDiff(a: ComfyWorkflowJSON, b: ComfyWorkflowJSON) {
412+
function sortGraphNodes(graph: ComfyWorkflowJSON) {
413+
return {
414+
links: graph.links,
415+
groups: graph.groups,
416+
nodes: graph.nodes.sort((a, b) => {
417+
if (typeof a.id === 'number' && typeof b.id === 'number') {
418+
return a.id - b.id
419+
}
420+
return 0
421+
})
422+
}
423+
}
424+
return jsondiffpatch.diff(sortGraphNodes(a), sortGraphNodes(b))
425+
}
360426
}

tests-ui/tests/fast/globalSetup.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,4 +19,10 @@ module.exports = async function () {
1919
useI18n: jest.fn()
2020
}
2121
})
22+
23+
jest.mock('jsondiffpatch', () => {
24+
return {
25+
diff: jest.fn()
26+
}
27+
})
2228
}

tests-ui/tests/globalSetup.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,4 +74,10 @@ module.exports = async function () {
7474
useI18n: jest.fn()
7575
}
7676
})
77+
78+
jest.mock('jsondiffpatch', () => {
79+
return {
80+
diff: jest.fn()
81+
}
82+
})
7783
}

0 commit comments

Comments
 (0)