Skip to content
18 changes: 3 additions & 15 deletions browser_tests/tests/colorPalette.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -244,21 +244,9 @@ test.describe(
await comfyPage.settings.setSetting('Comfy.Node.Opacity', 0.5)
await comfyPage.settings.setSetting('Comfy.ColorPalette', 'light')
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()
const parsed = await comfyPage.page.evaluate(() => {
return window['app'].graph.serialize()
})
expect(parsed.nodes).toBeDefined()
expect(Array.isArray(parsed.nodes)).toBe(true)
for (const node of parsed.nodes) {
Expand Down
34 changes: 30 additions & 4 deletions browser_tests/tests/interaction.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -736,6 +736,25 @@ test.describe('Load workflow', { tag: '@screenshot' }, () => {
await expect(comfyPage.canvas).toHaveScreenshot(
'single_ksampler_modified.png'
)
// Wait for V2 persistence debounce to save the modified workflow
const start = Date.now()
await comfyPage.page.waitForFunction((since) => {
for (let i = 0; i < window.localStorage.length; i++) {
const key = window.localStorage.key(i)
if (!key?.startsWith('Comfy.Workflow.DraftIndex.v2:')) continue
const json = window.localStorage.getItem(key)
if (!json) continue
try {
const index = JSON.parse(json)
if (typeof index.updatedAt === 'number' && index.updatedAt >= since) {
return true
}
} catch {
// ignore
}
}
return false
}, start)
await comfyPage.setup({ clearStorage: false })
await expect(comfyPage.canvas).toHaveScreenshot(
'single_ksampler_modified.png'
Expand All @@ -758,10 +777,17 @@ test.describe('Load workflow', { tag: '@screenshot' }, () => {
await comfyPage.menu.topbar.triggerTopbarCommand(['New'])
await comfyPage.menu.topbar.saveWorkflow(workflowB)

// Wait for localStorage to persist the workflow paths before reloading
await comfyPage.page.waitForFunction(
() => !!window.localStorage.getItem('Comfy.OpenWorkflowsPaths')
)
// Wait for sessionStorage to persist the workflow paths before reloading
// V2 persistence uses sessionStorage with client-scoped keys
await comfyPage.page.waitForFunction(() => {
for (let i = 0; i < window.sessionStorage.length; i++) {
const key = window.sessionStorage.key(i)
if (key?.startsWith('Comfy.Workflow.OpenPaths:')) {
return true
}
}
return false
})
await comfyPage.setup({ clearStorage: false })
})

Expand Down
2 changes: 1 addition & 1 deletion src/components/graph/GraphCanvas.vue
Original file line number Diff line number Diff line change
Expand Up @@ -152,7 +152,7 @@ import { useToastStore } from '@/platform/updates/common/toastStore'
import { useWorkflowService } from '@/platform/workflow/core/services/workflowService'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { useWorkflowAutoSave } from '@/platform/workflow/persistence/composables/useWorkflowAutoSave'
import { useWorkflowPersistence } from '@/platform/workflow/persistence/composables/useWorkflowPersistence'
import { useWorkflowPersistenceV2 as useWorkflowPersistence } from '@/platform/workflow/persistence/composables/useWorkflowPersistenceV2'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useCanvasInteractions } from '@/renderer/core/canvas/useCanvasInteractions'
import TransformPane from '@/renderer/core/layout/transform/TransformPane.vue'
Expand Down
12 changes: 12 additions & 0 deletions src/composables/auth/useFirebaseAuthActions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { t } from '@/i18n'
import { isCloud } from '@/platform/distribution/types'
import { useTelemetry } from '@/platform/telemetry'
import { useToastStore } from '@/platform/updates/common/toastStore'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { useDialogService } from '@/services/dialogService'
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
import type { BillingPortalTargetTier } from '@/stores/firebaseAuthStore'
Expand Down Expand Up @@ -51,6 +52,17 @@ export const useFirebaseAuthActions = () => {
}

const logout = wrapWithErrorHandlingAsync(async () => {
const workflowStore = useWorkflowStore()
if (workflowStore.modifiedWorkflows.length > 0) {
const dialogService = useDialogService()
const confirmed = await dialogService.confirm({
title: t('auth.signOut.unsavedChangesTitle'),
message: t('auth.signOut.unsavedChangesMessage'),
type: 'dirtyClose'
})
if (!confirmed) return
}

await authStore.logout()
toastStore.add({
severity: 'success',
Expand Down
4 changes: 3 additions & 1 deletion src/locales/en/main.json
Original file line number Diff line number Diff line change
Expand Up @@ -1997,7 +1997,9 @@
"signOut": {
"signOut": "Log Out",
"success": "Signed out successfully",
"successDetail": "You have been signed out of your account."
"successDetail": "You have been signed out of your account.",
"unsavedChangesTitle": "Unsaved Changes",
"unsavedChangesMessage": "You have unsaved changes that will be lost when you sign out. Do you want to continue?"
},
"passwordUpdate": {
"success": "Password Updated",
Expand Down
12 changes: 8 additions & 4 deletions src/platform/workflow/persistence/base/draftCacheV2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,22 +49,26 @@ export function upsertEntry(
[draftKey]: { ...meta, path }
}

const order = touchOrder(index.order, draftKey)
const touchedOrder = touchOrder(index.order, draftKey)
const evicted: string[] = []

while (order.length > effectiveLimit) {
const oldest = order.shift()
let evictCount = 0
while (touchedOrder.length - evictCount > effectiveLimit) {
const oldest = touchedOrder[evictCount]
if (oldest && oldest !== draftKey) {
delete entries[oldest]
evicted.push(oldest)
}
evictCount++
}

const finalOrder = touchedOrder.slice(evictCount)

return {
index: {
v: 2,
updatedAt: Date.now(),
order,
order: finalOrder,
entries
},
evicted
Expand Down
1 change: 0 additions & 1 deletion src/platform/workflow/persistence/base/draftTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,5 +81,4 @@ export interface OpenPathsPointer {
/** Maximum number of drafts to keep per workspace */
export const MAX_DRAFTS = 32

/** @knipIgnoreUsedByStackedPR Used by workflowPersistenceV2.ts (PR #3) */
export const PERSIST_DEBOUNCE_MS = 512
93 changes: 93 additions & 0 deletions src/platform/workflow/persistence/base/storageIO.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,99 @@ describe('storageIO', () => {
it('returns null for missing open paths', () => {
expect(readOpenPaths('missing')).toBeNull()
})

it('falls back to workspace search when clientId does not match and migrates', () => {
const oldClientId = 'old-client'
const newClientId = 'new-client'
const workspaceId = 'ws-123'

// Store pointer with old clientId
const pointer = {
workspaceId,
paths: ['workflows/a.json', 'workflows/b.json'],
activeIndex: 0
}
writeOpenPaths(oldClientId, pointer)

// Read with new clientId but same workspace - should find via fallback
const read = readOpenPaths(newClientId, workspaceId)
expect(read).toEqual(pointer)

// Should have migrated to new key and removed old key
const oldKey = `Comfy.Workflow.OpenPaths:${oldClientId}`
const newKey = `Comfy.Workflow.OpenPaths:${newClientId}`
expect(sessionStorage.getItem(oldKey)).toBeNull()
expect(sessionStorage.getItem(newKey)).not.toBeNull()
})

it('does not fall back to different workspace pointer', () => {
const oldClientId = 'old-client'
const newClientId = 'new-client'

// Store pointer for workspace-A
writeOpenPaths(oldClientId, {
workspaceId: 'workspace-A',
paths: ['workflows/a.json'],
activeIndex: 0
})

// Read with new clientId looking for workspace-B - should not find
const read = readOpenPaths(newClientId, 'workspace-B')
expect(read).toBeNull()
})

it('prefers exact clientId match over fallback search', () => {
const clientId = 'my-client'
const workspaceId = 'ws-123'

// Store pointer with different clientId for same workspace
writeOpenPaths('other-client', {
workspaceId,
paths: ['workflows/old.json'],
activeIndex: 0
})

// Store pointer with exact clientId match
const exactPointer = {
workspaceId,
paths: ['workflows/exact.json'],
activeIndex: 0
}
writeOpenPaths(clientId, exactPointer)

// Should return exact match, not fallback
const read = readOpenPaths(clientId, workspaceId)
expect(read).toEqual(exactPointer)
})

it('removes stale exact match from wrong workspace and falls back', () => {
const clientId = 'my-client'

// Store pointer for workspace-A under this clientId
writeActivePath(clientId, {
workspaceId: 'ws-A',
path: 'workflows/stale.json'
})

// Store pointer for workspace-B under a different clientId
writeActivePath('old-client', {
workspaceId: 'ws-B',
path: 'workflows/correct.json'
})

// Reading with workspace-B should skip the stale ws-A pointer and find the fallback
const result = readActivePath(clientId, 'ws-B')
expect(result).toEqual({
workspaceId: 'ws-B',
path: 'workflows/correct.json'
})

// Stale pointer should have been removed
const raw = sessionStorage.getItem(
`Comfy.Workflow.ActivePath:${clientId}`
)
expect(JSON.parse(raw!).workspaceId).toBe('ws-B')
})
})

describe('clearAllV2Storage', () => {
Expand Down
93 changes: 78 additions & 15 deletions src/platform/workflow/persistence/base/storageIO.ts
Original file line number Diff line number Diff line change
Expand Up @@ -186,20 +186,83 @@ export function deleteOrphanPayloads(
}

/**
* Reads the active path pointer from sessionStorage.
* Searches sessionStorage for a pointer matching the target workspaceId
* when the exact clientId key has no entry (e.g. clientId changed after reload).
* Migrates the found pointer to the new clientId key.
*/
export function readActivePath(clientId: string): ActivePathPointer | null {
function findAndMigratePointer<T extends { workspaceId: string }>(
newKey: string,
prefix: string,
targetWorkspaceId: string
): T | null {
for (let i = 0; i < sessionStorage.length; i++) {
const storageKey = sessionStorage.key(i)
if (!storageKey?.startsWith(prefix) || storageKey === newKey) continue

const json = sessionStorage.getItem(storageKey)
if (!json) continue

try {
const pointer = JSON.parse(json) as T
if (pointer.workspaceId === targetWorkspaceId) {
sessionStorage.setItem(newKey, json)
sessionStorage.removeItem(storageKey)
return pointer
}
} catch {
continue
}
}
return null
}

/**
* Reads a session pointer by clientId with workspace-based fallback.
* Validates workspace on exact match and removes stale cross-workspace pointers.
* If no valid entry exists, searches for any pointer matching the target
* workspaceId and migrates it to the new key.
*/
function readSessionPointer<T extends { workspaceId: string }>(
key: string,
prefix: string,
targetWorkspaceId?: string
): T | null {
try {
const key = StorageKeys.activePath(clientId)
const json = sessionStorage.getItem(key)
if (!json) return null
if (json) {
const pointer = JSON.parse(json) as T
if (targetWorkspaceId && pointer.workspaceId !== targetWorkspaceId) {
sessionStorage.removeItem(key)
} else {
return pointer
}
}

if (targetWorkspaceId) {
return findAndMigratePointer<T>(key, prefix, targetWorkspaceId)
}

return JSON.parse(json) as ActivePathPointer
return null
} catch {
return null
}
}

/**
* Reads the active path pointer from sessionStorage.
* Falls back to workspace-based search when clientId changes after reload.
*/
export function readActivePath(
clientId: string,
targetWorkspaceId?: string
): ActivePathPointer | null {
return readSessionPointer<ActivePathPointer>(
StorageKeys.activePath(clientId),
StorageKeys.prefixes.activePath,
targetWorkspaceId
)
}

/**
* Writes the active path pointer to sessionStorage.
*/
Expand All @@ -217,17 +280,17 @@ export function writeActivePath(

/**
* Reads the open paths pointer from sessionStorage.
* Falls back to workspace-based search when clientId changes after reload.
*/
export function readOpenPaths(clientId: string): OpenPathsPointer | null {
try {
const key = StorageKeys.openPaths(clientId)
const json = sessionStorage.getItem(key)
if (!json) return null

return JSON.parse(json) as OpenPathsPointer
} catch {
return null
}
export function readOpenPaths(
clientId: string,
targetWorkspaceId?: string
): OpenPathsPointer | null {
return readSessionPointer<OpenPathsPointer>(
StorageKeys.openPaths(clientId),
StorageKeys.prefixes.openPaths,
targetWorkspaceId
)
}

/**
Expand Down
Loading
Loading