Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/`
Expand Down
1 change: 1 addition & 0 deletions src/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
57 changes: 34 additions & 23 deletions src/components/graph/GraphCanvas.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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'
Expand All @@ -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'
Expand Down Expand Up @@ -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'
Expand Down Expand Up @@ -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()
Expand All @@ -404,35 +401,49 @@ useCanvasDrop(canvasRef)
useLitegraphSettings()
useNodeBadge()

useGlobalLitegraph()
useContextMenuTranslation()
useVueFeatureFlags()

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)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this logic/nuance be abstracted and exported from the store? So it's used like await untilSettingsReady()? To be fair it's already quite expressive.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes. I considered doing that for each of the lazily initialized stores instead of keeping the logic in bootstrap. I'll try it out.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just lacking an analogous store for i18n pieces.


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)
Expand Down
3 changes: 3 additions & 0 deletions src/composables/node/useNodeBadge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,9 @@ export const useNodeBadge = () => {
}

onMounted(() => {
if (extensionStore.isExtensionInstalled('Comfy.NodeBadge')) return
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍


// TODO: Fix the composables and watchers being setup in onMounted
const nodePricing = useNodePricing()

watch(
Expand Down
3 changes: 1 addition & 2 deletions src/composables/useCoreCommands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
8 changes: 8 additions & 0 deletions src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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__,
Expand Down Expand Up @@ -88,4 +93,7 @@ app
modules: [VueFireAuth()]
})

// Start store bootstrap (settings, i18n, workflows) after Pinia is registered
void bootstrapStore.startStoreBootstrap()

app.mount('#vue-app')
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,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
}
Expand Down
12 changes: 9 additions & 3 deletions src/platform/settings/settingStore.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,18 +82,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', () => {
Expand Down
6 changes: 5 additions & 1 deletion src/platform/settings/settingStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -170,7 +170,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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

router.push() on logout won't invalidate all the app state which would cause issues when changing accounts.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh, yeah, this. (My comment above)

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we add this aspect to the comment/todo? Or make an Issue?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@coderabbitai Open a tracking issue to fix the state such that we can use the router to login/logout

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Create a GitHub issue for implementing proper state reset on logout

gh issue create \
  --repo Comfy-Org/ComfyUI_frontend \
  --title "Implement store reset methods to enable router-based login/logout" \
  --body "## Context

Currently, the application uses \`window.location.reload()\` on logout because \`router.push()\` doesn't invalidate all app state, which causes issues when changing accounts.

## Problem

When using \`router.push()\` for logout, state persists across account changes. For example, \`isSettingsReady\` remains true, causing new users to inherit previous users' cached settings.

## Desired Outcome

Implement proper store reset methods in \`bootstrapStore\` and \`settingStore\` (and any other stores that need cleanup) so that logout can use \`router.push()\` instead of \`window.location.reload()\`.

## References

- PR: https://github.com/Comfy-Org/ComfyUI_frontend/pull/8307
- Discussion: https://github.com/Comfy-Org/ComfyUI_frontend/pull/8307#discussion_r2729390263
- Requester: `@DrJKL`

## Affected Files

- \`src/stores/bootstrapStore.ts\` - needs reset method
- \`src/platform/settings/settingStore.ts\` - needs reset method  
- \`SidebarLogoutIcon.vue\` - should use router.push() after stores have reset capabilities
- Other stores may also need reset methods for complete state cleanup" \
  --assignee DrJKL

Length of output: 57


@DrJKL I've created tracking issue #8337 to implement proper store reset methods for router-based login/logout. The issue has been assigned to you and includes context about the state persistence problem, references to this PR and discussion, and a list of affected files that will need reset capabilities.


🧬 Code Graph Analysis Results

[src/platform/settings/settingStore.ts] (snippet inside addSetting)

  • Location: Within the addSetting(setting: SettingParams) function, around the block that handles duplicate registrations.

Snippet:
function addSetting(setting: SettingParams) {
if (!setting.id) {
throw new Error('Settings must have an ID')
}
if (setting.id in settingsById.value) {
// 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

if (settingValues.value[setting.id] !== undefined) {
settingValues.value[setting.id] = tryMigrateDeprecatedValue(
setting,
settingValues.value[setting.id]
)
}
onChange(setting, get(setting.id), undefined)
}

Notes:

  • This snippet includes the explicit TODO comment about a potential state/store reset mechanism and migrating navigation away from a full reload (window.location.reload()) toward programmatic routing (router.push()) in SidebarLogoutIcon.vue. This is directly relevant to the user’s goal of enabling router-based login/logout flows instead of full page reloads.

🧠 Learnings used
Learnt from: DrJKL
Repo: Comfy-Org/ComfyUI_frontend PR: 8307
File: src/stores/bootstrapStore.ts:61-78
Timestamp: 2026-01-26T18:39:22.294Z
Learning: In src/stores/**/*.ts: Do not suggest exposing internal state or error refs from stores unless there is demonstrated active usage of those properties elsewhere in the codebase. Keep internal state private by default; only expose what is actively consumed.

Learnt from: DrJKL
Repo: Comfy-Org/ComfyUI_frontend PR: 8256
File: src/stores/assetsStore.ts:373-378
Timestamp: 2026-01-24T00:08:51.452Z
Learning: In src/stores/assetsStore.ts, the `state.assets = new Map(state.assets)` recreation within the loadBatches loop is intentionally triggered per batch (not deferred until after all batches complete) to provide progressive reactivity updates to the UI as model assets load.

Learnt from: CR
Repo: Comfy-Org/ComfyUI_frontend PR: 0
File: AGENTS.md:0-0
Timestamp: 2026-01-22T17:18:17.636Z
Learning: Applies to src/**/*.{vue,ts} : Leverage VueUse functions for performance-enhancing patterns

console.warn(`Setting already registered: ${setting.id}`)
return
}

settingsById.value[setting.id] = setting
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -139,21 +139,11 @@ describe('useMinimapGraph', () => {
expect(mockGraph.onConnectionChange).toBe(originalOnConnectionChange)
})

it('should handle cleanup for never-setup graph', () => {
const consoleErrorSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => {})

it('should handle cleanup for never-setup graph silently', () => {
const graphRef = ref(mockGraph as any)
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', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down
68 changes: 68 additions & 0 deletions src/stores/bootstrapStore.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
})
Loading
Loading