refactor: extract early bootstrap logic to bootstrapStore

- Add useBootstrapStore to centralize early initialization (api.init, fetchNodeDefs)
- Move settings loading and custom nodes i18n loading to store bootstrap phase
- Use VueUse's `until` to coordinate async dependencies in GraphCanvas
- Load settings, i18n, and newUserService initialization in parallel where possible
- Add unit tests for bootstrapStore

Amp-Thread-ID: https://ampcode.com/threads/T-019bf48d-af90-738f-99ce-46309e4be688
Co-authored-by: Amp <amp@ampcode.com>
This commit is contained in:
Alexander Brown
2026-01-25 01:48:34 -08:00
parent 33c457aca0
commit 326154f2b2
4 changed files with 222 additions and 23 deletions

View File

@@ -93,7 +93,7 @@
</template>
<script setup lang="ts">
import { useEventListener, whenever } from '@vueuse/core'
import { until, useEventListener, whenever } 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'
@@ -180,6 +183,9 @@ const toastStore = useToastStore()
const colorPaletteStore = useColorPaletteStore()
const colorPaletteService = useColorPaletteService()
const canvasInteractions = useCanvasInteractions()
const bootstrapStore = useBootstrapStore()
const { isI18nReady, i18nError, isSettingsReady, settingsError } =
storeToRefs(bootstrapStore)
const betaMenuEnabled = computed(
() => settingStore.get('Comfy.UseNewMenu') !== 'Disabled'
@@ -386,15 +392,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,13 +401,14 @@ useCanvasDrop(canvasRef)
useLitegraphSettings()
useNodeBadge()
useGlobalLitegraph()
useContextMenuTranslation()
useVueFeatureFlags()
onMounted(async () => {
useGlobalLitegraph()
useContextMenuTranslation()
useCopy()
usePaste()
useWorkflowAutoSave()
useVueFeatureFlags()
comfyApp.vueAppReady = true
@@ -418,21 +416,34 @@ onMounted(async () => {
// 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)

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)
@@ -43,6 +44,10 @@ const firebaseApp = initializeApp(getFirebaseConfig())
const app = createApp(App)
const pinia = createPinia()
// Start early bootstrap (api.init, fetchNodeDefs) before Pinia is registered
const bootstrapStore = useBootstrapStore(pinia)
bootstrapStore.startEarlyBootstrap()
Sentry.init({
app,
dsn: __SENTRY_DSN__,
@@ -88,4 +93,7 @@ app
modules: [VueFireAuth()]
})
// Start store bootstrap (settings, i18n, workflows) after Pinia is registered
void bootstrapStore.startStoreBootstrap()
app.mount('#vue-app')

View File

@@ -0,0 +1,68 @@
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
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()
}))
vi.mock('@/platform/settings/settingStore', () => ({
useSettingStore: vi.fn(() => ({
loadSettingValues: vi.fn().mockResolvedValue(undefined)
}))
}))
vi.mock('@/stores/workspaceStore', () => ({
useWorkspaceStore: vi.fn(() => ({
workflow: {
syncWorkflows: vi.fn().mockResolvedValue(undefined)
}
}))
}))
describe('bootstrapStore', () => {
let store: ReturnType<typeof useBootstrapStore>
beforeEach(() => {
setActivePinia(createPinia())
store = useBootstrapStore()
vi.clearAllMocks()
})
it('initializes with all flags false', () => {
expect(store.isNodeDefsReady).toBe(false)
expect(store.isSettingsReady).toBe(false)
expect(store.isI18nReady).toBe(false)
})
it('starts early bootstrap (node defs)', async () => {
const { api } = await import('@/scripts/api')
store.startEarlyBootstrap()
await vi.waitFor(() => {
expect(store.isNodeDefsReady).toBe(true)
})
expect(api.getNodeDefs).toHaveBeenCalled()
})
it('starts store bootstrap (settings, i18n)', async () => {
void store.startStoreBootstrap()
await vi.waitFor(() => {
expect(store.isSettingsReady).toBe(true)
expect(store.isI18nReady).toBe(true)
})
})
})

View File

@@ -0,0 +1,112 @@
import { useAsyncState } from '@vueuse/core'
import { defineStore } from 'pinia'
import type { ComfyNodeDef } from '@/schemas/nodeDefSchema'
import { api } from '@/scripts/api'
import { useUserStore } from '@/stores/userStore'
export const useBootstrapStore = defineStore('bootstrap', () => {
const {
state: nodeDefs,
isReady: isNodeDefsReady,
error: nodeDefsError,
execute: fetchNodeDefs
} = useAsyncState<Record<string, ComfyNodeDef>>(
async () => {
const defs = await api.getNodeDefs()
return defs
},
{},
{ immediate: false }
)
const {
isReady: isSettingsReady,
isLoading: isSettingsLoading,
error: settingsError,
execute: executeLoadSettings
} = useAsyncState(
async () => {
const { useSettingStore } =
await import('@/platform/settings/settingStore')
await useSettingStore().loadSettingValues()
},
undefined,
{ immediate: false }
)
function loadSettings() {
// TODO: This check makes the store "sticky" across logouts. Add a reset
// method to clear isSettingsReady, then replace window.location.reload()
// with router.push() in SidebarLogoutIcon.vue
if (!isSettingsReady.value && !isSettingsLoading.value) {
void executeLoadSettings()
}
}
const {
isReady: isI18nReady,
error: i18nError,
execute: loadI18n
} = useAsyncState(
async () => {
const { mergeCustomNodesI18n } = await import('@/i18n')
const i18nData = await api.getCustomNodesI18n()
mergeCustomNodesI18n(i18nData)
},
undefined,
{ immediate: false }
)
const {
isReady: isWorkflowsReady,
isLoading: isWorkflowsLoading,
execute: executeSyncWorkflows
} = useAsyncState(
async () => {
const { useWorkspaceStore } = await import('@/stores/workspaceStore')
await useWorkspaceStore().workflow.syncWorkflows()
},
undefined,
{ immediate: false }
)
function syncWorkflows() {
if (!isWorkflowsReady.value && !isWorkflowsLoading.value) {
void executeSyncWorkflows()
}
}
function startEarlyBootstrap() {
void fetchNodeDefs()
}
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()
// i18n can load without authentication
void loadI18n()
if (!userStore.needsLogin) {
loadSettings()
syncWorkflows()
}
}
return {
nodeDefs,
isNodeDefsReady,
nodeDefsError,
isSettingsReady,
settingsError,
isI18nReady,
i18nError,
startEarlyBootstrap,
startStoreBootstrap,
loadSettings,
syncWorkflows
}
})