Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,19 @@ import { useSettingStore } from '@/platform/settings/settingStore'
import { useWorkflowService } from '@/platform/workflow/core/services/workflowService'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { useTemplateUrlLoader } from '@/platform/workflow/templates/composables/useTemplateUrlLoader'
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
import { api } from '@/scripts/api'
import { app as comfyApp } from '@/scripts/app'
import { getStorageValue, setStorageValue } from '@/scripts/utils'
import {
getStorageStats,
getWithLruTracking,
setWithLruEviction
} from '@/platform/workflow/persistence/services/sessionStorageLruService'
import { useCommandStore } from '@/stores/commandStore'

const WORKFLOW_KEY_PATTERN = /^workflow:/

export function useWorkflowPersistence() {
const workflowStore = useWorkflowStore()
const settingStore = useSettingStore()
Expand Down Expand Up @@ -44,57 +52,76 @@ export function useWorkflowPersistence() {

const persistCurrentWorkflow = () => {
if (!workflowPersistenceEnabled.value) return
const workflow = JSON.stringify(comfyApp.rootGraph.serialize())
const workflowData = comfyApp.rootGraph.serialize()
const workflowJson = JSON.stringify(workflowData)

try {
localStorage.setItem('workflow', workflow)
if (api.clientId) {
sessionStorage.setItem(`workflow:${api.clientId}`, workflow)
}
localStorage.setItem('workflow', workflowJson)
} catch (error) {
// Only log our own keys and aggregate stats
const ourKeys = Object.keys(sessionStorage).filter(
(key) => key.startsWith('workflow:') || key === 'workflow'
console.warn(
'[WorkflowPersistence] Failed to save to localStorage:',
error
)
console.error('QuotaExceededError details:', {
workflowSizeKB: Math.round(workflow.length / 1024),
totalStorageItems: Object.keys(sessionStorage).length,
ourWorkflowKeys: ourKeys.length,
ourWorkflowSizes: ourKeys.map((key) => ({
key,
sizeKB: Math.round(sessionStorage[key].length / 1024)
})),
error: error instanceof Error ? error.message : String(error)
})
throw error
}

if (api.clientId) {
const key = `workflow:${api.clientId}`
const success = setWithLruEviction(
key,
workflowData,
WORKFLOW_KEY_PATTERN
)

if (!success) {
const stats = getStorageStats(WORKFLOW_KEY_PATTERN)
console.warn(
'[WorkflowPersistence] Failed to persist workflow after LRU eviction',
{
workflowSizeKB: Math.round(workflowJson.length / 1024),
...stats
}
)
}
}
}

const loadWorkflowFromStorage = async (
json: string | null,
const loadWorkflowFromData = async (
workflowData: ComfyWorkflowJSON | null,
workflowName: string | null
) => {
if (!json) return false
const workflow = JSON.parse(json)
await comfyApp.loadGraphData(workflow, true, true, workflowName)
if (!workflowData) return false
await comfyApp.loadGraphData(workflowData, true, true, workflowName)
return true
}

const loadPreviousWorkflowFromStorage = async () => {
const workflowName = getStorageValue('Comfy.PreviousWorkflow')
const clientId = api.initialClientId ?? api.clientId

// Try loading from session storage first
// Try loading from session storage first (uses LRU tracking)
if (clientId) {
const sessionWorkflow = sessionStorage.getItem(`workflow:${clientId}`)
if (await loadWorkflowFromStorage(sessionWorkflow, workflowName)) {
const sessionWorkflow = getWithLruTracking<ComfyWorkflowJSON>(
`workflow:${clientId}`
)
if (await loadWorkflowFromData(sessionWorkflow, workflowName)) {
return true
}
}

// Fall back to local storage
const localWorkflow = localStorage.getItem('workflow')
return await loadWorkflowFromStorage(localWorkflow, workflowName)
// Fall back to local storage (raw JSON, no LRU wrapper)
const localWorkflowJson = localStorage.getItem('workflow')
if (localWorkflowJson) {
try {
const localWorkflow = JSON.parse(localWorkflowJson) as ComfyWorkflowJSON
return await loadWorkflowFromData(localWorkflow, workflowName)
} catch {
console.warn(
'[WorkflowPersistence] Failed to parse localStorage workflow'
)
}
}

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.

🧹 Nitpick | 🔵 Trivial

🧩 Analysis chain

🏁 Script executed:

# Find and read the target file
find . -name "useWorkflowPersistence.ts" -type f | head -5

Repository: Comfy-Org/ComfyUI_frontend

Length of output: 143


🏁 Script executed:

# Look at the structure around the lines in question
find . -path "*/workflow/persistence/composables/*" -type f

Repository: Comfy-Org/ComfyUI_frontend

Length of output: 290


🏁 Script executed:

# Search for ComfyWorkflowJSON type definition and any Zod schemas
rg "ComfyWorkflowJSON" --type ts -A 3 -B 3

Repository: Comfy-Org/ComfyUI_frontend

Length of output: 48392


🏁 Script executed:

# Search for loadGraphData function to check its validation
rg "loadGraphData" --type ts -A 15 | head -100

Repository: Comfy-Org/ComfyUI_frontend

Length of output: 8193


🏁 Script executed:

# Check for Zod schema validation patterns in the codebase
rg "safeParse|schema\.parse" --type ts | head -20

Repository: Comfy-Org/ComfyUI_frontend

Length of output: 2216


🏁 Script executed:

# Read the file around the problematic lines
cat -n src/platform/workflow/persistence/composables/useWorkflowPersistence.ts | sed -n '80,115p'

Repository: Comfy-Org/ComfyUI_frontend

Length of output: 1538


🏁 Script executed:

# Check if validateComfyWorkflow is imported or used in this file
rg "validateComfyWorkflow" src/platform/workflow/persistence/composables/useWorkflowPersistence.ts -A 5 -B 5

Repository: Comfy-Org/ComfyUI_frontend

Length of output: 52


🏁 Script executed:

# Check the loadWorkflowFromData function to see if it does validation
rg "loadWorkflowFromData" src/platform/workflow/persistence/composables/useWorkflowPersistence.ts -A 10

Repository: Comfy-Org/ComfyUI_frontend

Length of output: 1029


🏁 Script executed:

# Check loadGraphData to see if it validates the input
rg "async loadGraphData" src/scripts/app.ts -A 30 | head -60

Repository: Comfy-Org/ComfyUI_frontend

Length of output: 961


🏁 Script executed:

# Check if loadGraphData has any validation logic
rg "loadGraphData" src/scripts/app.ts -A 80 | grep -A 80 "async loadGraphData"

Repository: Comfy-Org/ComfyUI_frontend

Length of output: 2577


🏁 Script executed:

# Check if there's any validation in the workflow validation composable that might be called
rg "useWorkflowValidation" src/platform/workflow/persistence/composables/useWorkflowPersistence.ts -B 3 -A 3

Repository: Comfy-Org/ComfyUI_frontend

Length of output: 52


🏁 Script executed:

# Look at the validateComfyWorkflow function to understand the pattern
cat -n src/platform/workflow/validation/schemas/workflowSchema.ts | sed -n '350,400p'

Repository: Comfy-Org/ComfyUI_frontend

Length of output: 1903


🏁 Script executed:

# Find validateComfyWorkflow signature and pattern
cat -n src/platform/workflow/validation/schemas/workflowSchema.ts | sed -n '490,520p'

Repository: Comfy-Org/ComfyUI_frontend

Length of output: 1165


Consider validating parsed localStorage workflow data with Zod schema.

The as ComfyWorkflowJSON cast on line 99 accepts any parsed JSON without validation. While loadGraphData includes optional schema validation (if Comfy.Validation.Workflows is enabled), corrupted or invalid data could slip through if validation is disabled. Following the codebase pattern, use validateComfyWorkflow() instead:

try {
  const validated = await validateComfyWorkflow(JSON.parse(localWorkflowJson))
  if (!validated) {
    console.warn('[WorkflowPersistence] Invalid localStorage workflow')
    return false
  }
  return await loadWorkflowFromData(validated, workflowName)
} catch {
  console.warn('[WorkflowPersistence] Failed to parse localStorage workflow')
}

This validates data at the entry point and aligns with existing patterns in the codebase (e.g., src/platform/remote/comfyui/jobs/fetchJobs.ts).

🤖 Prompt for AI Agents
In `@src/platform/workflow/persistence/composables/useWorkflowPersistence.ts`
around lines 95 - 106, Replace the unchecked cast of parsed localStorage JSON
with Zod validation: parse localStorage.getItem('workflow'), pass the parsed
value into validateComfyWorkflow(), and if validation fails log
'[WorkflowPersistence] Invalid localStorage workflow' and return false; if
validation succeeds call loadWorkflowFromData(validated, workflowName). Update
the try/catch around JSON.parse to use validateComfyWorkflow() instead of "as
ComfyWorkflowJSON" and preserve the existing catch that logs parse failures.


return false
}

const loadDefaultWorkflow = async () => {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,238 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'

import {
getStorageStats,
getWithLruTracking,
removeFromStorage,
setWithLruEviction
} from './sessionStorageLruService'

describe('sessionStorageLruService', () => {
beforeEach(() => {
sessionStorage.clear()
vi.clearAllMocks()
})

afterEach(() => {
vi.restoreAllMocks()
})

describe('setWithLruEviction', () => {
it('stores data with LRU metadata wrapper', () => {
const data = { nodes: [], links: [] }
const result = setWithLruEviction('workflow:test', data)

expect(result).toBe(true)

const stored = sessionStorage.getItem('workflow:test')
expect(stored).toBeTruthy()

const parsed = JSON.parse(stored!)
expect(parsed).toHaveProperty('accessedAt')
expect(parsed).toHaveProperty('data')
expect(parsed.data).toEqual(data)
expect(typeof parsed.accessedAt).toBe('number')
})

it('returns true on successful storage', () => {
const result = setWithLruEviction('key', { test: 'data' })
expect(result).toBe(true)
})

it('evicts LRU entries when quota is exceeded', () => {
const oldEntry = { accessedAt: 1000, data: { old: 'data' } }
const newEntry = { accessedAt: 2000, data: { new: 'data' } }

sessionStorage.setItem('workflow:old', JSON.stringify(oldEntry))
sessionStorage.setItem('workflow:new', JSON.stringify(newEntry))

let callCount = 0
const originalSetItem = sessionStorage.setItem.bind(sessionStorage)
vi.spyOn(sessionStorage, 'setItem').mockImplementation((key, value) => {
callCount++
if (key === 'workflow:current' && callCount === 1) {
const error = new DOMException('Quota exceeded', 'QuotaExceededError')
throw error
}
return originalSetItem(key, value)
})

const result = setWithLruEviction(
'workflow:current',
{ current: 'data' },
/^workflow:/
)

expect(result).toBe(true)
expect(sessionStorage.getItem('workflow:old')).toBeNull()
expect(sessionStorage.getItem('workflow:new')).toBeTruthy()
})

it('returns false after max eviction attempts', () => {
const spy = vi.spyOn(sessionStorage, 'setItem').mockImplementation(() => {
throw new DOMException('Quota exceeded', 'QuotaExceededError')
})

const result = setWithLruEviction('key', { test: 'data' })
expect(result).toBe(false)

spy.mockRestore()
})
})

describe('getWithLruTracking', () => {
it('returns null for non-existent key', () => {
const result = getWithLruTracking('nonexistent')
expect(result).toBeNull()
})

it('returns unwrapped data for new format entries', () => {
const entry = { accessedAt: Date.now(), data: { nodes: [], links: [] } }
sessionStorage.setItem('workflow:test', JSON.stringify(entry))

const result = getWithLruTracking('workflow:test')
expect(result).toEqual({ nodes: [], links: [] })
})

it('handles legacy format (unwrapped data) with accessedAt: 0', () => {
const legacyData = { nodes: [1, 2, 3], links: [] }
sessionStorage.setItem('workflow:legacy', JSON.stringify(legacyData))

const result = getWithLruTracking('workflow:legacy')
expect(result).toEqual(legacyData)
})

it('updates accessedAt when reading', () => {
const oldTime = 1000
const entry = { accessedAt: oldTime, data: { test: 'data' } }
sessionStorage.setItem('workflow:test', JSON.stringify(entry))

getWithLruTracking('workflow:test', true)

const stored = JSON.parse(sessionStorage.getItem('workflow:test')!)
expect(stored.accessedAt).toBeGreaterThan(oldTime)
})

it('does not update accessedAt when updateAccessTime is false', () => {
const oldTime = 1000
const entry = { accessedAt: oldTime, data: { test: 'data' } }
sessionStorage.setItem('workflow:test', JSON.stringify(entry))

getWithLruTracking('workflow:test', false)

const stored = JSON.parse(sessionStorage.getItem('workflow:test')!)
expect(stored.accessedAt).toBe(oldTime)
})
})

describe('removeFromStorage', () => {
it('removes item from session storage', () => {
sessionStorage.setItem('key', 'value')
expect(sessionStorage.getItem('key')).toBe('value')

removeFromStorage('key')
expect(sessionStorage.getItem('key')).toBeNull()
})
})

describe('getStorageStats', () => {
it('returns stats for all entries', () => {
const entry1 = { accessedAt: 1000, data: { a: 1 } }
const entry2 = { accessedAt: 2000, data: { b: 2 } }
sessionStorage.setItem('workflow:a', JSON.stringify(entry1))
sessionStorage.setItem('workflow:b', JSON.stringify(entry2))
sessionStorage.setItem('other:c', 'value')

const stats = getStorageStats()

expect(stats.totalItems).toBe(3)
expect(stats.matchingItems).toBe(3)
})

it('filters entries by pattern', () => {
const entry1 = { accessedAt: 1000, data: { a: 1 } }
const entry2 = { accessedAt: 2000, data: { b: 2 } }
sessionStorage.setItem('workflow:a', JSON.stringify(entry1))
sessionStorage.setItem('workflow:b', JSON.stringify(entry2))
sessionStorage.setItem('other:c', 'value')

const stats = getStorageStats(/^workflow:/)

expect(stats.totalItems).toBe(3)
expect(stats.matchingItems).toBe(2)
expect(stats.entries).toHaveLength(2)
})

it('sorts entries by accessedAt (oldest first)', () => {
const entry1 = { accessedAt: 2000, data: { newer: true } }
const entry2 = { accessedAt: 1000, data: { older: true } }
sessionStorage.setItem('workflow:newer', JSON.stringify(entry1))
sessionStorage.setItem('workflow:older', JSON.stringify(entry2))

const stats = getStorageStats(/^workflow:/)

expect(stats.entries[0].key).toBe('workflow:older')
expect(stats.entries[1].key).toBe('workflow:newer')
})

it('treats legacy entries as accessedAt: 0', () => {
const legacyData = { nodes: [], links: [] }
const newEntry = { accessedAt: 1000, data: { test: true } }
sessionStorage.setItem('workflow:legacy', JSON.stringify(legacyData))
sessionStorage.setItem('workflow:new', JSON.stringify(newEntry))

const stats = getStorageStats(/^workflow:/)

expect(stats.entries[0].key).toBe('workflow:legacy')
expect(stats.entries[0].accessedAt).toBe(0)
})
})

describe('LRU eviction order', () => {
it('evicts oldest entries first (legacy before new)', () => {
const legacyData = { nodes: [], links: [] }
const newEntry = { accessedAt: Date.now(), data: { new: true } }

sessionStorage.setItem('workflow:legacy', JSON.stringify(legacyData))
sessionStorage.setItem('workflow:new', JSON.stringify(newEntry))

let callCount = 0
const originalSetItem = sessionStorage.setItem.bind(sessionStorage)
vi.spyOn(sessionStorage, 'setItem').mockImplementation((key, value) => {
callCount++
if (key === 'workflow:current' && callCount === 1) {
throw new DOMException('Quota exceeded', 'QuotaExceededError')
}
return originalSetItem(key, value)
})

setWithLruEviction('workflow:current', { current: true }, /^workflow:/)

expect(sessionStorage.getItem('workflow:legacy')).toBeNull()
expect(sessionStorage.getItem('workflow:new')).toBeTruthy()
})

it('evicts oldest new-format entries when no legacy entries exist', () => {
const oldEntry = { accessedAt: 1000, data: { old: true } }
const newEntry = { accessedAt: 2000, data: { new: true } }

sessionStorage.setItem('workflow:old', JSON.stringify(oldEntry))
sessionStorage.setItem('workflow:new', JSON.stringify(newEntry))

let callCount = 0
const originalSetItem = sessionStorage.setItem.bind(sessionStorage)
vi.spyOn(sessionStorage, 'setItem').mockImplementation((key, value) => {
callCount++
if (key === 'workflow:current' && callCount === 1) {
throw new DOMException('Quota exceeded', 'QuotaExceededError')
}
return originalSetItem(key, value)
})

setWithLruEviction('workflow:current', { current: true }, /^workflow:/)

expect(sessionStorage.getItem('workflow:old')).toBeNull()
expect(sessionStorage.getItem('workflow:new')).toBeTruthy()
})
})
})
Loading