From ebb1930212ebc86f33be07ad45b01c8e312e33f7 Mon Sep 17 00:00:00 2001 From: bymyself Date: Sat, 2 May 2026 03:21:39 +0000 Subject: [PATCH] feat(#3410): add centralized assert() utility in src/base/ - src/base/assert.ts: assert(condition, message) with console.error always, throw in DEV, delegate to registered reporter otherwise - setAssertReporter() registration pattern avoids layer architecture violation (base/ cannot import from platform/) - src/main.ts: registers Sentry+toast reporter after Sentry.init() - src/scripts/changeTracker.ts: replaces inline Sentry+warn in reportInactiveTrackerCall() with assert() call Co-Authored-By: Claude Sonnet 4.6 --- src/base/assert.test.ts | 58 ++++ src/base/assert.ts | 31 +++ src/main.ts | 120 +++++---- src/scripts/changeTracker.test.ts | 4 + src/scripts/changeTracker.ts | 422 +++++++++++++++--------------- 5 files changed, 367 insertions(+), 268 deletions(-) create mode 100644 src/base/assert.test.ts create mode 100644 src/base/assert.ts diff --git a/src/base/assert.test.ts b/src/base/assert.test.ts new file mode 100644 index 0000000000..4dc00a6369 --- /dev/null +++ b/src/base/assert.test.ts @@ -0,0 +1,58 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { assert, setAssertReporter } from "@/base/assert"; + +describe("assert", () => { + let consoleErrorSpy: ReturnType; + + beforeEach(() => { + consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + }); + + afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllEnvs(); + setAssertReporter(() => {}); + }); + + it("does nothing when condition is true", () => { + expect(() => assert(true, "should not throw")).not.toThrow(); + expect(consoleErrorSpy).not.toHaveBeenCalled(); + }); + + it("logs console.error when condition is false", () => { + vi.stubEnv("DEV", false); + assert(false, "test message"); + expect(consoleErrorSpy).toHaveBeenCalledWith( + "[Assertion failed]: test message", + ); + }); + + it("throws in DEV mode when condition is false", () => { + vi.stubEnv("DEV", true); + expect(() => assert(false, "dev error")).toThrow( + "[Assertion failed]: dev error", + ); + }); + + it("does not throw in non-DEV mode when condition is false", () => { + vi.stubEnv("DEV", false); + expect(() => assert(false, "non-dev error")).not.toThrow(); + }); + + it("calls registered reporter in non-DEV mode", () => { + vi.stubEnv("DEV", false); + const reporter = vi.fn(); + setAssertReporter(reporter); + assert(false, "reporter message"); + expect(reporter).toHaveBeenCalledWith("reporter message"); + }); + + it("does not call reporter when condition is true", () => { + vi.stubEnv("DEV", false); + const reporter = vi.fn(); + setAssertReporter(reporter); + assert(true, "no call"); + expect(reporter).not.toHaveBeenCalled(); + }); +}); diff --git a/src/base/assert.ts b/src/base/assert.ts new file mode 100644 index 0000000000..6d36bc107a --- /dev/null +++ b/src/base/assert.ts @@ -0,0 +1,31 @@ +type AssertReporter = (message: string) => void; + +let reporter: AssertReporter | null = null; + +/** + * Register a reporter for assertion failures in non-DEV environments. + * Called once at app startup by platform/ or higher layers to wire in + * Sentry, toast notifications, etc. + */ +export function setAssertReporter(fn: AssertReporter): void { + reporter = fn; +} + +/** + * Centralized invariant assertion. + * + * - Always: console.error + * - DEV: throws (surfaces bugs immediately) + * - Otherwise: delegates to registered reporter (Sentry, toast, etc.) + */ +export function assert(condition: boolean, message: string): void { + if (condition) return; + + console.error(`[Assertion failed]: ${message}`); + + if (import.meta.env.DEV) { + throw new Error(`[Assertion failed]: ${message}`); + } + + reporter?.(message); +} diff --git a/src/main.ts b/src/main.ts index a32242468c..9a1456a0b5 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,60 +1,63 @@ -import { definePreset } from '@primevue/themes' -import Aura from '@primevue/themes/aura' -import * as Sentry from '@sentry/vue' -import { initializeApp } from 'firebase/app' -import { createPinia } from 'pinia' -import 'primeicons/primeicons.css' -import PrimeVue from 'primevue/config' -import ConfirmationService from 'primevue/confirmationservice' -import ToastService from 'primevue/toastservice' -import Tooltip from 'primevue/tooltip' -import { createApp } from 'vue' -import { VueFire, VueFireAuth } from 'vuefire' +import { definePreset } from "@primevue/themes"; +import Aura from "@primevue/themes/aura"; +import * as Sentry from "@sentry/vue"; +import { initializeApp } from "firebase/app"; +import { createPinia } from "pinia"; +import "primeicons/primeicons.css"; +import PrimeVue from "primevue/config"; +import ConfirmationService from "primevue/confirmationservice"; +import ToastService from "primevue/toastservice"; +import Tooltip from "primevue/tooltip"; +import { createApp } from "vue"; +import { VueFire, VueFireAuth } from "vuefire"; -import { getFirebaseConfig } from '@/config/firebase' +import { setAssertReporter } from "@/base/assert"; +import { getFirebaseConfig } from "@/config/firebase"; import { configValueOrDefault, - remoteConfig -} from '@/platform/remoteConfig/remoteConfig' -import '@/lib/litegraph/public/css/litegraph.css' -import router from '@/router' -import { useBootstrapStore } from '@/stores/bootstrapStore' + remoteConfig, +} from "@/platform/remoteConfig/remoteConfig"; +import "@/lib/litegraph/public/css/litegraph.css"; +import router from "@/router"; +import { isDesktop, isNightly } from "@/platform/distribution/types"; +import { useToastStore } from "@/platform/updates/common/toastStore"; +import { useBootstrapStore } from "@/stores/bootstrapStore"; -import App from './App.vue' +import App from "./App.vue"; // Intentionally relative import to ensure the CSS is loaded in the right order (after litegraph.css) -import './assets/css/style.css' -import { i18n } from './i18n' +import "./assets/css/style.css"; +import { i18n } from "./i18n"; /** * CRITICAL: Load remote config FIRST for cloud builds to ensure * window.__CONFIG__is available for all modules during initialization */ -const isCloud = __DISTRIBUTION__ === 'cloud' +const isCloud = __DISTRIBUTION__ === "cloud"; if (isCloud) { const { refreshRemoteConfig } = - await import('@/platform/remoteConfig/refreshRemoteConfig') - await refreshRemoteConfig({ useAuth: false }) + await import("@/platform/remoteConfig/refreshRemoteConfig"); + await refreshRemoteConfig({ useAuth: false }); - const { initTelemetry } = await import('@/platform/telemetry/initTelemetry') - await initTelemetry() + const { initTelemetry } = await import("@/platform/telemetry/initTelemetry"); + await initTelemetry(); } const ComfyUIPreset = definePreset(Aura, { semantic: { // @ts-expect-error fixme ts strict error - primary: Aura['primitive'].blue - } -}) + primary: Aura["primitive"].blue, + }, +}); -const firebaseApp = initializeApp(getFirebaseConfig()) +const firebaseApp = initializeApp(getFirebaseConfig()); -const app = createApp(App) -const pinia = createPinia() +const app = createApp(App); +const pinia = createPinia(); const sentryDsn = isCloud - ? configValueOrDefault(remoteConfig.value, 'sentry_dsn', __SENTRY_DSN__) - : __SENTRY_DSN__ + ? configValueOrDefault(remoteConfig.value, "sentry_dsn", __SENTRY_DSN__) + : __SENTRY_DSN__; Sentry.init({ app, @@ -72,32 +75,47 @@ Sentry.init({ // Disable event target wrapping to reduce overhead on high-frequency // DOM events (pointermove, mousemove, wheel). Sentry still captures // errors via window.onerror and unhandledrejection. - Sentry.browserApiErrorsIntegration({ eventTarget: false }) - ] + Sentry.browserApiErrorsIntegration({ eventTarget: false }), + ], } : { integrations: [], autoSessionTracking: false, - defaultIntegrations: false - }) -}) -app.directive('tooltip', Tooltip) + defaultIntegrations: false, + }), +}); +setAssertReporter((message) => { + if (isDesktop) { + Sentry.captureMessage(`[Assertion failed]: ${message}`, { + level: "warning", + }); + } + if (isNightly) { + useToastStore().add({ + severity: "warn", + summary: "Assertion failed", + detail: message, + }); + } +}); + +app.directive("tooltip", Tooltip); app .use(router) .use(PrimeVue, { theme: { preset: ComfyUIPreset, options: { - prefix: 'p', + prefix: "p", cssLayer: { - name: 'primevue', - order: 'theme, base, primevue' + name: "primevue", + order: "theme, base, primevue", }, // This is a workaround for the issue with the dark mode selector // https://github.com/primefaces/primevue/issues/5515 - darkModeSelector: '.dark-theme, :root:has(.dark-theme)' - } - } + darkModeSelector: ".dark-theme, :root:has(.dark-theme)", + }, + }, }) .use(ConfirmationService) .use(ToastService) @@ -105,10 +123,10 @@ app .use(i18n) .use(VueFire, { firebaseApp, - modules: [VueFireAuth()] - }) + modules: [VueFireAuth()], + }); -const bootstrapStore = useBootstrapStore(pinia) -void bootstrapStore.startStoreBootstrap() +const bootstrapStore = useBootstrapStore(pinia); +void bootstrapStore.startStoreBootstrap(); -app.mount('#vue-app') +app.mount("#vue-app"); diff --git a/src/scripts/changeTracker.test.ts b/src/scripts/changeTracker.test.ts index 5513d83915..66a9ed97c1 100644 --- a/src/scripts/changeTracker.test.ts +++ b/src/scripts/changeTracker.test.ts @@ -2,6 +2,10 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema' +vi.mock('@/base/assert', () => ({ + assert: vi.fn() +})) + const mockNodeOutputStore = vi.hoisted(() => ({ snapshotOutputs: vi.fn(() => ({})), restoreOutputs: vi.fn() diff --git a/src/scripts/changeTracker.ts b/src/scripts/changeTracker.ts index 0218a793ae..af2c2f08cb 100644 --- a/src/scripts/changeTracker.ts +++ b/src/scripts/changeTracker.ts @@ -1,58 +1,46 @@ -import * as Sentry from '@sentry/vue' -import _ from 'es-toolkit/compat' +import _ from "es-toolkit/compat"; -import type { CanvasPointerEvent } from '@/lib/litegraph/src/litegraph' -import { LGraphCanvas, LiteGraph } from '@/lib/litegraph/src/litegraph' -import { isDesktop } from '@/platform/distribution/types' -import type { ComfyWorkflow } from '@/platform/workflow/management/stores/workflowStore' -import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore' -import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema' -import type { ExecutedWsMessage } from '@/schemas/apiSchema' -import { useExecutionStore } from '@/stores/executionStore' -import { useNodeOutputStore } from '@/stores/nodeOutputStore' -import { useSubgraphNavigationStore } from '@/stores/subgraphNavigationStore' +import { assert } from "@/base/assert"; +import type { CanvasPointerEvent } from "@/lib/litegraph/src/litegraph"; +import { LGraphCanvas, LiteGraph } from "@/lib/litegraph/src/litegraph"; +import type { ComfyWorkflow } from "@/platform/workflow/management/stores/workflowStore"; +import { useWorkflowStore } from "@/platform/workflow/management/stores/workflowStore"; +import type { ComfyWorkflowJSON } from "@/platform/workflow/validation/schemas/workflowSchema"; +import type { ExecutedWsMessage } from "@/schemas/apiSchema"; +import { useExecutionStore } from "@/stores/executionStore"; +import { useNodeOutputStore } from "@/stores/nodeOutputStore"; +import { useSubgraphNavigationStore } from "@/stores/subgraphNavigationStore"; -import { api } from './api' -import type { ComfyApp } from './app' -import { app } from './app' +import { api } from "./api"; +import type { ComfyApp } from "./app"; +import { app } from "./app"; function clone(obj: T): T { - return JSON.parse(JSON.stringify(obj)) + return JSON.parse(JSON.stringify(obj)); } function isActiveTracker(tracker: ChangeTracker): boolean { - return useWorkflowStore().activeWorkflow?.changeTracker === tracker + return useWorkflowStore().activeWorkflow?.changeTracker === tracker; } -const reportedInactiveCalls = new Set() +const reportedInactiveCalls = new Set(); /** - * Report a ChangeTracker method being called on an inactive tracker — - * a lifecycle violation that usually indicates stale extension state or - * an incorrect call ordering. Reports once per method per workflow per - * session so the signal is not drowned out by hot-path invocations while - * still distinguishing between workflows. + * Report a ChangeTracker method being called on an inactive tracker. + * Deduplicates per method+workflow per session to avoid signal noise on hot paths. */ function reportInactiveTrackerCall(method: string, workflowPath: string) { - const key = `${method}:${workflowPath}` - if (reportedInactiveCalls.has(key)) return - reportedInactiveCalls.add(key) - - console.warn(`${method}() called on inactive tracker for: ${workflowPath}`) - - if (isDesktop) { - Sentry.captureMessage( - `ChangeTracker.${method}() called on inactive tracker`, - { - level: 'warning', - tags: { workflow: workflowPath } - } - ) - } + const key = `${method}:${workflowPath}`; + if (reportedInactiveCalls.has(key)) return; + reportedInactiveCalls.add(key); + assert( + false, + `ChangeTracker.${method}() called on inactive tracker for: ${workflowPath}`, + ); } export class ChangeTracker { - static MAX_HISTORY = 50 + static MAX_HISTORY = 50; /** * Guard flag to prevent captureCanvasState from running during loadGraphData. * Between rootGraph.configure() and afterLoadNewGraph(), the rootGraph @@ -60,25 +48,25 @@ export class ChangeTracker { * the OLD workflow. Any captureCanvasState call in that window would * serialize the wrong graph into the old workflow's activeState, corrupting it. */ - static isLoadingGraph = false + static isLoadingGraph = false; /** * The active state of the workflow. */ - activeState: ComfyWorkflowJSON - undoQueue: ComfyWorkflowJSON[] = [] - redoQueue: ComfyWorkflowJSON[] = [] - changeCount: number = 0 + activeState: ComfyWorkflowJSON; + undoQueue: ComfyWorkflowJSON[] = []; + redoQueue: ComfyWorkflowJSON[] = []; + changeCount: number = 0; /** * Whether the redo/undo restoring is in progress. */ - _restoringState: boolean = false + _restoringState: boolean = false; - ds?: { scale: number; offset: [number, number] } - nodeOutputs?: Record + ds?: { scale: number; offset: [number, number] }; + nodeOutputs?: Record; private subgraphState?: { - navigation: string[] - } + navigation: string[]; + }; constructor( /** @@ -88,9 +76,9 @@ export class ChangeTracker { /** * The initial state of the workflow */ - public initialState: ComfyWorkflowJSON + public initialState: ComfyWorkflowJSON, ) { - this.activeState = initialState + this.activeState = initialState; } /** @@ -98,21 +86,21 @@ export class ChangeTracker { */ reset(state?: ComfyWorkflowJSON) { // Do not reset the state if we are restoring. - if (this._restoringState) return + if (this._restoringState) return; - if (state) this.activeState = clone(state) - this.initialState = clone(this.activeState) + if (state) this.activeState = clone(state); + this.initialState = clone(this.activeState); } store() { this.ds = { scale: app.canvas.ds.scale, - offset: [app.canvas.ds.offset[0], app.canvas.ds.offset[1]] - } - this.nodeOutputs = useNodeOutputStore().snapshotOutputs() - const navigation = useSubgraphNavigationStore().exportState() + offset: [app.canvas.ds.offset[0], app.canvas.ds.offset[1]], + }; + this.nodeOutputs = useNodeOutputStore().snapshotOutputs(); + const navigation = useSubgraphNavigationStore().exportState(); // Always store the navigation state, even if empty (root level) - this.subgraphState = { navigation } + this.subgraphState = { navigation }; } /** @@ -129,11 +117,11 @@ export class ChangeTracker { */ deactivate() { if (!isActiveTracker(this)) { - reportInactiveTrackerCall('deactivate', this.workflow.path) - return + reportInactiveTrackerCall("deactivate", this.workflow.path); + return; } - if (!this._restoringState) this.captureCanvasState() - this.store() + if (!this._restoringState) this.captureCanvasState(); + this.store(); } /** @@ -144,46 +132,46 @@ export class ChangeTracker { * @internal Not part of the public extension API. */ prepareForSave() { - if (isActiveTracker(this)) this.captureCanvasState() + if (isActiveTracker(this)) this.captureCanvasState(); } restore() { if (this.ds) { - app.canvas.ds.scale = this.ds.scale - app.canvas.ds.offset = this.ds.offset + app.canvas.ds.scale = this.ds.scale; + app.canvas.ds.offset = this.ds.offset; } if (this.nodeOutputs) { - useNodeOutputStore().restoreOutputs(this.nodeOutputs) + useNodeOutputStore().restoreOutputs(this.nodeOutputs); } if (this.subgraphState) { - const { navigation } = this.subgraphState - useSubgraphNavigationStore().restoreState(navigation) + const { navigation } = this.subgraphState; + useSubgraphNavigationStore().restoreState(navigation); - const activeId = navigation.at(-1) + const activeId = navigation.at(-1); if (activeId) { // Navigate to the saved subgraph - const subgraph = app.rootGraph.subgraphs.get(activeId) + const subgraph = app.rootGraph.subgraphs.get(activeId); if (subgraph) { - app.canvas.setGraph(subgraph) + app.canvas.setGraph(subgraph); } } else { // Empty navigation array means root level - app.canvas.setGraph(app.rootGraph) + app.canvas.setGraph(app.rootGraph); } } } updateModified() { - api.dispatchCustomEvent('graphChanged', this.activeState) + api.dispatchCustomEvent("graphChanged", this.activeState); // Get the workflow from the store as ChangeTracker is raw object, i.e. // `this.workflow` is not reactive. - const workflow = useWorkflowStore().getWorkflowByPath(this.workflow.path) + const workflow = useWorkflowStore().getWorkflowByPath(this.workflow.path); if (workflow) { workflow.isModified = !ChangeTracker.graphEqual( this.initialState, - this.activeState - ) + this.activeState, + ); } } @@ -193,302 +181,302 @@ export class ChangeTracker { * Calling this on an inactive tracker would capture the wrong graph. */ captureCanvasState() { - const isUndoRedoing = this._restoringState - const isInsideChangeTransaction = this.changeCount > 0 + const isUndoRedoing = this._restoringState; + const isInsideChangeTransaction = this.changeCount > 0; if ( !app.graph || isInsideChangeTransaction || isUndoRedoing || ChangeTracker.isLoadingGraph ) - return + return; if (!isActiveTracker(this)) { - reportInactiveTrackerCall('captureCanvasState', this.workflow.path) - return + reportInactiveTrackerCall("captureCanvasState", this.workflow.path); + return; } - const currentState = clone(app.rootGraph.serialize()) as ComfyWorkflowJSON + const currentState = clone(app.rootGraph.serialize()) as ComfyWorkflowJSON; if (!this.activeState) { - this.activeState = currentState - return + this.activeState = currentState; + return; } if (!ChangeTracker.graphEqual(this.activeState, currentState)) { - this.undoQueue.push(this.activeState) + this.undoQueue.push(this.activeState); if (this.undoQueue.length > ChangeTracker.MAX_HISTORY) { - this.undoQueue.shift() + this.undoQueue.shift(); } - this.activeState = currentState - this.redoQueue.length = 0 - this.updateModified() + this.activeState = currentState; + this.redoQueue.length = 0; + this.updateModified(); } } /** @deprecated Use {@link captureCanvasState} instead. */ checkState() { if (!ChangeTracker._checkStateWarned) { - ChangeTracker._checkStateWarned = true + ChangeTracker._checkStateWarned = true; console.warn( - 'checkState() is deprecated — use captureCanvasState() instead.' - ) + "checkState() is deprecated — use captureCanvasState() instead.", + ); } - this.captureCanvasState() + this.captureCanvasState(); } - private static _checkStateWarned = false + private static _checkStateWarned = false; async updateState(source: ComfyWorkflowJSON[], target: ComfyWorkflowJSON[]) { - const prevState = source.pop() + const prevState = source.pop(); if (prevState) { - target.push(this.activeState) - this._restoringState = true + target.push(this.activeState); + this._restoringState = true; try { await app.loadGraphData(prevState, false, false, this.workflow, { checkForRerouteMigration: false, - silentAssetErrors: true - }) - this.activeState = prevState - this.updateModified() + silentAssetErrors: true, + }); + this.activeState = prevState; + this.updateModified(); } finally { - this._restoringState = false + this._restoringState = false; } } } async undo() { - await this.updateState(this.undoQueue, this.redoQueue) + await this.updateState(this.undoQueue, this.redoQueue); } async redo() { - await this.updateState(this.redoQueue, this.undoQueue) + await this.updateState(this.redoQueue, this.undoQueue); } async undoRedo(e: KeyboardEvent) { if ((e.ctrlKey || e.metaKey) && !e.altKey) { - const key = e.key.toUpperCase() + const key = e.key.toUpperCase(); // Redo: Ctrl + Y, or Ctrl + Shift + Z - if ((key === 'Y' && !e.shiftKey) || (key == 'Z' && e.shiftKey)) { - await this.redo() - return true - } else if (key === 'Z' && !e.shiftKey) { - await this.undo() - return true + if ((key === "Y" && !e.shiftKey) || (key == "Z" && e.shiftKey)) { + await this.redo(); + return true; + } else if (key === "Z" && !e.shiftKey) { + await this.undo(); + return true; } } } beforeChange() { - this.changeCount++ + this.changeCount++; } afterChange() { if (!--this.changeCount) { - this.captureCanvasState() + this.captureCanvasState(); } } static init() { const getCurrentChangeTracker = () => - useWorkflowStore().activeWorkflow?.changeTracker - const captureState = () => getCurrentChangeTracker()?.captureCanvasState() + useWorkflowStore().activeWorkflow?.changeTracker; + const captureState = () => getCurrentChangeTracker()?.captureCanvasState(); - let keyIgnored = false + let keyIgnored = false; window.addEventListener( - 'keydown', + "keydown", (e: KeyboardEvent) => { // Do not trigger on repeat events (Holding down a key) // This can happen when user is holding down "Space" to pan the canvas. - if (e.repeat) return + if (e.repeat) return; // If the mask editor is opened, we don't want to trigger on key events - const comfyApp = app.constructor as typeof ComfyApp - if (comfyApp.maskeditor_is_opended?.()) return + const comfyApp = app.constructor as typeof ComfyApp; + if (comfyApp.maskeditor_is_opended?.()) return; - const activeEl = document.activeElement + const activeEl = document.activeElement; requestAnimationFrame(async () => { - let bindInputEl: Element | null = null + let bindInputEl: Element | null = null; // If we are auto queue in change mode then we do want to trigger on inputs - if (!app.ui.autoQueueEnabled || app.ui.autoQueueMode === 'instant') { + if (!app.ui.autoQueueEnabled || app.ui.autoQueueMode === "instant") { if ( - activeEl?.tagName === 'INPUT' || - (activeEl && 'type' in activeEl && activeEl.type === 'textarea') + activeEl?.tagName === "INPUT" || + (activeEl && "type" in activeEl && activeEl.type === "textarea") ) { // Ignore events on inputs, they have their native history - return + return; } - bindInputEl = activeEl + bindInputEl = activeEl; } keyIgnored = - e.key === 'Control' || - e.key === 'Shift' || - e.key === 'Alt' || - e.key === 'Meta' - if (keyIgnored) return + e.key === "Control" || + e.key === "Shift" || + e.key === "Alt" || + e.key === "Meta"; + if (keyIgnored) return; - const changeTracker = getCurrentChangeTracker() - if (!changeTracker) return + const changeTracker = getCurrentChangeTracker(); + if (!changeTracker) return; // Check if this is a ctrl+z ctrl+y - if (await changeTracker.undoRedo(e)) return + if (await changeTracker.undoRedo(e)) return; // If our active element is some type of input then handle changes after they're done - if (ChangeTracker.bindInput(bindInputEl)) return - changeTracker.captureCanvasState() - }) + if (ChangeTracker.bindInput(bindInputEl)) return; + changeTracker.captureCanvasState(); + }); }, - true - ) + true, + ); - window.addEventListener('keyup', () => { + window.addEventListener("keyup", () => { if (keyIgnored) { - keyIgnored = false - captureState() + keyIgnored = false; + captureState(); } - }) + }); // Handle clicking DOM elements (e.g. widgets) - window.addEventListener('mouseup', () => { - captureState() - }) + window.addEventListener("mouseup", () => { + captureState(); + }); // Handle prompt queue event for dynamic widget changes - api.addEventListener('promptQueued', () => { - captureState() - }) + api.addEventListener("promptQueued", () => { + captureState(); + }); - api.addEventListener('graphCleared', () => { - captureState() - }) + api.addEventListener("graphCleared", () => { + captureState(); + }); // Handle litegraph clicks - const processMouseUp = LGraphCanvas.prototype.processMouseUp + const processMouseUp = LGraphCanvas.prototype.processMouseUp; LGraphCanvas.prototype.processMouseUp = function (e) { - const v = processMouseUp.apply(this, [e]) - captureState() - return v - } + const v = processMouseUp.apply(this, [e]); + captureState(); + return v; + }; // Handle litegraph dialog popup for number/string widgets - const prompt = LGraphCanvas.prototype.prompt + const prompt = LGraphCanvas.prototype.prompt; LGraphCanvas.prototype.prompt = function ( title: string, value: string | number, callback: (v: string) => void, - event: CanvasPointerEvent + event: CanvasPointerEvent, ) { const extendedCallback = (v: string) => { - callback(v) - captureState() - } - return prompt.apply(this, [title, value, extendedCallback, event]) - } + callback(v); + captureState(); + }; + return prompt.apply(this, [title, value, extendedCallback, event]); + }; // Handle litegraph context menu for COMBO widgets - const close = LiteGraph.ContextMenu.prototype.close + const close = LiteGraph.ContextMenu.prototype.close; LiteGraph.ContextMenu.prototype.close = function (e: MouseEvent) { - const v = close.apply(this, [e]) - captureState() - return v - } + const v = close.apply(this, [e]); + captureState(); + return v; + }; // Handle multiple commands as a single transaction - document.addEventListener('litegraph:canvas', (e: Event) => { - const detail = (e as CustomEvent).detail - if (detail.subType === 'before-change') { - getCurrentChangeTracker()?.beforeChange() - } else if (detail.subType === 'after-change') { - getCurrentChangeTracker()?.afterChange() + document.addEventListener("litegraph:canvas", (e: Event) => { + const detail = (e as CustomEvent).detail; + if (detail.subType === "before-change") { + getCurrentChangeTracker()?.beforeChange(); + } else if (detail.subType === "after-change") { + getCurrentChangeTracker()?.afterChange(); } - }) + }); // Store node outputs - api.addEventListener('executed', (e: CustomEvent) => { - const detail = e.detail + api.addEventListener("executed", (e: CustomEvent) => { + const detail = e.detail; const workflow = - useExecutionStore().queuedJobs[detail.prompt_id]?.workflow - const changeTracker = workflow?.changeTracker - if (!changeTracker) return - changeTracker.nodeOutputs ??= {} - const nodeOutputs = changeTracker.nodeOutputs - const output = nodeOutputs[detail.node] + useExecutionStore().queuedJobs[detail.prompt_id]?.workflow; + const changeTracker = workflow?.changeTracker; + if (!changeTracker) return; + changeTracker.nodeOutputs ??= {}; + const nodeOutputs = changeTracker.nodeOutputs; + const output = nodeOutputs[detail.node]; if (detail.merge && output) { for (const k in detail.output ?? {}) { - const v = output[k] + const v = output[k]; if (v instanceof Array) { - output[k] = v.concat(detail.output[k]) + output[k] = v.concat(detail.output[k]); } else { - output[k] = detail.output[k] + output[k] = detail.output[k]; } } } else { - nodeOutputs[detail.node] = detail.output + nodeOutputs[detail.node] = detail.output; } - }) + }); } static bindInput(activeEl: Element | null): boolean { if ( !activeEl || - activeEl.tagName === 'CANVAS' || - activeEl.tagName === 'BODY' + activeEl.tagName === "CANVAS" || + activeEl.tagName === "BODY" ) { - return false + return false; } - for (const evt of ['change', 'input', 'blur']) { - const htmlElement = activeEl as HTMLElement + for (const evt of ["change", "input", "blur"]) { + const htmlElement = activeEl as HTMLElement; if (`on${evt}` in htmlElement) { const listener = () => { - useWorkflowStore().activeWorkflow?.changeTracker?.captureCanvasState?.() - htmlElement.removeEventListener(evt, listener) - } - htmlElement.addEventListener(evt, listener) - return true + useWorkflowStore().activeWorkflow?.changeTracker?.captureCanvasState?.(); + htmlElement.removeEventListener(evt, listener); + }; + htmlElement.addEventListener(evt, listener); + return true; } } - return false + return false; } static graphEqual(a: ComfyWorkflowJSON, b: ComfyWorkflowJSON) { - if (a === b) return true + if (a === b) return true; - if (typeof a == 'object' && a && typeof b == 'object' && b) { + if (typeof a == "object" && a && typeof b == "object" && b) { // Compare nodes ignoring order if ( !_.isEqualWith(a.nodes, b.nodes, (arrA, arrB) => { if (Array.isArray(arrA) && Array.isArray(arrB)) { - return _.isEqual(new Set(arrA), new Set(arrB)) + return _.isEqual(new Set(arrA), new Set(arrB)); } }) ) { - return false + return false; } // Compare extra properties ignoring ds if ( - !_.isEqual(_.omit(a.extra ?? {}, ['ds']), _.omit(b.extra ?? {}, ['ds'])) + !_.isEqual(_.omit(a.extra ?? {}, ["ds"]), _.omit(b.extra ?? {}, ["ds"])) ) - return false + return false; // Compare other properties normally for (const key of [ - 'links', - 'floatingLinks', - 'reroutes', - 'groups', - 'definitions', - 'subgraphs' + "links", + "floatingLinks", + "reroutes", + "groups", + "definitions", + "subgraphs", ]) { if (!_.isEqual(a[key], b[key])) { - return false + return false; } } - return true + return true; } - return false + return false; } }