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:
Alexander Brown
2026-01-27 12:50:13 -08:00
committed by GitHub
parent 788f50834c
commit 14369c08a3
19 changed files with 422 additions and 187 deletions

View File

@@ -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/`

View File

@@ -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 ({

View File

@@ -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

View File

@@ -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

View File

@@ -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')
})

View File

@@ -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(

View File

@@ -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(() => {

View File

@@ -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()

View File

@@ -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')

View File

@@ -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
}

View File

@@ -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', () => {

View File

@@ -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,

View File

@@ -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,

View File

@@ -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', () => {

View File

@@ -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
}

View 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)
})
})
})
})

View 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
}
})

View File

@@ -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.

View File

@@ -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
})