mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-02-09 01:20:09 +00:00
refactor: parallelize bootstrap and simplify lifecycle with VueUse (#8307)
## Summary Refactors bootstrap and lifecycle management to parallelize initialization, use Vue best practices, and fix a logout state bug. ## Changes ### Bootstrap Store (`bootstrapStore.ts`) - Extract early bootstrap logic into a dedicated store using `useAsyncState` - Parallelize settings, i18n, and workflow sync loading (previously sequential) - Handle multi-user login scenarios by deferring settings/workflows until authenticated ### GraphCanvas Refactoring - Move non-DOM composables (`useGlobalLitegraph`, `useCopy`, `usePaste`, etc.) to script setup level for earlier initialization - Move `watch` and `whenever` declarations outside `onMounted` (Vue best practice) - Use `until()` from VueUse to await bootstrap store readiness instead of direct async calls ### GraphView Simplification - Replace manual `addEventListener`/`removeEventListener` with `useEventListener` - Replace `setInterval` with `useIntervalFn` for automatic cleanup - Move core command registration to script setup level ### Bug Fix Using `router.push()` for logout caused `isSettingsReady` to persist as `true`, making new users inherit the previous user's cached settings. Reverted to `window.location.reload()` with TODOs for future store reset implementation. ## Testing - Verified login/logout cycle clears settings correctly - Verified bootstrap sequence completes without errors --------- Co-authored-by: Amp <amp@ampcode.com>
This commit is contained in:
@@ -300,6 +300,12 @@ When referencing Comfy-Org repos:
|
||||
|
||||
Rules for agent-based coding tasks.
|
||||
|
||||
### Chrome DevTools MCP
|
||||
|
||||
When using `take_snapshot` to inspect dropdowns, listboxes, or other components with dynamic options:
|
||||
- Use `verbose: true` to see the full accessibility tree including list items
|
||||
- Non-verbose snapshots often omit nested options in comboboxes/listboxes
|
||||
|
||||
### Temporary Files
|
||||
|
||||
- Put planning documents under `/temp/plans/`
|
||||
|
||||
@@ -38,6 +38,10 @@ test.describe('Version Mismatch Warnings', () => {
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
await comfyPage.setSetting(
|
||||
'Comfy.VersionCompatibility.DisableWarnings',
|
||||
false
|
||||
)
|
||||
})
|
||||
|
||||
test('should show version mismatch warnings when installed version lower than required', async ({
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
- Clear public interfaces
|
||||
- Restrict extension access
|
||||
- Clean up subscriptions
|
||||
- Only expose state/actions that are used externally; keep internal state private
|
||||
|
||||
## General Guidelines
|
||||
|
||||
|
||||
@@ -10,7 +10,6 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { captureException } from '@sentry/vue'
|
||||
import { useEventListener } from '@vueuse/core'
|
||||
import BlockUI from 'primevue/blockui'
|
||||
import ProgressSpinner from 'primevue/progressspinner'
|
||||
import { computed, onMounted } from 'vue'
|
||||
@@ -21,15 +20,13 @@ import { useWorkspaceStore } from '@/stores/workspaceStore'
|
||||
import { useConflictDetection } from '@/workbench/extensions/manager/composables/useConflictDetection'
|
||||
|
||||
import { electronAPI, isElectron } from './utils/envUtil'
|
||||
import { app } from '@/scripts/app'
|
||||
|
||||
const workspaceStore = useWorkspaceStore()
|
||||
app.extensionManager = useWorkspaceStore()
|
||||
|
||||
const conflictDetection = useConflictDetection()
|
||||
const isLoading = computed<boolean>(() => workspaceStore.spinner)
|
||||
const handleKey = (e: KeyboardEvent) => {
|
||||
workspaceStore.shiftDown = e.shiftKey
|
||||
}
|
||||
useEventListener(window, 'keydown', handleKey)
|
||||
useEventListener(window, 'keyup', handleKey)
|
||||
|
||||
const showContextMenu = (event: MouseEvent) => {
|
||||
const { target } = event
|
||||
|
||||
@@ -93,7 +93,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useEventListener, whenever } from '@vueuse/core'
|
||||
import { until, useEventListener } from '@vueuse/core'
|
||||
import {
|
||||
computed,
|
||||
nextTick,
|
||||
@@ -129,7 +129,7 @@ import { useCopy } from '@/composables/useCopy'
|
||||
import { useGlobalLitegraph } from '@/composables/useGlobalLitegraph'
|
||||
import { usePaste } from '@/composables/usePaste'
|
||||
import { useVueFeatureFlags } from '@/composables/useVueFeatureFlags'
|
||||
import { mergeCustomNodesI18n, t } from '@/i18n'
|
||||
import { t } from '@/i18n'
|
||||
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import { useLitegraphSettings } from '@/platform/settings/composables/useLitegraphSettings'
|
||||
import { CORE_SETTINGS } from '@/platform/settings/constants/coreSettings'
|
||||
@@ -144,12 +144,15 @@ import { useCanvasInteractions } from '@/renderer/core/canvas/useCanvasInteracti
|
||||
import TransformPane from '@/renderer/core/layout/transform/TransformPane.vue'
|
||||
import MiniMap from '@/renderer/extensions/minimap/MiniMap.vue'
|
||||
import LGraphNode from '@/renderer/extensions/vueNodes/components/LGraphNode.vue'
|
||||
import { UnauthorizedError, api } from '@/scripts/api'
|
||||
import { UnauthorizedError } from '@/scripts/api'
|
||||
import { app as comfyApp } from '@/scripts/app'
|
||||
import { ChangeTracker } from '@/scripts/changeTracker'
|
||||
import { IS_CONTROL_WIDGET, updateControlWidgetLabel } from '@/scripts/widgets'
|
||||
import { useColorPaletteService } from '@/services/colorPaletteService'
|
||||
import { newUserService } from '@/services/newUserService'
|
||||
import { storeToRefs } from 'pinia'
|
||||
|
||||
import { useBootstrapStore } from '@/stores/bootstrapStore'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useExecutionStore } from '@/stores/executionStore'
|
||||
import { useNodeDefStore } from '@/stores/nodeDefStore'
|
||||
@@ -175,11 +178,16 @@ const settingStore = useSettingStore()
|
||||
const nodeDefStore = useNodeDefStore()
|
||||
const workspaceStore = useWorkspaceStore()
|
||||
const canvasStore = useCanvasStore()
|
||||
const workflowStore = useWorkflowStore()
|
||||
const executionStore = useExecutionStore()
|
||||
const toastStore = useToastStore()
|
||||
const colorPaletteStore = useColorPaletteStore()
|
||||
const colorPaletteService = useColorPaletteService()
|
||||
const canvasInteractions = useCanvasInteractions()
|
||||
const bootstrapStore = useBootstrapStore()
|
||||
const { isI18nReady, i18nError } = storeToRefs(bootstrapStore)
|
||||
const { isReady: isSettingsReady, error: settingsError } =
|
||||
storeToRefs(settingStore)
|
||||
|
||||
const betaMenuEnabled = computed(
|
||||
() => settingStore.get('Comfy.UseNewMenu') !== 'Disabled'
|
||||
@@ -386,15 +394,6 @@ useEventListener(
|
||||
{ passive: true }
|
||||
)
|
||||
|
||||
const loadCustomNodesI18n = async () => {
|
||||
try {
|
||||
const i18nData = await api.getCustomNodesI18n()
|
||||
mergeCustomNodesI18n(i18nData)
|
||||
} catch (error) {
|
||||
console.error('Failed to load custom nodes i18n', error)
|
||||
}
|
||||
}
|
||||
|
||||
const comfyAppReady = ref(false)
|
||||
const workflowPersistence = useWorkflowPersistence()
|
||||
const { flags } = useFeatureFlags()
|
||||
@@ -404,35 +403,72 @@ useCanvasDrop(canvasRef)
|
||||
useLitegraphSettings()
|
||||
useNodeBadge()
|
||||
|
||||
useGlobalLitegraph()
|
||||
useContextMenuTranslation()
|
||||
useCopy()
|
||||
usePaste()
|
||||
useWorkflowAutoSave()
|
||||
|
||||
// Start watching for locale change after the initial value is loaded.
|
||||
watch(
|
||||
() => settingStore.get('Comfy.Locale'),
|
||||
async (_newLocale, oldLocale) => {
|
||||
if (!oldLocale) return
|
||||
await Promise.all([
|
||||
until(() => isSettingsReady.value || !!settingsError.value).toBe(true),
|
||||
until(() => isI18nReady.value || !!i18nError.value).toBe(true)
|
||||
])
|
||||
if (settingsError.value || i18nError.value) {
|
||||
console.warn(
|
||||
'Somehow the Locale setting was changed while the settings or i18n had a setup error'
|
||||
)
|
||||
}
|
||||
await useCommandStore().execute('Comfy.RefreshNodeDefinitions')
|
||||
await useWorkflowService().reloadCurrentWorkflow()
|
||||
}
|
||||
)
|
||||
useEventListener(
|
||||
() => canvasStore.canvas?.canvas,
|
||||
'litegraph:set-graph',
|
||||
() => {
|
||||
workflowStore.updateActiveGraph()
|
||||
}
|
||||
)
|
||||
|
||||
onMounted(async () => {
|
||||
useGlobalLitegraph()
|
||||
useContextMenuTranslation()
|
||||
useCopy()
|
||||
usePaste()
|
||||
useWorkflowAutoSave()
|
||||
useVueFeatureFlags()
|
||||
|
||||
comfyApp.vueAppReady = true
|
||||
|
||||
workspaceStore.spinner = true
|
||||
// ChangeTracker needs to be initialized before setup, as it will overwrite
|
||||
// some listeners of litegraph canvas.
|
||||
ChangeTracker.init()
|
||||
await loadCustomNodesI18n()
|
||||
try {
|
||||
await settingStore.loadSettingValues()
|
||||
} catch (error) {
|
||||
if (error instanceof UnauthorizedError) {
|
||||
|
||||
await until(() => isSettingsReady.value || !!settingsError.value).toBe(true)
|
||||
|
||||
if (settingsError.value) {
|
||||
if (settingsError.value instanceof UnauthorizedError) {
|
||||
localStorage.removeItem('Comfy.userId')
|
||||
localStorage.removeItem('Comfy.userName')
|
||||
window.location.reload()
|
||||
} else {
|
||||
throw error
|
||||
return
|
||||
}
|
||||
throw settingsError.value
|
||||
}
|
||||
|
||||
// Register core settings immediately after settings are ready
|
||||
CORE_SETTINGS.forEach(settingStore.addSetting)
|
||||
|
||||
await newUserService().initializeIfNewUser(settingStore)
|
||||
// Wait for both i18n and newUserService in parallel
|
||||
// (newUserService only needs settings, not i18n)
|
||||
await Promise.all([
|
||||
until(() => isI18nReady.value || !!i18nError.value).toBe(true),
|
||||
newUserService().initializeIfNewUser(settingStore)
|
||||
])
|
||||
if (i18nError.value) {
|
||||
console.warn(
|
||||
'[GraphCanvas] Failed to load custom nodes i18n:',
|
||||
i18nError.value
|
||||
)
|
||||
}
|
||||
|
||||
// @ts-expect-error fixme ts strict error
|
||||
await comfyApp.setup(canvasRef.value)
|
||||
@@ -487,25 +523,6 @@ onMounted(async () => {
|
||||
const releaseStore = useReleaseStore()
|
||||
void releaseStore.initialize()
|
||||
|
||||
// Start watching for locale change after the initial value is loaded.
|
||||
watch(
|
||||
() => settingStore.get('Comfy.Locale'),
|
||||
async () => {
|
||||
await useCommandStore().execute('Comfy.RefreshNodeDefinitions')
|
||||
await useWorkflowService().reloadCurrentWorkflow()
|
||||
}
|
||||
)
|
||||
|
||||
whenever(
|
||||
() => useCanvasStore().canvas,
|
||||
(canvas) => {
|
||||
useEventListener(canvas.canvas, 'litegraph:set-graph', () => {
|
||||
useWorkflowStore().updateActiveGraph()
|
||||
})
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
emit('ready')
|
||||
})
|
||||
|
||||
|
||||
@@ -71,6 +71,9 @@ export const useNodeBadge = () => {
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (extensionStore.isExtensionInstalled('Comfy.NodeBadge')) return
|
||||
|
||||
// TODO: Fix the composables and watchers being setup in onMounted
|
||||
const nodePricing = useNodePricing()
|
||||
|
||||
watch(
|
||||
|
||||
@@ -167,25 +167,30 @@ describe('useCoreCommands', () => {
|
||||
return {
|
||||
get: vi.fn().mockReturnValue(getReturnValue),
|
||||
addSetting: vi.fn(),
|
||||
loadSettingValues: vi.fn(),
|
||||
load: vi.fn(),
|
||||
set: vi.fn(),
|
||||
exists: vi.fn(),
|
||||
getDefaultValue: vi.fn(),
|
||||
isReady: true,
|
||||
isLoading: false,
|
||||
error: undefined,
|
||||
settingValues: {},
|
||||
settingsById: {},
|
||||
$id: 'setting',
|
||||
$state: {
|
||||
settingValues: {},
|
||||
settingsById: {}
|
||||
settingsById: {},
|
||||
isReady: true,
|
||||
isLoading: false,
|
||||
error: undefined
|
||||
},
|
||||
$patch: vi.fn(),
|
||||
$reset: vi.fn(),
|
||||
$subscribe: vi.fn(),
|
||||
$onAction: vi.fn(),
|
||||
$dispose: vi.fn(),
|
||||
_customProperties: new Set(),
|
||||
_p: {}
|
||||
} as ReturnType<typeof useSettingStore>
|
||||
_customProperties: new Set()
|
||||
} satisfies ReturnType<typeof useSettingStore>
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
|
||||
@@ -67,10 +67,9 @@ import { useWorkflowTemplateSelectorDialog } from './useWorkflowTemplateSelector
|
||||
import { useMaskEditorStore } from '@/stores/maskEditorStore'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
|
||||
const { isActiveSubscription, showSubscriptionDialog } = useSubscription()
|
||||
|
||||
const moveSelectedNodesVersionAdded = '1.22.2'
|
||||
export function useCoreCommands(): ComfyCommand[] {
|
||||
const { isActiveSubscription, showSubscriptionDialog } = useSubscription()
|
||||
const workflowService = useWorkflowService()
|
||||
const workflowStore = useWorkflowStore()
|
||||
const dialogService = useDialogService()
|
||||
|
||||
@@ -14,6 +14,7 @@ import { VueFire, VueFireAuth } from 'vuefire'
|
||||
import { getFirebaseConfig } from '@/config/firebase'
|
||||
import '@/lib/litegraph/public/css/litegraph.css'
|
||||
import router from '@/router'
|
||||
import { useBootstrapStore } from '@/stores/bootstrapStore'
|
||||
|
||||
import App from './App.vue'
|
||||
// Intentionally relative import to ensure the CSS is loaded in the right order (after litegraph.css)
|
||||
@@ -46,6 +47,7 @@ const firebaseApp = initializeApp(getFirebaseConfig())
|
||||
|
||||
const app = createApp(App)
|
||||
const pinia = createPinia()
|
||||
|
||||
Sentry.init({
|
||||
app,
|
||||
dsn: __SENTRY_DSN__,
|
||||
@@ -91,4 +93,7 @@ app
|
||||
modules: [VueFireAuth()]
|
||||
})
|
||||
|
||||
const bootstrapStore = useBootstrapStore(pinia)
|
||||
void bootstrapStore.startStoreBootstrap()
|
||||
|
||||
app.mount('#vue-app')
|
||||
|
||||
@@ -252,6 +252,10 @@ function useSubscriptionInternal() {
|
||||
if (loggedIn) {
|
||||
try {
|
||||
await fetchSubscriptionStatus()
|
||||
} catch (error) {
|
||||
// Network errors are expected during navigation/component unmount
|
||||
// and when offline - log for debugging but don't surface to user
|
||||
console.error('Failed to fetch subscription status:', error)
|
||||
} finally {
|
||||
isInitialized.value = true
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import {
|
||||
@@ -33,7 +34,7 @@ describe('useSettingStore', () => {
|
||||
let store: ReturnType<typeof useSettingStore>
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
store = useSettingStore()
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
@@ -43,20 +44,20 @@ describe('useSettingStore', () => {
|
||||
expect(store.settingsById).toEqual({})
|
||||
})
|
||||
|
||||
describe('loadSettingValues', () => {
|
||||
describe('load', () => {
|
||||
it('should load settings from API', async () => {
|
||||
const mockSettings = { 'test.setting': 'value' }
|
||||
vi.mocked(api.getSettings).mockResolvedValue(
|
||||
mockSettings as Partial<Settings> as Settings
|
||||
)
|
||||
|
||||
await store.loadSettingValues()
|
||||
await store.load()
|
||||
|
||||
expect(store.settingValues).toEqual(mockSettings)
|
||||
expect(api.getSettings).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should throw error if settings are loaded after registration', async () => {
|
||||
it('should set error if settings are loaded after registration', async () => {
|
||||
const setting: SettingParams = {
|
||||
id: 'test.setting',
|
||||
name: 'test.setting',
|
||||
@@ -65,9 +66,14 @@ describe('useSettingStore', () => {
|
||||
}
|
||||
store.addSetting(setting)
|
||||
|
||||
await expect(store.loadSettingValues()).rejects.toThrow(
|
||||
'Setting values must be loaded before any setting is registered.'
|
||||
)
|
||||
await store.load()
|
||||
|
||||
expect(store.error).toBeInstanceOf(Error)
|
||||
if (store.error instanceof Error) {
|
||||
expect(store.error.message).toBe(
|
||||
'Setting values must be loaded before any setting is registered.'
|
||||
)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -85,18 +91,24 @@ describe('useSettingStore', () => {
|
||||
expect(store.settingsById['test.setting']).toEqual(setting)
|
||||
})
|
||||
|
||||
it('should throw error for duplicate setting ID', () => {
|
||||
it('should warn and skip for duplicate setting ID', () => {
|
||||
const setting: SettingParams = {
|
||||
id: 'test.setting',
|
||||
name: 'test.setting',
|
||||
type: 'text',
|
||||
defaultValue: 'default'
|
||||
}
|
||||
const consoleWarnSpy = vi
|
||||
.spyOn(console, 'warn')
|
||||
.mockImplementation(() => {})
|
||||
|
||||
store.addSetting(setting)
|
||||
expect(() => store.addSetting(setting)).toThrow(
|
||||
'Setting test.setting must have a unique ID.'
|
||||
store.addSetting(setting)
|
||||
|
||||
expect(consoleWarnSpy).toHaveBeenCalledWith(
|
||||
'Setting already registered: test.setting'
|
||||
)
|
||||
consoleWarnSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('should migrate deprecated values', () => {
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { retry } from 'es-toolkit'
|
||||
import _ from 'es-toolkit/compat'
|
||||
import { until, useAsyncState } from '@vueuse/core'
|
||||
import { defineStore } from 'pinia'
|
||||
import { compare, valid } from 'semver'
|
||||
import { ref } from 'vue'
|
||||
@@ -47,6 +49,39 @@ export const useSettingStore = defineStore('setting', () => {
|
||||
const settingValues = ref<Record<string, any>>({})
|
||||
const settingsById = ref<Record<string, SettingParams>>({})
|
||||
|
||||
const {
|
||||
isReady,
|
||||
isLoading,
|
||||
error,
|
||||
execute: loadSettingValues
|
||||
} = useAsyncState(
|
||||
async () => {
|
||||
if (Object.keys(settingsById.value).length) {
|
||||
throw new Error(
|
||||
'Setting values must be loaded before any setting is registered.'
|
||||
)
|
||||
}
|
||||
settingValues.value = await retry(() => api.getSettings(), {
|
||||
retries: 3,
|
||||
delay: (attempt) => Math.min(1000 * Math.pow(2, attempt), 8000)
|
||||
})
|
||||
await migrateZoomThresholdToFontSize()
|
||||
},
|
||||
undefined,
|
||||
{ immediate: false }
|
||||
)
|
||||
|
||||
async function load(): Promise<void> {
|
||||
if (isReady.value) return
|
||||
|
||||
if (isLoading.value) {
|
||||
await until(isLoading).toBe(false)
|
||||
return
|
||||
}
|
||||
|
||||
await loadSettingValues()
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a setting's value exists, i.e. if the user has set it manually.
|
||||
* @param key - The key of the setting to check.
|
||||
@@ -170,7 +205,11 @@ export const useSettingStore = defineStore('setting', () => {
|
||||
throw new Error('Settings must have an ID')
|
||||
}
|
||||
if (setting.id in settingsById.value) {
|
||||
throw new Error(`Setting ${setting.id} must have a unique ID.`)
|
||||
// Setting already registered - skip to allow component remounting
|
||||
// TODO: Add store reset methods to bootstrapStore and settingStore, then
|
||||
// replace window.location.reload() with router.push() in SidebarLogoutIcon.vue
|
||||
console.warn(`Setting already registered: ${setting.id}`)
|
||||
return
|
||||
}
|
||||
|
||||
settingsById.value[setting.id] = setting
|
||||
@@ -184,22 +223,6 @@ export const useSettingStore = defineStore('setting', () => {
|
||||
onChange(setting, get(setting.id), undefined)
|
||||
}
|
||||
|
||||
/*
|
||||
* Load setting values from server.
|
||||
* This needs to be called before any setting is registered.
|
||||
*/
|
||||
async function loadSettingValues() {
|
||||
if (Object.keys(settingsById.value).length) {
|
||||
throw new Error(
|
||||
'Setting values must be loaded before any setting is registered.'
|
||||
)
|
||||
}
|
||||
settingValues.value = await api.getSettings()
|
||||
|
||||
// Migrate old zoom threshold setting to new font size setting
|
||||
await migrateZoomThresholdToFontSize()
|
||||
}
|
||||
|
||||
/**
|
||||
* Migrate the old zoom threshold setting to the new font size setting.
|
||||
* Preserves the exact zoom threshold behavior by converting it to equivalent font size.
|
||||
@@ -242,8 +265,11 @@ export const useSettingStore = defineStore('setting', () => {
|
||||
return {
|
||||
settingValues,
|
||||
settingsById,
|
||||
isReady,
|
||||
isLoading,
|
||||
error,
|
||||
load,
|
||||
addSetting,
|
||||
loadSettingValues,
|
||||
set,
|
||||
get,
|
||||
exists,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import _ from 'es-toolkit/compat'
|
||||
import { until, useAsyncState } from '@vueuse/core'
|
||||
import { defineStore } from 'pinia'
|
||||
import { computed, markRaw, ref, shallowRef, watch } from 'vue'
|
||||
import type { Raw } from 'vue'
|
||||
@@ -531,48 +532,72 @@ export const useWorkflowStore = defineStore('workflow', () => {
|
||||
workflow.isPersisted && !workflow.path.startsWith('subgraphs/')
|
||||
)
|
||||
)
|
||||
const syncWorkflows = async (dir: string = '') => {
|
||||
await syncEntities(
|
||||
dir ? 'workflows/' + dir : 'workflows',
|
||||
workflowLookup.value,
|
||||
(file) =>
|
||||
new ComfyWorkflow({
|
||||
path: file.path,
|
||||
modified: file.modified,
|
||||
size: file.size
|
||||
}),
|
||||
(existingWorkflow, file) => {
|
||||
const isActiveWorkflow =
|
||||
activeWorkflow.value?.path === existingWorkflow.path
|
||||
|
||||
const nextLastModified = Math.max(
|
||||
existingWorkflow.lastModified,
|
||||
file.modified
|
||||
)
|
||||
const {
|
||||
isReady: isSyncReady,
|
||||
isLoading: isSyncLoading,
|
||||
execute: executeSyncWorkflows
|
||||
} = useAsyncState(
|
||||
async (dir: string = '') => {
|
||||
await syncEntities(
|
||||
dir ? 'workflows/' + dir : 'workflows',
|
||||
workflowLookup.value,
|
||||
(file) =>
|
||||
new ComfyWorkflow({
|
||||
path: file.path,
|
||||
modified: file.modified,
|
||||
size: file.size
|
||||
}),
|
||||
(existingWorkflow, file) => {
|
||||
const isActiveWorkflow =
|
||||
activeWorkflow.value?.path === existingWorkflow.path
|
||||
|
||||
const isMetadataUnchanged =
|
||||
nextLastModified === existingWorkflow.lastModified &&
|
||||
file.size === existingWorkflow.size
|
||||
const nextLastModified = Math.max(
|
||||
existingWorkflow.lastModified,
|
||||
file.modified
|
||||
)
|
||||
|
||||
if (!isMetadataUnchanged) {
|
||||
existingWorkflow.lastModified = nextLastModified
|
||||
existingWorkflow.size = file.size
|
||||
}
|
||||
const isMetadataUnchanged =
|
||||
nextLastModified === existingWorkflow.lastModified &&
|
||||
file.size === existingWorkflow.size
|
||||
|
||||
// Never unload the active workflow - it may contain unsaved in-memory edits.
|
||||
if (isActiveWorkflow) {
|
||||
return
|
||||
}
|
||||
if (!isMetadataUnchanged) {
|
||||
existingWorkflow.lastModified = nextLastModified
|
||||
existingWorkflow.size = file.size
|
||||
}
|
||||
|
||||
// If nothing changed, keep any loaded content cached.
|
||||
if (isMetadataUnchanged) {
|
||||
return
|
||||
}
|
||||
// Never unload the active workflow - it may contain unsaved in-memory edits.
|
||||
if (isActiveWorkflow) {
|
||||
return
|
||||
}
|
||||
|
||||
existingWorkflow.unload()
|
||||
},
|
||||
/* exclude */ (workflow) => workflow.isTemporary
|
||||
)
|
||||
// If nothing changed, keep any loaded content cached.
|
||||
if (isMetadataUnchanged) {
|
||||
return
|
||||
}
|
||||
|
||||
existingWorkflow.unload()
|
||||
},
|
||||
/* exclude */ (workflow) => workflow.isTemporary
|
||||
)
|
||||
},
|
||||
undefined,
|
||||
{ immediate: false }
|
||||
)
|
||||
|
||||
async function syncWorkflows(dir: string = '') {
|
||||
return executeSyncWorkflows(0, dir)
|
||||
}
|
||||
|
||||
async function loadWorkflows(): Promise<void> {
|
||||
if (isSyncReady.value) return
|
||||
|
||||
if (isSyncLoading.value) {
|
||||
await until(isSyncLoading).toBe(false)
|
||||
return
|
||||
}
|
||||
|
||||
await syncWorkflows()
|
||||
}
|
||||
|
||||
const bookmarkStore = useWorkflowBookmarkStore()
|
||||
@@ -884,6 +909,7 @@ export const useWorkflowStore = defineStore('workflow', () => {
|
||||
modifiedWorkflows,
|
||||
getWorkflowByPath,
|
||||
syncWorkflows,
|
||||
loadWorkflows,
|
||||
|
||||
isSubgraphActive,
|
||||
activeSubgraph,
|
||||
|
||||
@@ -147,20 +147,10 @@ describe('useMinimapGraph', () => {
|
||||
})
|
||||
|
||||
it('should handle cleanup for never-setup graph', () => {
|
||||
const consoleErrorSpy = vi
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => {})
|
||||
|
||||
const graphRef = ref(mockGraph) as Ref<LGraph | null>
|
||||
const graphManager = useMinimapGraph(graphRef, onGraphChangedMock)
|
||||
|
||||
graphManager.cleanupEventListeners()
|
||||
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
'Attempted to cleanup event listeners for graph that was never set up'
|
||||
)
|
||||
|
||||
consoleErrorSpy.mockRestore()
|
||||
expect(() => graphManager.cleanupEventListeners()).not.toThrow()
|
||||
})
|
||||
|
||||
it('should detect node position changes', () => {
|
||||
|
||||
@@ -102,9 +102,7 @@ export function useMinimapGraph(
|
||||
|
||||
const originalCallbacks = originalCallbacksMap.get(g.id)
|
||||
if (!originalCallbacks) {
|
||||
console.error(
|
||||
'Attempted to cleanup event listeners for graph that was never set up'
|
||||
)
|
||||
// Graph was never set up (e.g., minimap destroyed before init) - nothing to clean up
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
115
src/stores/bootstrapStore.test.ts
Normal file
115
src/stores/bootstrapStore.test.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
|
||||
import { useBootstrapStore } from './bootstrapStore'
|
||||
|
||||
vi.mock('@/scripts/api', () => ({
|
||||
api: {
|
||||
init: vi.fn().mockResolvedValue(undefined),
|
||||
getNodeDefs: vi.fn().mockResolvedValue({ TestNode: { name: 'TestNode' } }),
|
||||
getCustomNodesI18n: vi.fn().mockResolvedValue({}),
|
||||
getUserConfig: vi.fn().mockResolvedValue({})
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/i18n', () => ({
|
||||
mergeCustomNodesI18n: vi.fn()
|
||||
}))
|
||||
|
||||
const mockIsSettingsReady = ref(false)
|
||||
|
||||
vi.mock('@/platform/settings/settingStore', () => ({
|
||||
useSettingStore: vi.fn(() => ({
|
||||
load: vi.fn(() => {
|
||||
mockIsSettingsReady.value = true
|
||||
}),
|
||||
get isReady() {
|
||||
return mockIsSettingsReady.value
|
||||
},
|
||||
isLoading: ref(false),
|
||||
error: ref(undefined)
|
||||
}))
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
|
||||
useWorkflowStore: vi.fn(() => ({
|
||||
loadWorkflows: vi.fn(),
|
||||
syncWorkflows: vi.fn().mockResolvedValue(undefined)
|
||||
}))
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/userStore', () => ({
|
||||
useUserStore: vi.fn(() => ({
|
||||
initialize: vi.fn().mockResolvedValue(undefined),
|
||||
needsLogin: false
|
||||
}))
|
||||
}))
|
||||
|
||||
const mockIsFirebaseInitialized = ref(false)
|
||||
vi.mock('@/stores/firebaseAuthStore', () => ({
|
||||
useFirebaseAuthStore: vi.fn(() => ({
|
||||
isInitialized: mockIsFirebaseInitialized
|
||||
}))
|
||||
}))
|
||||
|
||||
const mockDistributionTypes = vi.hoisted(() => ({
|
||||
isCloud: false
|
||||
}))
|
||||
vi.mock('@/platform/distribution/types', () => mockDistributionTypes)
|
||||
|
||||
describe('bootstrapStore', () => {
|
||||
beforeEach(() => {
|
||||
mockIsSettingsReady.value = false
|
||||
mockIsFirebaseInitialized.value = false
|
||||
mockDistributionTypes.isCloud = false
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('initializes with all flags false', () => {
|
||||
const store = useBootstrapStore()
|
||||
const settingStore = useSettingStore()
|
||||
expect(settingStore.isReady).toBe(false)
|
||||
expect(store.isI18nReady).toBe(false)
|
||||
})
|
||||
|
||||
it('starts store bootstrap (settings, i18n)', async () => {
|
||||
const store = useBootstrapStore()
|
||||
const settingStore = useSettingStore()
|
||||
void store.startStoreBootstrap()
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(settingStore.isReady).toBe(true)
|
||||
expect(store.isI18nReady).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('cloud mode', () => {
|
||||
beforeEach(() => {
|
||||
mockDistributionTypes.isCloud = true
|
||||
})
|
||||
|
||||
it('waits for Firebase auth before loading i18n and settings', async () => {
|
||||
const store = useBootstrapStore()
|
||||
const settingStore = useSettingStore()
|
||||
const bootstrapPromise = store.startStoreBootstrap()
|
||||
|
||||
// Bootstrap is blocked waiting for firebase
|
||||
expect(store.isI18nReady).toBe(false)
|
||||
expect(settingStore.isReady).toBe(false)
|
||||
|
||||
// Unblock by initializing firebase
|
||||
mockIsFirebaseInitialized.value = true
|
||||
await bootstrapPromise
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(store.isI18nReady).toBe(true)
|
||||
expect(settingStore.isReady).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
54
src/stores/bootstrapStore.ts
Normal file
54
src/stores/bootstrapStore.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { until, useAsyncState } from '@vueuse/core'
|
||||
import { defineStore, storeToRefs } from 'pinia'
|
||||
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { api } from '@/scripts/api'
|
||||
import { useUserStore } from '@/stores/userStore'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
|
||||
|
||||
export const useBootstrapStore = defineStore('bootstrap', () => {
|
||||
const settingStore = useSettingStore()
|
||||
const workflowStore = useWorkflowStore()
|
||||
|
||||
const {
|
||||
isReady: isI18nReady,
|
||||
error: i18nError,
|
||||
execute: loadI18n
|
||||
} = useAsyncState(
|
||||
async () => {
|
||||
const { mergeCustomNodesI18n } = await import('@/i18n')
|
||||
const i18nData = await api.getCustomNodesI18n()
|
||||
mergeCustomNodesI18n(i18nData)
|
||||
},
|
||||
undefined,
|
||||
{ immediate: false }
|
||||
)
|
||||
|
||||
async function startStoreBootstrap() {
|
||||
// Defer settings and workflows if multi-user login is required
|
||||
// (settings API requires authentication in multi-user mode)
|
||||
const userStore = useUserStore()
|
||||
await userStore.initialize()
|
||||
|
||||
if (isCloud) {
|
||||
const { isInitialized } = storeToRefs(useFirebaseAuthStore())
|
||||
await until(isInitialized).toBe(true)
|
||||
}
|
||||
|
||||
// i18n can load without authentication
|
||||
void loadI18n()
|
||||
|
||||
if (!userStore.needsLogin) {
|
||||
void settingStore.load()
|
||||
void workflowStore.loadWorkflows()
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
isI18nReady,
|
||||
i18nError,
|
||||
startStoreBootstrap
|
||||
}
|
||||
})
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useMagicKeys } from '@vueuse/core'
|
||||
import { defineStore } from 'pinia'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
@@ -18,7 +19,7 @@ import { useSidebarTabStore } from './workspace/sidebarTabStore'
|
||||
|
||||
export const useWorkspaceStore = defineStore('workspace', () => {
|
||||
const spinner = ref(false)
|
||||
const shiftDown = ref(false)
|
||||
const { shift: shiftDown } = useMagicKeys()
|
||||
/**
|
||||
* Whether the workspace is in focus mode.
|
||||
* When in focus mode, only the graph editor is visible.
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useEventListener } from '@vueuse/core'
|
||||
import { useEventListener, useIntervalFn } from '@vueuse/core'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import type { ToastMessageOptions } from 'primevue/toast'
|
||||
import { useToast } from 'primevue/usetoast'
|
||||
@@ -79,7 +79,6 @@ import { useServerConfigStore } from '@/stores/serverConfigStore'
|
||||
import { useBottomPanelStore } from '@/stores/workspace/bottomPanelStore'
|
||||
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
|
||||
import { useSidebarTabStore } from '@/stores/workspace/sidebarTabStore'
|
||||
import { useWorkspaceStore } from '@/stores/workspaceStore'
|
||||
import { electronAPI, isElectron } from '@/utils/envUtil'
|
||||
import LinearView from '@/views/LinearView.vue'
|
||||
import ManagerProgressToast from '@/workbench/extensions/manager/components/ManagerProgressToast.vue'
|
||||
@@ -102,9 +101,6 @@ const { linearMode } = storeToRefs(useCanvasStore())
|
||||
const telemetry = useTelemetry()
|
||||
const firebaseAuthStore = useFirebaseAuthStore()
|
||||
let hasTrackedLogin = false
|
||||
let visibilityListener: (() => void) | null = null
|
||||
let tabCountInterval: number | null = null
|
||||
let tabCountChannel: BroadcastChannel | null = null
|
||||
|
||||
watch(
|
||||
() => colorPaletteStore.completedActivePalette,
|
||||
@@ -198,15 +194,12 @@ watchEffect(() => {
|
||||
queueStore.maxHistoryItems = settingStore.get('Comfy.Queue.MaxHistoryItems')
|
||||
})
|
||||
|
||||
const init = () => {
|
||||
const coreCommands = useCoreCommands()
|
||||
useCommandStore().registerCommands(coreCommands)
|
||||
useMenuItemStore().registerCoreMenuCommands()
|
||||
useKeybindingService().registerCoreKeybindings()
|
||||
useSidebarTabStore().registerCoreSidebarTabs()
|
||||
useBottomPanelStore().registerCoreBottomPanelTabs()
|
||||
app.extensionManager = useWorkspaceStore()
|
||||
}
|
||||
const coreCommands = useCoreCommands()
|
||||
useCommandStore().registerCommands(coreCommands)
|
||||
useMenuItemStore().registerCoreMenuCommands()
|
||||
useKeybindingService().registerCoreKeybindings()
|
||||
useSidebarTabStore().registerCoreSidebarTabs()
|
||||
useBottomPanelStore().registerCoreBottomPanelTabs()
|
||||
|
||||
const queuePendingTaskCountStore = useQueuePendingTaskCountStore()
|
||||
const sidebarTabStore = useSidebarTabStore()
|
||||
@@ -274,15 +267,15 @@ if (isCloud) {
|
||||
)
|
||||
}
|
||||
|
||||
useEventListener(api, 'status', onStatus)
|
||||
useEventListener(api, 'execution_success', onExecutionSuccess)
|
||||
useEventListener(api, 'reconnecting', onReconnecting)
|
||||
useEventListener(api, 'reconnected', onReconnected)
|
||||
|
||||
onMounted(() => {
|
||||
api.addEventListener('status', onStatus)
|
||||
api.addEventListener('execution_success', onExecutionSuccess)
|
||||
api.addEventListener('reconnecting', onReconnecting)
|
||||
api.addEventListener('reconnected', onReconnected)
|
||||
executionStore.bindExecutionEvents()
|
||||
|
||||
try {
|
||||
init()
|
||||
// Relocate the legacy menu container to the graph canvas container so it is below other elements
|
||||
graphCanvasContainerRef.value?.prepend(app.ui.menuContainer)
|
||||
} catch (e) {
|
||||
@@ -291,27 +284,7 @@ onMounted(() => {
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
api.removeEventListener('status', onStatus)
|
||||
api.removeEventListener('execution_success', onExecutionSuccess)
|
||||
api.removeEventListener('reconnecting', onReconnecting)
|
||||
api.removeEventListener('reconnected', onReconnected)
|
||||
executionStore.unbindExecutionEvents()
|
||||
|
||||
// Clean up page visibility listener
|
||||
if (visibilityListener) {
|
||||
document.removeEventListener('visibilitychange', visibilityListener)
|
||||
visibilityListener = null
|
||||
}
|
||||
|
||||
// Clean up tab count tracking
|
||||
if (tabCountInterval) {
|
||||
window.clearInterval(tabCountInterval)
|
||||
tabCountInterval = null
|
||||
}
|
||||
if (tabCountChannel) {
|
||||
tabCountChannel.close()
|
||||
tabCountChannel = null
|
||||
}
|
||||
})
|
||||
|
||||
useEventListener(window, 'keydown', useKeybindingService().keybindHandler)
|
||||
@@ -337,18 +310,17 @@ const onGraphReady = () => {
|
||||
}
|
||||
|
||||
// Set up page visibility tracking (cloud only)
|
||||
if (isCloud && telemetry && !visibilityListener) {
|
||||
visibilityListener = () => {
|
||||
if (isCloud && telemetry) {
|
||||
useEventListener(document, 'visibilitychange', () => {
|
||||
telemetry.trackPageVisibilityChanged({
|
||||
visibility_state: document.visibilityState as 'visible' | 'hidden'
|
||||
})
|
||||
}
|
||||
document.addEventListener('visibilitychange', visibilityListener)
|
||||
})
|
||||
}
|
||||
|
||||
// Set up tab count tracking (cloud only)
|
||||
if (isCloud && telemetry && !tabCountInterval) {
|
||||
tabCountChannel = new BroadcastChannel('comfyui-tab-count')
|
||||
if (isCloud && telemetry) {
|
||||
const tabCountChannel = new BroadcastChannel('comfyui-tab-count')
|
||||
const activeTabs = new Map<string, number>()
|
||||
const currentTabId = crypto.randomUUID()
|
||||
|
||||
@@ -363,7 +335,7 @@ const onGraphReady = () => {
|
||||
}
|
||||
|
||||
// 5-minute heartbeat interval
|
||||
tabCountInterval = window.setInterval(() => {
|
||||
useIntervalFn(() => {
|
||||
const now = Date.now()
|
||||
|
||||
// Clean up stale tabs (no heartbeat for 45 seconds)
|
||||
@@ -374,7 +346,7 @@ const onGraphReady = () => {
|
||||
})
|
||||
|
||||
// Broadcast our heartbeat
|
||||
tabCountChannel?.postMessage({
|
||||
tabCountChannel.postMessage({
|
||||
type: 'heartbeat',
|
||||
tabId: currentTabId
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user