Feat: Persist all unsaved workflow tabs (#6050)

## Summary

- Keep all drafts in localStorage, mirroring the logic from VSCode.

- Fix a bug where newly created blank workflow tabs would incorrectly
restore as defaultGraph instead of blankGraph after page refresh.

Resolves https://github.com/Comfy-Org/desktop/issues/910, Resolves
https://github.com/Comfy-Org/ComfyUI_frontend/issues/4057, Fixes
https://github.com/Comfy-Org/ComfyUI_frontend/issues/3665

## Changes

### What
- Fix `restoreWorkflowTabsState` to parse and pass workflow data from
drafts when recreating temporary workflows
- Add error handling for invalid draft data with fallback to default
workflow
- Fix E2E test `should not serialize color adjustments in workflow` to
wait for workflow persistence before assertions
- Add proper validation for workflow nodes array in test assertions

### Breaking
- None

### Dependencies
- No new dependencies added

## Review Focus

1. **Workflow restoration**: Verify that blank workflows correctly
restore as blankGraph after page refresh
2. **Error handling**: Check that invalid draft data gracefully falls
back to default workflow
3. **Test coverage**: Ensure E2E test correctly waits for workflow
persistence before checking node properties
4. **Edge cases**: Test with multiple tabs, switching between tabs, and
rapid refresh scenarios

---------

Co-authored-by: Yourz <crazilou@vip.qq.com>
This commit is contained in:
Christian Byrne
2026-01-26 09:35:38 -08:00
committed by GitHub
parent 29220f6562
commit d3e664b2dd
10 changed files with 826 additions and 55 deletions

View File

@@ -232,11 +232,25 @@ test.describe('Node Color Adjustments', () => {
}) => {
await comfyPage.setSetting('Comfy.Node.Opacity', 0.5)
await comfyPage.setSetting('Comfy.ColorPalette', 'light')
const saveWorkflowInterval = 1000
const workflow = await comfyPage.page.evaluate(() => {
return localStorage.getItem('workflow')
})
for (const node of JSON.parse(workflow ?? '{}').nodes) {
await comfyPage.nextFrame()
const parsed = await (
await comfyPage.page.waitForFunction(
() => {
const workflow = localStorage.getItem('workflow')
if (!workflow) return null
try {
const data = JSON.parse(workflow)
return Array.isArray(data?.nodes) ? data : null
} catch {
return null
}
},
{ timeout: 3000 }
)
).jsonValue()
expect(parsed.nodes).toBeDefined()
expect(Array.isArray(parsed.nodes)).toBe(true)
for (const node of parsed.nodes) {
if (node.bgcolor) expect(node.bgcolor).not.toMatch(/hsla/)
if (node.color) expect(node.color).not.toMatch(/hsla/)
}

View File

@@ -1749,6 +1749,7 @@
"nothingToQueue": "Nothing to queue",
"pleaseSelectOutputNodes": "Please select output nodes",
"failedToQueue": "Failed to queue",
"failedToSaveDraft": "Failed to save workflow draft",
"failedExecutionPathResolution": "Could not resolve path to selected nodes",
"no3dScene": "No 3D scene to apply texture",
"failedToApplyTexture": "Failed to apply texture",

View File

@@ -6,6 +6,7 @@ import { LGraph, LGraphCanvas } from '@/lib/litegraph/src/litegraph'
import type { Point, SerialisableGraph } from '@/lib/litegraph/src/litegraph'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useToastStore } from '@/platform/updates/common/toastStore'
import { useWorkflowDraftStore } from '@/platform/workflow/persistence/stores/workflowDraftStore'
import {
ComfyWorkflow,
useWorkflowStore
@@ -28,6 +29,7 @@ export const useWorkflowService = () => {
const dialogService = useDialogService()
const workflowThumbnail = useWorkflowThumbnail()
const domWidgetStore = useDomWidgetStore()
const workflowDraftStore = useWorkflowDraftStore()
async function getFilename(defaultName: string): Promise<string | null> {
if (settingStore.get('Comfy.PromptFilename')) {
@@ -291,6 +293,27 @@ export const useWorkflowService = () => {
const activeWorkflow = workflowStore.activeWorkflow
if (activeWorkflow) {
activeWorkflow.changeTracker.store()
if (settingStore.get('Comfy.Workflow.Persist') && activeWorkflow.path) {
const activeState = activeWorkflow.activeState
if (activeState) {
try {
const workflowJson = JSON.stringify(activeState)
workflowDraftStore.saveDraft(activeWorkflow.path, {
data: workflowJson,
updatedAt: Date.now(),
name: activeWorkflow.key,
isTemporary: activeWorkflow.isTemporary
})
} catch {
toastStore.add({
severity: 'error',
summary: t('g.error'),
detail: t('toastMessages.failedToSaveDraft'),
life: 3000
})
}
}
}
// Capture thumbnail before loading new graph
void workflowThumbnail.storeThumbnail(activeWorkflow)
domWidgetStore.clear()

View File

@@ -11,6 +11,7 @@ import {
useWorkflowBookmarkStore,
useWorkflowStore
} from '@/platform/workflow/management/stores/workflowStore'
import { useWorkflowDraftStore } from '@/platform/workflow/persistence/stores/workflowDraftStore'
import { api } from '@/scripts/api'
import { app as comfyApp } from '@/scripts/app'
import { defaultGraph, defaultGraphJSON } from '@/scripts/defaultGraph'
@@ -66,6 +67,9 @@ describe('useWorkflowStore', () => {
store = useWorkflowStore()
bookmarkStore = useWorkflowBookmarkStore()
vi.clearAllMocks()
localStorage.clear()
sessionStorage.clear()
useWorkflowDraftStore().reset()
// Add default mock implementations
vi.mocked(api.getUserData).mockResolvedValue({
@@ -235,6 +239,60 @@ describe('useWorkflowStore', () => {
expect(workflow.isModified).toBe(false)
})
it('prefers local draft snapshots when available', async () => {
localStorage.clear()
await syncRemoteWorkflows(['a.json'])
const workflow = store.getWorkflowByPath('workflows/a.json')!
const draftGraph = {
...defaultGraph,
nodes: [...defaultGraph.nodes]
}
useWorkflowDraftStore().saveDraft(workflow.path, {
data: JSON.stringify(draftGraph),
updatedAt: Date.now(),
name: workflow.key,
isTemporary: workflow.isTemporary
})
vi.mocked(api.getUserData).mockResolvedValue({
status: 200,
text: () => Promise.resolve(defaultGraphJSON)
} as Response)
await workflow.load()
expect(workflow.isModified).toBe(true)
expect(workflow.changeTracker?.activeState).toEqual(draftGraph)
})
it('ignores stale drafts when server version is newer', async () => {
await syncRemoteWorkflows(['a.json'])
const workflow = store.getWorkflowByPath('workflows/a.json')!
const draftStore = useWorkflowDraftStore()
const draftSnapshot = {
data: JSON.stringify(defaultGraph),
updatedAt: Date.now(),
name: workflow.key,
isTemporary: workflow.isTemporary
}
draftStore.saveDraft(workflow.path, draftSnapshot)
workflow.lastModified = draftSnapshot.updatedAt + 1000
vi.mocked(api.getUserData).mockResolvedValue({
status: 200,
text: () => Promise.resolve(defaultGraphJSON)
} as Response)
await workflow.load()
expect(workflow.isModified).toBe(false)
expect(draftStore.getDraft(workflow.path)).toBeUndefined()
})
it('should load and open a remote workflow', async () => {
await syncRemoteWorkflows(['a.json', 'b.json'])
@@ -413,6 +471,20 @@ describe('useWorkflowStore', () => {
expect(store.isOpen(workflow)).toBe(false)
expect(store.getWorkflowByPath(workflow.path)).toBeNull()
})
it('should remove draft when closing temporary workflow', async () => {
const workflow = store.createTemporary('test.json')
const draftStore = useWorkflowDraftStore()
draftStore.saveDraft(workflow.path, {
data: defaultGraphJSON,
updatedAt: Date.now(),
name: workflow.key,
isTemporary: true
})
expect(draftStore.getDraft(workflow.path)).toBeDefined()
await store.closeWorkflow(workflow)
expect(draftStore.getDraft(workflow.path)).toBeUndefined()
})
})
describe('deleteWorkflow', () => {

View File

@@ -13,6 +13,7 @@ import type {
ComfyWorkflowJSON,
NodeId
} from '@/platform/workflow/validation/schemas/workflowSchema'
import { useWorkflowDraftStore } from '@/platform/workflow/persistence/stores/workflowDraftStore'
import { useWorkflowThumbnail } from '@/renderer/core/thumbnail/useWorkflowThumbnail'
import { api } from '@/scripts/api'
import { app as comfyApp } from '@/scripts/app'
@@ -86,6 +87,28 @@ export class ComfyWorkflow extends UserFile {
override async load({ force = false }: { force?: boolean } = {}): Promise<
this & LoadedComfyWorkflow
> {
const draftStore = useWorkflowDraftStore()
let draft = !force ? draftStore.getDraft(this.path) : undefined
let draftState: ComfyWorkflowJSON | null = null
let draftContent: string | null = null
if (draft) {
if (draft.updatedAt < this.lastModified) {
draftStore.removeDraft(this.path)
draft = undefined
}
}
if (draft) {
try {
draftState = JSON.parse(draft.data)
draftContent = draft.data
} catch (err) {
console.warn('Failed to parse workflow draft, clearing it', err)
draftStore.removeDraft(this.path)
}
}
await super.load({ force })
if (!force && this.isLoaded) return this as this & LoadedComfyWorkflow
@@ -93,13 +116,14 @@ export class ComfyWorkflow extends UserFile {
throw new Error('[ASSERT] Workflow content should be loaded')
}
// Note: originalContent is populated by super.load()
this.changeTracker = markRaw(
new ChangeTracker(
this,
/* initialState= */ JSON.parse(this.originalContent)
)
)
const initialState = JSON.parse(this.originalContent)
this.changeTracker = markRaw(new ChangeTracker(this, initialState))
if (draftState && draftContent) {
this.changeTracker.activeState = draftState
this.content = draftContent
this._isModified = true
draftStore.markDraftUsed(this.path)
}
return this as this & LoadedComfyWorkflow
}
@@ -109,12 +133,14 @@ export class ComfyWorkflow extends UserFile {
}
override async save() {
const draftStore = useWorkflowDraftStore()
this.content = JSON.stringify(this.activeState)
// Force save to ensure the content is updated in remote storage incase
// the isModified state is screwed by changeTracker.
const ret = await super.save({ force: true })
this.changeTracker?.reset()
this.isModified = false
draftStore.removeDraft(this.path)
return ret
}
@@ -124,8 +150,11 @@ export class ComfyWorkflow extends UserFile {
* @returns this
*/
override async saveAs(path: string) {
const draftStore = useWorkflowDraftStore()
this.content = JSON.stringify(this.activeState)
return await super.saveAs(path)
const result = await super.saveAs(path)
draftStore.removeDraft(path)
return result
}
async promptSave(): Promise<string | null> {
@@ -436,6 +465,8 @@ export const useWorkflowStore = defineStore('workflow', () => {
if (workflow.isTemporary) {
// Clear thumbnail when temporary workflow is closed
clearThumbnail(workflow.key)
// Clear draft when unsaved workflow tab is closed
useWorkflowDraftStore().removeDraft(workflow.path)
delete workflowLookup.value[workflow.path]
} else {
workflow.unload()
@@ -565,6 +596,7 @@ export const useWorkflowStore = defineStore('workflow', () => {
const oldPath = workflow.path
const oldKey = workflow.key
const wasBookmarked = bookmarkStore.isBookmarked(oldPath)
const draftStore = useWorkflowDraftStore()
const openIndex = detachWorkflow(workflow)
// Perform the actual rename operation first
@@ -574,6 +606,8 @@ export const useWorkflowStore = defineStore('workflow', () => {
attachWorkflow(workflow, openIndex)
}
draftStore.moveDraft(oldPath, newPath, workflow.key)
// Move thumbnail from old key to new key (using workflow keys, not full paths)
const newKey = workflow.key
moveWorkflowThumbnail(oldKey, newKey)
@@ -591,6 +625,7 @@ export const useWorkflowStore = defineStore('workflow', () => {
isBusy.value = true
try {
await workflow.delete()
useWorkflowDraftStore().removeDraft(workflow.path)
if (bookmarkStore.isBookmarked(workflow.path)) {
await bookmarkStore.setBookmarked(workflow.path, false)
}

View File

@@ -0,0 +1,79 @@
import { describe, expect, it } from 'vitest'
import {
MAX_DRAFTS,
createDraftCacheState,
mostRecentDraftPath,
moveDraft,
removeDraft,
touchEntry,
upsertDraft
} from './draftCache'
import type { WorkflowDraftSnapshot } from './draftCache'
function createSnapshot(name: string): WorkflowDraftSnapshot {
return {
data: JSON.stringify({ name }),
updatedAt: Date.now(),
name,
isTemporary: true
}
}
describe('draftCache helpers', () => {
it('touchEntry moves path to end', () => {
expect(touchEntry(['a', 'b'], 'a')).toEqual(['b', 'a'])
expect(touchEntry(['a', 'b'], 'c')).toEqual(['a', 'b', 'c'])
})
it('upsertDraft stores snapshot and applies LRU', () => {
let state = createDraftCacheState()
for (let i = 0; i < MAX_DRAFTS; i++) {
const path = `workflows/Draft${i}.json`
state = upsertDraft(state, path, createSnapshot(String(i)))
}
expect(Object.keys(state.drafts).length).toBe(MAX_DRAFTS)
state = upsertDraft(state, 'workflows/New.json', createSnapshot('new'))
expect(Object.keys(state.drafts).length).toBe(MAX_DRAFTS)
expect(state.drafts).not.toHaveProperty('workflows/Draft0.json')
expect(state.order[state.order.length - 1]).toBe('workflows/New.json')
})
it('removeDraft clears entry and order', () => {
const state = upsertDraft(
createDraftCacheState(),
'workflows/test.json',
createSnapshot('test')
)
const nextState = removeDraft(state, 'workflows/test.json')
expect(nextState.drafts).toEqual({})
expect(nextState.order).toEqual([])
})
it('moveDraft renames entry and updates order', () => {
const state = upsertDraft(
createDraftCacheState(),
'workflows/old.json',
createSnapshot('old')
)
const nextState = moveDraft(
state,
'workflows/old.json',
'workflows/new.json',
'new'
)
expect(nextState.drafts).not.toHaveProperty('workflows/old.json')
expect(nextState.drafts['workflows/new.json']?.name).toBe('new')
expect(nextState.order).toEqual(['workflows/new.json'])
})
it('mostRecentDraftPath returns last entry', () => {
const state = createDraftCacheState({}, ['a', 'b', 'c'])
expect(mostRecentDraftPath(state.order)).toBe('c')
expect(mostRecentDraftPath([])).toBeNull()
})
})

View File

@@ -0,0 +1,78 @@
export interface WorkflowDraftSnapshot {
data: string
updatedAt: number
name: string
isTemporary: boolean
}
export interface DraftCacheState {
drafts: Record<string, WorkflowDraftSnapshot>
order: string[]
}
export const MAX_DRAFTS = 32
export const createDraftCacheState = (
drafts: Record<string, WorkflowDraftSnapshot> = {},
order: string[] = []
): DraftCacheState => ({ drafts, order })
export const touchEntry = (order: string[], path: string): string[] => {
const next = order.filter((entry) => entry !== path)
next.push(path)
return next
}
export const upsertDraft = (
state: DraftCacheState,
path: string,
snapshot: WorkflowDraftSnapshot,
limit: number = MAX_DRAFTS
): DraftCacheState => {
const effectiveLimit = Math.max(1, limit)
const drafts = { ...state.drafts, [path]: snapshot }
const order = touchEntry(state.order, path)
while (order.length > effectiveLimit) {
const oldest = order.shift()
if (!oldest) continue
if (oldest !== path) {
delete drafts[oldest]
}
}
return createDraftCacheState(drafts, order)
}
export const removeDraft = (
state: DraftCacheState,
path: string
): DraftCacheState => {
if (!(path in state.drafts)) return state
const drafts = { ...state.drafts }
delete drafts[path]
const order = state.order.filter((entry) => entry !== path)
return createDraftCacheState(drafts, order)
}
export const moveDraft = (
state: DraftCacheState,
oldPath: string,
newPath: string,
name: string
): DraftCacheState => {
const draft = state.drafts[oldPath]
if (!draft) return state
const updatedDraft = { ...draft, name }
const drafts = { ...state.drafts }
delete drafts[oldPath]
drafts[newPath] = updatedDraft
const order = touchEntry(
state.order.filter((entry) => entry !== oldPath && entry !== newPath),
newPath
)
return createDraftCacheState(drafts, order)
}
export const mostRecentDraftPath = (order: string[]): string | null =>
order.length ? order[order.length - 1] : null

View File

@@ -0,0 +1,254 @@
import { createPinia, setActivePinia } from 'pinia'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type * as I18n from 'vue-i18n'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { useWorkflowPersistence } from '@/platform/workflow/persistence/composables/useWorkflowPersistence'
import { useWorkflowDraftStore } from '@/platform/workflow/persistence/stores/workflowDraftStore'
import { defaultGraphJSON } from '@/scripts/defaultGraph'
import { setStorageValue } from '@/scripts/utils'
vi.mock('@/platform/settings/settingStore', () => ({
useSettingStore: vi.fn(() => ({
get: vi.fn((key: string) =>
key === 'Comfy.Workflow.Persist' ? true : undefined
),
set: vi.fn()
}))
}))
const mockToastAdd = vi.fn()
vi.mock('primevue', () => ({
useToast: () => ({
add: mockToastAdd
})
}))
vi.mock('vue-i18n', async (importOriginal) => {
const actual = await importOriginal<typeof I18n>()
return {
...actual,
useI18n: () => ({
t: (key: string) => key
})
}
})
const loadBlankWorkflow = vi.fn()
vi.mock('@/platform/workflow/core/services/workflowService', () => ({
useWorkflowService: () => ({
loadBlankWorkflow
})
}))
vi.mock(
'@/platform/workflow/templates/composables/useTemplateUrlLoader',
() => ({
useTemplateUrlLoader: () => ({
loadTemplateFromUrlParams: vi.fn()
})
})
)
const executeCommand = vi.fn()
vi.mock('@/stores/commandStore', () => ({
useCommandStore: () => ({
execute: executeCommand
})
}))
type GraphChangedHandler = (() => void) | null
const mocks = vi.hoisted(() => {
const state = {
graphChangedHandler: null as GraphChangedHandler,
currentGraph: {} as Record<string, unknown>
}
const serializeMock = vi.fn(() => state.currentGraph)
const loadGraphDataMock = vi.fn()
const apiMock = {
clientId: 'test-client',
initialClientId: 'test-client',
addEventListener: vi.fn((event: string, handler: () => void) => {
if (event === 'graphChanged') {
state.graphChangedHandler = handler
}
}),
removeEventListener: vi.fn(),
getUserData: vi.fn(),
storeUserData: vi.fn(),
listUserDataFullInfo: vi.fn(),
storeSetting: vi.fn(),
getSettings: vi.fn(),
deleteUserData: vi.fn(),
moveUserData: vi.fn(),
apiURL: vi.fn((path: string) => path)
}
return { state, serializeMock, loadGraphDataMock, apiMock }
})
vi.mock('@/scripts/app', () => ({
app: {
graph: {
serialize: () => mocks.serializeMock()
},
rootGraph: {
serialize: () => mocks.serializeMock()
},
loadGraphData: (...args: unknown[]) => mocks.loadGraphDataMock(...args),
canvas: {}
}
}))
vi.mock('@/scripts/api', () => ({
api: mocks.apiMock
}))
describe('useWorkflowPersistence', () => {
beforeEach(() => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2025-01-01T00:00:00Z'))
setActivePinia(createPinia())
localStorage.clear()
sessionStorage.clear()
vi.clearAllMocks()
mockToastAdd.mockClear()
useWorkflowDraftStore().reset()
mocks.state.graphChangedHandler = null
mocks.state.currentGraph = { initial: true }
mocks.serializeMock.mockImplementation(() => mocks.state.currentGraph)
mocks.loadGraphDataMock.mockReset()
mocks.apiMock.clientId = 'test-client'
mocks.apiMock.initialClientId = 'test-client'
mocks.apiMock.addEventListener.mockImplementation(
(event: string, handler: () => void) => {
if (event === 'graphChanged') {
mocks.state.graphChangedHandler = handler
}
}
)
mocks.apiMock.removeEventListener.mockImplementation(() => {})
mocks.apiMock.listUserDataFullInfo.mockResolvedValue([])
mocks.apiMock.getUserData.mockResolvedValue({
status: 200,
text: () => Promise.resolve(defaultGraphJSON)
} as Response)
mocks.apiMock.apiURL.mockImplementation((path: string) => path)
})
afterEach(() => {
vi.useRealTimers()
})
it('persists snapshots for multiple workflows', async () => {
const workflowStore = useWorkflowStore()
const workflowA = workflowStore.createTemporary('DraftA.json')
await workflowStore.openWorkflow(workflowA)
const persistence = useWorkflowPersistence()
expect(persistence).toBeDefined()
expect(mocks.state.graphChangedHandler).toBeTypeOf('function')
const graphA = { title: 'A' }
mocks.state.currentGraph = graphA
mocks.state.graphChangedHandler!()
await vi.advanceTimersByTimeAsync(800)
const workflowB = workflowStore.createTemporary('DraftB.json')
await workflowStore.openWorkflow(workflowB)
const graphB = { title: 'B' }
mocks.state.currentGraph = graphB
mocks.state.graphChangedHandler!()
await vi.advanceTimersByTimeAsync(800)
const drafts = JSON.parse(
localStorage.getItem('Comfy.Workflow.Drafts') ?? '{}'
) as Record<string, { data: string; isTemporary: boolean }>
expect(Object.keys(drafts)).toEqual(
expect.arrayContaining(['workflows/DraftA.json', 'workflows/DraftB.json'])
)
expect(JSON.parse(drafts['workflows/DraftA.json'].data)).toEqual(graphA)
expect(JSON.parse(drafts['workflows/DraftB.json'].data)).toEqual(graphB)
expect(drafts['workflows/DraftA.json'].isTemporary).toBe(true)
expect(drafts['workflows/DraftB.json'].isTemporary).toBe(true)
})
it('evicts least recently used drafts beyond the limit', async () => {
const workflowStore = useWorkflowStore()
useWorkflowPersistence()
expect(mocks.state.graphChangedHandler).toBeTypeOf('function')
for (let i = 0; i < 33; i++) {
const workflow = workflowStore.createTemporary(`Draft${i}.json`)
await workflowStore.openWorkflow(workflow)
mocks.state.currentGraph = { index: i }
mocks.state.graphChangedHandler!()
await vi.advanceTimersByTimeAsync(800)
vi.setSystemTime(new Date(Date.now() + 60000))
}
const drafts = JSON.parse(
localStorage.getItem('Comfy.Workflow.Drafts') ?? '{}'
) as Record<string, any>
expect(Object.keys(drafts).length).toBe(32)
expect(drafts['workflows/Draft0.json']).toBeUndefined()
expect(drafts['workflows/Draft32.json']).toBeDefined()
})
it('restores temporary tabs from cached drafts', async () => {
const workflowStore = useWorkflowStore()
const draftStore = useWorkflowDraftStore()
const draftData = JSON.parse(defaultGraphJSON)
draftStore.saveDraft('workflows/Unsaved Workflow.json', {
data: JSON.stringify(draftData),
updatedAt: Date.now(),
name: 'Unsaved Workflow.json',
isTemporary: true
})
setStorageValue(
'Comfy.OpenWorkflowsPaths',
JSON.stringify(['workflows/Unsaved Workflow.json'])
)
setStorageValue('Comfy.ActiveWorkflowIndex', JSON.stringify(0))
const { restoreWorkflowTabsState } = useWorkflowPersistence()
restoreWorkflowTabsState()
const restored = workflowStore.getWorkflowByPath(
'workflows/Unsaved Workflow.json'
)
expect(restored).toBeTruthy()
expect(restored?.isTemporary).toBe(true)
expect(
workflowStore.openWorkflows.map((workflow) => workflow?.path)
).toContain('workflows/Unsaved Workflow.json')
})
it('shows error toast when draft save fails', async () => {
const workflowStore = useWorkflowStore()
const draftStore = useWorkflowDraftStore()
const workflow = workflowStore.createTemporary('FailingDraft.json')
await workflowStore.openWorkflow(workflow)
useWorkflowPersistence()
expect(mocks.state.graphChangedHandler).toBeTypeOf('function')
vi.spyOn(draftStore, 'saveDraft').mockImplementation(() => {
throw new Error('Storage quota exceeded')
})
mocks.state.currentGraph = { title: 'Test' }
mocks.state.graphChangedHandler!()
await vi.advanceTimersByTimeAsync(800)
expect(mockToastAdd).toHaveBeenCalledWith(
expect.objectContaining({
severity: 'error',
detail: expect.any(String)
})
)
})
})

View File

@@ -1,5 +1,7 @@
import { useToast } from 'primevue'
import { tryOnScopeDispose } from '@vueuse/core'
import { computed, watch } from 'vue'
import { computed, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRoute, useRouter } from 'vue-router'
import {
@@ -9,7 +11,11 @@ import {
import { PRESERVED_QUERY_NAMESPACES } from '@/platform/navigation/preservedQueryNamespaces'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useWorkflowService } from '@/platform/workflow/core/services/workflowService'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import {
ComfyWorkflow,
useWorkflowStore
} from '@/platform/workflow/management/stores/workflowStore'
import { useWorkflowDraftStore } from '@/platform/workflow/persistence/stores/workflowDraftStore'
import { useTemplateUrlLoader } from '@/platform/workflow/templates/composables/useTemplateUrlLoader'
import { api } from '@/scripts/api'
import { app as comfyApp } from '@/scripts/app'
@@ -17,12 +23,15 @@ import { getStorageValue, setStorageValue } from '@/scripts/utils'
import { useCommandStore } from '@/stores/commandStore'
export function useWorkflowPersistence() {
const { t } = useI18n()
const workflowStore = useWorkflowStore()
const settingStore = useSettingStore()
const route = useRoute()
const router = useRouter()
const templateUrlLoader = useTemplateUrlLoader()
const TEMPLATE_NAMESPACE = PRESERVED_QUERY_NAMESPACES.TEMPLATE
const workflowDraftStore = useWorkflowDraftStore()
const toast = useToast()
const ensureTemplateQueryFromIntent = async () => {
hydratePreservedQuery(TEMPLATE_NAMESPACE)
@@ -42,14 +51,40 @@ export function useWorkflowPersistence() {
settingStore.get('Comfy.Workflow.Persist')
)
const lastSavedJsonByPath = ref<Record<string, string>>({})
const persistCurrentWorkflow = () => {
if (!workflowPersistenceEnabled.value) return
const workflow = JSON.stringify(comfyApp.rootGraph.serialize())
const activeWorkflow = workflowStore.activeWorkflow
if (!activeWorkflow) return
const graphData = comfyApp.rootGraph.serialize()
const workflowJson = JSON.stringify(graphData)
const workflowPath = activeWorkflow.path
if (workflowJson === lastSavedJsonByPath.value[workflowPath]) return
try {
localStorage.setItem('workflow', workflow)
workflowDraftStore.saveDraft(activeWorkflow.path, {
data: workflowJson,
updatedAt: Date.now(),
name: activeWorkflow.key,
isTemporary: activeWorkflow.isTemporary
})
} catch (error) {
console.error('Failed to save draft', error)
toast.add({
severity: 'error',
summary: t('g.error'),
detail: t('toastMessages.failedToSaveDraft'),
life: 3000
})
return
}
try {
localStorage.setItem('workflow', workflowJson)
if (api.clientId) {
sessionStorage.setItem(`workflow:${api.clientId}`, workflow)
sessionStorage.setItem(`workflow:${api.clientId}`, workflowJson)
}
} catch (error) {
// Only log our own keys and aggregate stats
@@ -57,7 +92,7 @@ export function useWorkflowPersistence() {
(key) => key.startsWith('workflow:') || key === 'workflow'
)
console.error('QuotaExceededError details:', {
workflowSizeKB: Math.round(workflow.length / 1024),
workflowSizeKB: Math.round(workflowJson.length / 1024),
totalStorageItems: Object.keys(sessionStorage).length,
ourWorkflowKeys: ourKeys.length,
ourWorkflowSizes: ourKeys.map((key) => ({
@@ -68,33 +103,25 @@ export function useWorkflowPersistence() {
})
throw error
}
}
const loadWorkflowFromStorage = async (
json: string | null,
workflowName: string | null
) => {
if (!json) return false
const workflow = JSON.parse(json)
await comfyApp.loadGraphData(workflow, true, true, workflowName)
return true
lastSavedJsonByPath.value[workflowPath] = workflowJson
if (!activeWorkflow.isTemporary && !activeWorkflow.isModified) {
workflowDraftStore.removeDraft(activeWorkflow.path)
return
}
}
const loadPreviousWorkflowFromStorage = async () => {
const workflowName = getStorageValue('Comfy.PreviousWorkflow')
const clientId = api.initialClientId ?? api.clientId
// Try loading from session storage first
if (clientId) {
const sessionWorkflow = sessionStorage.getItem(`workflow:${clientId}`)
if (await loadWorkflowFromStorage(sessionWorkflow, workflowName)) {
return true
}
}
// Fall back to local storage
const localWorkflow = localStorage.getItem('workflow')
return await loadWorkflowFromStorage(localWorkflow, workflowName)
const preferredPath = workflowName
? `${ComfyWorkflow.basePath}${workflowName}`
: null
return await workflowDraftStore.loadPersistedWorkflow({
workflowName,
preferredPath,
fallbackToLatestDraft: !workflowName
})
}
const loadDefaultWorkflow = async () => {
@@ -141,6 +168,7 @@ export function useWorkflowPersistence() {
persistCurrentWorkflow()
}
)
api.addEventListener('graphChanged', persistCurrentWorkflow)
// Clean up event listener when component unmounts
@@ -158,24 +186,33 @@ export function useWorkflowPersistence() {
}
const paths = openWorkflows.value
.filter((workflow) => workflow?.isPersisted)
.map((workflow) => workflow.path)
const activeIndex = openWorkflows.value.findIndex(
(workflow) => workflow.path === activeWorkflow.value?.path
)
.map((workflow) => workflow?.path)
.filter(
(path): path is string =>
typeof path === 'string' && path.startsWith(ComfyWorkflow.basePath)
)
const activeIndex = paths.indexOf(activeWorkflow.value.path)
return { paths, activeIndex }
}
)
// Get storage values before setting watchers
const storedWorkflows = JSON.parse(
const parsedWorkflows = JSON.parse(
getStorageValue('Comfy.OpenWorkflowsPaths') || '[]'
)
const storedActiveIndex = JSON.parse(
const storedWorkflows = Array.isArray(parsedWorkflows)
? parsedWorkflows.filter(
(entry): entry is string => typeof entry === 'string'
)
: []
const parsedIndex = JSON.parse(
getStorageValue('Comfy.ActiveWorkflowIndex') || '-1'
)
const storedActiveIndex =
typeof parsedIndex === 'number' && Number.isFinite(parsedIndex)
? parsedIndex
: -1
watch(restoreState, ({ paths, activeIndex }) => {
if (workflowPersistenceEnabled.value) {
setStorageValue('Comfy.OpenWorkflowsPaths', JSON.stringify(paths))
@@ -186,12 +223,29 @@ export function useWorkflowPersistence() {
const restoreWorkflowTabsState = () => {
if (!workflowPersistenceEnabled.value) return
const isRestorable = storedWorkflows?.length > 0 && storedActiveIndex >= 0
if (isRestorable) {
workflowStore.openWorkflowsInBackground({
left: storedWorkflows.slice(0, storedActiveIndex),
right: storedWorkflows.slice(storedActiveIndex)
})
}
if (!isRestorable) return
storedWorkflows.forEach((path: string) => {
if (workflowStore.getWorkflowByPath(path)) return
const draft = workflowDraftStore.getDraft(path)
if (!draft?.isTemporary) return
try {
const workflowData = JSON.parse(draft.data)
workflowStore.createTemporary(draft.name, workflowData)
} catch (err) {
console.warn(
'Failed to parse workflow draft, creating with default',
err
)
workflowDraftStore.removeDraft(path)
workflowStore.createTemporary(draft.name)
}
})
workflowStore.openWorkflowsInBackground({
left: storedWorkflows.slice(0, storedActiveIndex),
right: storedWorkflows.slice(storedActiveIndex)
})
}
return {

View File

@@ -0,0 +1,161 @@
import { useStorage } from '@vueuse/core'
import { defineStore } from 'pinia'
import { computed } from 'vue'
import type {
DraftCacheState,
WorkflowDraftSnapshot
} from '@/platform/workflow/persistence/base/draftCache'
import {
MAX_DRAFTS,
createDraftCacheState,
mostRecentDraftPath,
moveDraft as moveDraftEntry,
removeDraft as removeDraftEntry,
touchEntry,
upsertDraft
} from '@/platform/workflow/persistence/base/draftCache'
import { api } from '@/scripts/api'
import { app as comfyApp } from '@/scripts/app'
const DRAFTS_STORAGE_KEY = 'Comfy.Workflow.Drafts'
const ORDER_STORAGE_KEY = 'Comfy.Workflow.DraftOrder'
interface LoadPersistedWorkflowOptions {
workflowName: string | null
preferredPath?: string | null
fallbackToLatestDraft?: boolean
}
export const useWorkflowDraftStore = defineStore('workflowDraft', () => {
const storedDrafts = useStorage<Record<string, WorkflowDraftSnapshot>>(
DRAFTS_STORAGE_KEY,
{}
)
const storedOrder = useStorage<string[]>(ORDER_STORAGE_KEY, [])
const mostRecentDraft = computed(() => mostRecentDraftPath(storedOrder.value))
const currentState = (): DraftCacheState =>
createDraftCacheState(storedDrafts.value, storedOrder.value)
const updateState = (state: DraftCacheState) => {
storedDrafts.value = state.drafts
storedOrder.value = state.order
}
const saveDraft = (path: string, snapshot: WorkflowDraftSnapshot) => {
try {
updateState(upsertDraft(currentState(), path, snapshot, MAX_DRAFTS))
} catch (error) {
if (
error instanceof DOMException &&
error.name === 'QuotaExceededError'
) {
const state = currentState()
if (state.order.length > 0) {
const oldestPath = state.order[0]
updateState(removeDraftEntry(state, oldestPath))
updateState(upsertDraft(currentState(), path, snapshot, MAX_DRAFTS))
} else {
throw error
}
} else {
throw error
}
}
}
const removeDraft = (path: string) => {
updateState(removeDraftEntry(currentState(), path))
}
const moveDraft = (oldPath: string, newPath: string, name: string) => {
updateState(moveDraftEntry(currentState(), oldPath, newPath, name))
}
const markDraftUsed = (path: string) => {
if (!(path in storedDrafts.value)) return
storedOrder.value = touchEntry(storedOrder.value, path)
}
const getDraft = (path: string) => storedDrafts.value[path]
const tryLoadGraph = async (
payload: string | null,
workflowName: string | null,
onFailure?: () => void
) => {
if (!payload) return false
try {
const workflow = JSON.parse(payload)
await comfyApp.loadGraphData(
workflow,
/* clean= */ true,
/* restore_view= */ true,
workflowName
)
return true
} catch (err) {
console.error('Failed to load persisted workflow', err)
onFailure?.()
return false
}
}
const loadDraft = async (path: string) => {
const draft = getDraft(path)
if (!draft) return false
const loaded = await tryLoadGraph(draft.data, draft.name, () => {
removeDraft(path)
})
if (loaded) {
markDraftUsed(path)
}
return loaded
}
const loadPersistedWorkflow = async (
options: LoadPersistedWorkflowOptions
): Promise<boolean> => {
const {
workflowName,
preferredPath,
fallbackToLatestDraft = false
} = options
if (preferredPath && (await loadDraft(preferredPath))) {
return true
}
if (!preferredPath && fallbackToLatestDraft) {
const fallbackPath = mostRecentDraft.value
if (fallbackPath && (await loadDraft(fallbackPath))) {
return true
}
}
const clientId = api.initialClientId ?? api.clientId
if (clientId) {
const sessionPayload = sessionStorage.getItem(`workflow:${clientId}`)
if (await tryLoadGraph(sessionPayload, workflowName)) {
return true
}
}
const localPayload = localStorage.getItem('workflow')
return await tryLoadGraph(localPayload, workflowName)
}
return {
saveDraft,
removeDraft,
moveDraft,
markDraftUsed,
getDraft,
loadPersistedWorkflow,
reset: () => {
updateState(createDraftCacheState())
}
}
})