Skip to content
Merged
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
19 changes: 15 additions & 4 deletions browser_tests/tests/interaction.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -733,6 +733,10 @@ test.describe('Load workflow', { tag: '@screenshot' }, () => {
await expect(comfyPage.canvas).toHaveScreenshot(
'single_ksampler_modified.png'
)
// Wait for V2 persistence debounce (512ms) to save the modified workflow
await comfyPage.page.evaluate(
() => new Promise((resolve) => setTimeout(resolve, 600))
)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
await comfyPage.setup({ clearStorage: false })
await expect(comfyPage.canvas).toHaveScreenshot(
'single_ksampler_modified.png'
Expand All @@ -755,10 +759,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 package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
"size:collect": "node scripts/size-collect.js",
"size:report": "node scripts/size-report.js",
"collect-i18n": "pnpm exec playwright test --config=playwright.i18n.config.ts",
"dev:cloud": "cross-env DEV_SERVER_COMFYUI_URL='https://testcloud.comfy.org/' nx serve",
"dev:cloud": "cross-env DEV_SERVER_COMFYUI_URL='https://stagingcloud.comfy.org/' nx serve",
"dev:desktop": "nx dev @comfyorg/desktop-ui",
"dev:electron": "cross-env DISTRIBUTION=desktop nx serve --config vite.electron.config.mts",
"dev:no-vue": "cross-env DISABLE_VUE_PLUGINS=true nx serve",
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 @@ -1978,7 +1978,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
64 changes: 64 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,70 @@ 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)
})
})

describe('clearAllV2Storage', () => {
Expand Down
72 changes: 66 additions & 6 deletions src/platform/workflow/persistence/base/storageIO.ts
Original file line number Diff line number Diff line change
Expand Up @@ -187,14 +187,44 @@ export function deleteOrphanPayloads(

/**
* Reads the active path pointer from sessionStorage.
* If no pointer exists for the given clientId, searches for any pointer
* matching the target workspaceId (handles clientId changes after reload).
* When found via fallback, migrates the pointer to the new clientId key.
*/
export function readActivePath(clientId: string): ActivePathPointer | null {
export function readActivePath(
clientId: string,
targetWorkspaceId?: string
): ActivePathPointer | null {
try {
// Try exact clientId match first
const key = StorageKeys.activePath(clientId)
const json = sessionStorage.getItem(key)
if (!json) return null
if (json) {
return JSON.parse(json) as ActivePathPointer
}

// Fallback: search for any pointer matching the target workspace
// This handles the case where clientId changed after page reload
if (targetWorkspaceId) {
const prefix = StorageKeys.prefixes.activePath
for (let i = 0; i < sessionStorage.length; i++) {
const storageKey = sessionStorage.key(i)
if (storageKey?.startsWith(prefix) && storageKey !== key) {
const pointerJson = sessionStorage.getItem(storageKey)
if (pointerJson) {
const pointer = JSON.parse(pointerJson) as ActivePathPointer
if (pointer.workspaceId === targetWorkspaceId) {
// Migrate to new clientId key and clean up old key
sessionStorage.setItem(key, pointerJson)
sessionStorage.removeItem(storageKey)
return pointer
}
}
}
}
}

return JSON.parse(json) as ActivePathPointer
return null
Comment thread
coderabbitai[bot] marked this conversation as resolved.
} catch {
return null
}
Expand All @@ -217,14 +247,44 @@ export function writeActivePath(

/**
* Reads the open paths pointer from sessionStorage.
* If no pointer exists for the given clientId, searches for any pointer
* matching the target workspaceId (handles clientId changes after reload).
* When found via fallback, migrates the pointer to the new clientId key.
*/
export function readOpenPaths(clientId: string): OpenPathsPointer | null {
export function readOpenPaths(
clientId: string,
targetWorkspaceId?: string
): OpenPathsPointer | null {
try {
// Try exact clientId match first
const key = StorageKeys.openPaths(clientId)
const json = sessionStorage.getItem(key)
if (!json) return null
if (json) {
return JSON.parse(json) as OpenPathsPointer
}

// Fallback: search for any pointer matching the target workspace
// This handles the case where clientId changed after page reload
if (targetWorkspaceId) {
const prefix = StorageKeys.prefixes.openPaths
for (let i = 0; i < sessionStorage.length; i++) {
const storageKey = sessionStorage.key(i)
if (storageKey?.startsWith(prefix) && storageKey !== key) {
const pointerJson = sessionStorage.getItem(storageKey)
if (pointerJson) {
const pointer = JSON.parse(pointerJson) as OpenPathsPointer
if (pointer.workspaceId === targetWorkspaceId) {
// Migrate to new clientId key and clean up old key
sessionStorage.setItem(key, pointerJson)
sessionStorage.removeItem(storageKey)
return pointer
}
}
}
}
}

return JSON.parse(json) as OpenPathsPointer
return null
} catch {
return null
}
Expand Down
39 changes: 34 additions & 5 deletions src/platform/workflow/persistence/base/storageKeys.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,17 +35,46 @@ describe('storageKeys', () => {
const { getWorkspaceId } = await import('./storageKeys')
expect(getWorkspaceId()).toBe('personal')
})
})

describe('StorageKeys', () => {
it('generates draftIndex key with workspace scope', async () => {
it('returns personal when workspace has no id', async () => {
sessionStorage.setItem(
'Comfy.Workspace.Current',
JSON.stringify({ type: 'team', id: 'ws-123' })
JSON.stringify({ type: 'team', id: '' })
)
const { getWorkspaceId } = await import('./storageKeys')
expect(getWorkspaceId()).toBe('personal')
})

it('reads fresh value on each call (not cached)', async () => {
const { getWorkspaceId } = await import('./storageKeys')

// Initially no workspace set
expect(getWorkspaceId()).toBe('personal')

// Set workspace after import
sessionStorage.setItem(
'Comfy.Workspace.Current',
JSON.stringify({ type: 'team', id: 'ws-new' })
)

// Should read the new value (not cached 'personal')
expect(getWorkspaceId()).toBe('ws-new')

// Change workspace again
sessionStorage.setItem(
'Comfy.Workspace.Current',
JSON.stringify({ type: 'team', id: 'ws-another' })
)

expect(getWorkspaceId()).toBe('ws-another')
})
})

describe('StorageKeys', () => {
it('generates draftIndex key with workspace scope', async () => {
const { StorageKeys } = await import('./storageKeys')

expect(StorageKeys.draftIndex()).toBe(
expect(StorageKeys.draftIndex('ws-123')).toBe(
'Comfy.Workflow.DraftIndex.v2:ws-123'
)
})
Expand Down
Loading
Loading