Merge remote-tracking branch 'origin/main' into fix-nigthly-test

This commit is contained in:
Johnpaul
2026-01-27 22:39:05 +01:00
28 changed files with 757 additions and 195 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 ({

1
global.d.ts vendored
View File

@@ -30,6 +30,7 @@ interface Window {
badge?: string
}
}
dataLayer?: Array<Record<string, unknown>>
}
interface Navigator {

View File

@@ -15,21 +15,62 @@
body {
/* Setting it early for background during load */
--bg-color: #202020;
--fg-color: #FFFFFF;
}
}
html, body {
width: 100%;
height: 100%;
margin: 0;
}
body {
display: grid;
background-color: var(--bg-color);
background-image: var(--bg-img);
background-position: center;
background-size: cover;
background-repeat: no-repeat;
}
#vue-app:has(#loading-logo) {
display: contents;
color: var(--fg-color);
& #loading-logo {
place-self: center;
font-size: clamp(2px, 1vw, 6px);
line-height: 1;
overflow: hidden;
max-width: 100vw;
border-radius: 20ch;
}
}
.visually-hidden {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
border: 0;
}
</style>
<link rel="manifest" href="manifest.json">
</head>
<body class="litegraph grid">
<div id="vue-app"></div>
<div id="vue-app">
<span class="visually-hidden" role="status">Loading ComfyUI...</span>
<svg width="520" height="520" viewBox="0 0 520 520" fill="none" xmlns="http://www.w3.org/2000/svg" id="loading-logo">
<mask id="mask0_227_285" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="0" y="0" width="520" height="520">
<path d="M0 184.335C0 119.812 0 87.5502 12.5571 62.9055C23.6026 41.2274 41.2274 23.6026 62.9055 12.5571C87.5502 0 119.812 0 184.335 0H335.665C400.188 0 432.45 0 457.094 12.5571C478.773 23.6026 496.397 41.2274 507.443 62.9055C520 87.5502 520 119.812 520 184.335V335.665C520 400.188 520 432.45 507.443 457.094C496.397 478.773 478.773 496.397 457.094 507.443C432.45 520 400.188 520 335.665 520H184.335C119.812 520 87.5502 520 62.9055 507.443C41.2274 496.397 23.6026 478.773 12.5571 457.094C0 432.45 0 400.188 0 335.665V184.335Z" fill="#EEFF30"/>
</mask>
<g mask="url(#mask0_227_285)">
<rect y="0.751831" width="520" height="520" fill="#172DD7"/>
<path d="M176.484 428.831C168.649 428.831 162.327 425.919 158.204 420.412C153.966 414.755 152.861 406.857 155.171 398.749L164.447 366.178C165.187 363.585 164.672 360.794 163.059 358.636C161.446 356.483 158.921 355.216 156.241 355.216H129.571C121.731 355.216 115.409 352.308 111.289 346.802C107.051 341.14 105.946 333.242 108.258 325.134L140.124 213.748L143.642 201.51C148.371 184.904 165.62 171.407 182.097 171.407H214.009C217.817 171.407 221.167 168.868 222.215 165.183L232.769 128.135C237.494 111.545 254.742 98.048 271.219 98.048L339.468 97.9264L389.431 97.9221C397.268 97.9221 403.59 100.831 407.711 106.337C411.949 111.994 413.054 119.892 410.744 128L396.457 178.164C391.734 194.75 374.485 208.242 358.009 208.242L289.607 208.372H257.706C253.902 208.372 250.557 210.907 249.502 214.588L222.903 307.495C222.159 310.093 222.673 312.892 224.291 315.049C225.904 317.202 228.428 318.469 231.107 318.469C231.113 318.469 276.307 318.381 276.307 318.381H326.122C333.959 318.381 340.281 321.29 344.402 326.796C348.639 332.457 349.744 340.355 347.433 348.463L333.146 398.619C328.423 415.209 311.174 428.701 294.698 428.701L226.299 428.831H176.484Z" fill="#F0FF41"/>
</g>
</svg>
</div>
<script type="module" src="src/main.ts"></script>
</body>
</html>

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)
@@ -30,6 +31,9 @@ if (isCloud) {
const { refreshRemoteConfig } =
await import('@/platform/remoteConfig/refreshRemoteConfig')
await refreshRemoteConfig({ useAuth: false })
const { initGtm } = await import('@/platform/telemetry/gtm')
initGtm()
}
const ComfyUIPreset = definePreset(Aura, {
@@ -43,6 +47,7 @@ const firebaseApp = initializeApp(getFirebaseConfig())
const app = createApp(App)
const pinia = createPinia()
Sentry.init({
app,
dsn: __SENTRY_DSN__,
@@ -88,4 +93,7 @@ app
modules: [VueFireAuth()]
})
const bootstrapStore = useBootstrapStore(pinia)
void bootstrapStore.startStoreBootstrap()
app.mount('#vue-app')

View File

@@ -206,6 +206,49 @@ describe('useSubscription', () => {
)
})
it('pushes purchase event after a pending subscription completes', async () => {
window.dataLayer = []
localStorage.setItem(
'pending_subscription_purchase',
JSON.stringify({
tierKey: 'creator',
billingCycle: 'monthly',
timestamp: Date.now()
})
)
vi.mocked(global.fetch).mockResolvedValue({
ok: true,
json: async () => ({
is_active: true,
subscription_id: 'sub_123',
subscription_tier: 'CREATOR',
subscription_duration: 'MONTHLY'
})
} as Response)
mockIsLoggedIn.value = true
const { fetchStatus } = useSubscription()
await fetchStatus()
expect(window.dataLayer).toHaveLength(1)
expect(window.dataLayer?.[0]).toMatchObject({
event: 'purchase',
transaction_id: 'sub_123',
currency: 'USD',
items: [
{
item_id: 'monthly_creator',
item_variant: 'monthly',
item_category: 'subscription',
quantity: 1
}
]
})
expect(localStorage.getItem('pending_subscription_purchase')).toBeNull()
})
it('should handle fetch errors gracefully', async () => {
vi.mocked(global.fetch).mockResolvedValue({
ok: false,

View File

@@ -8,12 +8,20 @@ import { getComfyApiBaseUrl, getComfyPlatformBaseUrl } from '@/config/comfyApi'
import { t } from '@/i18n'
import { isCloud } from '@/platform/distribution/types'
import { useTelemetry } from '@/platform/telemetry'
import { pushDataLayerEvent } from '@/platform/telemetry/gtm'
import {
FirebaseAuthStoreError,
useFirebaseAuthStore
} from '@/stores/firebaseAuthStore'
import { useDialogService } from '@/services/dialogService'
import { TIER_TO_KEY } from '@/platform/cloud/subscription/constants/tierPricing'
import {
getTierPrice,
TIER_TO_KEY
} from '@/platform/cloud/subscription/constants/tierPricing'
import {
clearPendingSubscriptionPurchase,
getPendingSubscriptionPurchase
} from '@/platform/cloud/subscription/utils/subscriptionPurchaseTracker'
import type { operations } from '@/types/comfyRegistryTypes'
import { useSubscriptionCancellationWatcher } from './useSubscriptionCancellationWatcher'
@@ -93,7 +101,45 @@ function useSubscriptionInternal() {
: baseName
})
const buildApiUrl = (path: string) => `${getComfyApiBaseUrl()}${path}`
function buildApiUrl(path: string): string {
return `${getComfyApiBaseUrl()}${path}`
}
function trackSubscriptionPurchase(
status: CloudSubscriptionStatusResponse | null
): void {
if (!status?.is_active || !status.subscription_id) return
const pendingPurchase = getPendingSubscriptionPurchase()
if (!pendingPurchase) return
const { tierKey, billingCycle } = pendingPurchase
const isYearly = billingCycle === 'yearly'
const baseName = t(`subscription.tiers.${tierKey}.name`)
const planName = isYearly
? t('subscription.tierNameYearly', { name: baseName })
: baseName
const unitPrice = getTierPrice(tierKey, isYearly)
const value = isYearly && tierKey !== 'founder' ? unitPrice * 12 : unitPrice
pushDataLayerEvent({
event: 'purchase',
transaction_id: status.subscription_id,
value,
currency: 'USD',
items: [
{
item_id: `${billingCycle}_${tierKey}`,
item_name: planName,
item_category: 'subscription',
item_variant: billingCycle,
price: value,
quantity: 1
}
]
})
clearPendingSubscriptionPurchase()
}
const fetchStatus = wrapWithErrorHandlingAsync(
fetchSubscriptionStatus,
@@ -194,6 +240,12 @@ function useSubscriptionInternal() {
const statusData = await response.json()
subscriptionStatus.value = statusData
try {
await trackSubscriptionPurchase(statusData)
} catch (error) {
console.error('Failed to track subscription purchase', error)
}
return statusData
}
@@ -203,6 +255,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

@@ -6,6 +6,7 @@ import {
useFirebaseAuthStore
} from '@/stores/firebaseAuthStore'
import type { TierKey } from '@/platform/cloud/subscription/constants/tierPricing'
import { startSubscriptionPurchaseTracking } from '@/platform/cloud/subscription/utils/subscriptionPurchaseTracker'
import type { BillingCycle } from './subscriptionTierRank'
type CheckoutTier = TierKey | `${TierKey}-yearly`
@@ -78,6 +79,7 @@ export async function performSubscriptionCheckout(
const data = await response.json()
if (data.checkout_url) {
startSubscriptionPurchaseTracking(tierKey, currentBillingCycle)
if (openInNewTab) {
window.open(data.checkout_url, '_blank')
} else {

View File

@@ -0,0 +1,78 @@
import type { TierKey } from '@/platform/cloud/subscription/constants/tierPricing'
import type { BillingCycle } from './subscriptionTierRank'
type PendingSubscriptionPurchase = {
tierKey: TierKey
billingCycle: BillingCycle
timestamp: number
}
const STORAGE_KEY = 'pending_subscription_purchase'
const MAX_AGE_MS = 24 * 60 * 60 * 1000 // 24 hours
const VALID_TIERS: TierKey[] = ['standard', 'creator', 'pro', 'founder']
const VALID_CYCLES: BillingCycle[] = ['monthly', 'yearly']
const safeRemove = (): void => {
try {
localStorage.removeItem(STORAGE_KEY)
} catch {
// Ignore storage errors (e.g. private browsing mode)
}
}
export function startSubscriptionPurchaseTracking(
tierKey: TierKey,
billingCycle: BillingCycle
): void {
if (typeof window === 'undefined') return
try {
const payload: PendingSubscriptionPurchase = {
tierKey,
billingCycle,
timestamp: Date.now()
}
localStorage.setItem(STORAGE_KEY, JSON.stringify(payload))
} catch {
// Ignore storage errors (e.g. private browsing mode)
}
}
export function getPendingSubscriptionPurchase(): PendingSubscriptionPurchase | null {
if (typeof window === 'undefined') return null
try {
const raw = localStorage.getItem(STORAGE_KEY)
if (!raw) return null
const parsed = JSON.parse(raw) as PendingSubscriptionPurchase
if (!parsed || typeof parsed !== 'object') {
safeRemove()
return null
}
const { tierKey, billingCycle, timestamp } = parsed
if (
!VALID_TIERS.includes(tierKey) ||
!VALID_CYCLES.includes(billingCycle) ||
typeof timestamp !== 'number'
) {
safeRemove()
return null
}
if (Date.now() - timestamp > MAX_AGE_MS) {
safeRemove()
return null
}
return parsed
} catch {
safeRemove()
return null
}
}
export function clearPendingSubscriptionPurchase(): void {
if (typeof window === 'undefined') return
safeRemove()
}

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

@@ -0,0 +1,43 @@
import { isCloud } from '@/platform/distribution/types'
const GTM_CONTAINER_ID = 'GTM-NP9JM6K7'
let isInitialized = false
let initPromise: Promise<void> | null = null
export function initGtm(): void {
if (!isCloud || typeof window === 'undefined') return
if (typeof document === 'undefined') return
if (isInitialized) return
if (!initPromise) {
initPromise = new Promise((resolve) => {
const dataLayer = window.dataLayer ?? (window.dataLayer = [])
dataLayer.push({
'gtm.start': Date.now(),
event: 'gtm.js'
})
const script = document.createElement('script')
script.async = true
script.src = `https://www.googletagmanager.com/gtm.js?id=${GTM_CONTAINER_ID}`
const finalize = () => {
isInitialized = true
resolve()
}
script.addEventListener('load', finalize, { once: true })
script.addEventListener('error', finalize, { once: true })
document.head?.appendChild(script)
})
}
void initPromise
}
export function pushDataLayerEvent(event: Record<string, unknown>): void {
if (!isCloud || typeof window === 'undefined') return
const dataLayer = window.dataLayer ?? (window.dataLayer = [])
dataLayer.push(event)
}

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

@@ -1,5 +1,6 @@
<script setup lang="ts">
import RadioButton from 'primevue/radiobutton'
import Button from '@/components/ui/button/Button.vue'
import { computed } from 'vue'
import { useSettingStore } from '@/platform/settings/settingStore'
@@ -64,12 +65,16 @@ const controlMode = defineModel<ControlOptions>()
</div>
<div class="space-y-2">
<div
<Button
v-for="option in controlOptions"
:key="option.mode"
class="flex items-center justify-between py-2 gap-7"
as="label"
variant="textonly"
size="lg"
class="flex w-full h-[unset] text-left items-center justify-between py-2 gap-7"
:for="option.mode"
>
<div class="flex items-center gap-2 flex-1 min-w-0">
<div class="flex items-center gap-2 flex-1 min-w-0 text-wrap">
<div
class="flex items-center justify-center w-8 h-8 rounded-lg flex-shrink-0 bg-secondary-background border border-border-subtle"
>
@@ -102,11 +107,11 @@ const controlMode = defineModel<ControlOptions>()
<RadioButton
v-model="controlMode"
class="flex-shrink-0"
class="shrink"
:input-id="option.mode"
:value="option.mode"
/>
</div>
</Button>
</div>
</div>
</template>

View File

@@ -9,6 +9,7 @@ import type { RouteLocationNormalized } from 'vue-router'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import { isCloud } from '@/platform/distribution/types'
import { pushDataLayerEvent } from '@/platform/telemetry/gtm'
import { useDialogService } from '@/services/dialogService'
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
import { useUserStore } from '@/stores/userStore'
@@ -36,6 +37,16 @@ function getBasePath(): string {
const basePath = getBasePath()
function pushPageView(): void {
if (!isCloud || typeof window === 'undefined') return
pushDataLayerEvent({
event: 'page_view',
page_location: window.location.href,
page_title: document.title
})
}
const router = createRouter({
history: isFileProtocol
? createWebHashHistory()
@@ -93,6 +104,10 @@ installPreservedQueryTracker(router, [
}
])
router.afterEach(() => {
pushPageView()
})
if (isCloud) {
const { flags } = useFeatureFlags()
const PUBLIC_ROUTE_NAMES = new Set([

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

@@ -26,6 +26,7 @@ import { t } from '@/i18n'
import { WORKSPACE_STORAGE_KEYS } from '@/platform/auth/workspace/workspaceConstants'
import { isCloud } from '@/platform/distribution/types'
import { useTelemetry } from '@/platform/telemetry'
import { pushDataLayerEvent as pushDataLayerEventBase } from '@/platform/telemetry/gtm'
import { useDialogService } from '@/services/dialogService'
import { useApiKeyAuthStore } from '@/stores/apiKeyAuthStore'
import type { AuthHeader } from '@/types/authTypes'
@@ -81,6 +82,42 @@ export const useFirebaseAuthStore = defineStore('firebaseAuth', () => {
const buildApiUrl = (path: string) => `${getComfyApiBaseUrl()}${path}`
function pushDataLayerEvent(event: Record<string, unknown>): void {
if (!isCloud || typeof window === 'undefined') return
try {
pushDataLayerEventBase(event)
} catch (error) {
console.warn('Failed to push data layer event', error)
}
}
async function hashSha256(value: string): Promise<string | undefined> {
if (typeof crypto === 'undefined' || !crypto.subtle) return
if (typeof TextEncoder === 'undefined') return
const data = new TextEncoder().encode(value)
const hash = await crypto.subtle.digest('SHA-256', data)
return Array.from(new Uint8Array(hash))
.map((b) => b.toString(16).padStart(2, '0'))
.join('')
}
async function trackSignUp(method: 'email' | 'google' | 'github') {
if (!isCloud || typeof window === 'undefined') return
try {
const userId = currentUser.value?.uid
const hashedUserId = userId ? await hashSha256(userId) : undefined
pushDataLayerEvent({
event: 'sign_up',
method,
...(hashedUserId ? { user_id: hashedUserId } : {})
})
} catch (error) {
console.warn('Failed to track sign up', error)
}
}
// Providers
const googleProvider = new GoogleAuthProvider()
googleProvider.addScope('email')
@@ -347,6 +384,7 @@ export const useFirebaseAuthStore = defineStore('firebaseAuth', () => {
method: 'email',
is_new_user: true
})
await trackSignUp('email')
}
return result
@@ -365,6 +403,9 @@ export const useFirebaseAuthStore = defineStore('firebaseAuth', () => {
method: 'google',
is_new_user: isNewUser
})
if (isNewUser) {
await trackSignUp('google')
}
}
return result
@@ -383,6 +424,9 @@ export const useFirebaseAuthStore = defineStore('firebaseAuth', () => {
method: 'github',
is_new_user: isNewUser
})
if (isNewUser) {
await trackSignUp('github')
}
}
return result

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