Compare commits

...

9 Commits

Author SHA1 Message Date
Glary-Bot
cc07b775a5 fix(manager): expose failure count on focusable Failed tab
The failure-count tooltip was on the non-focusable <i> indicator,
so keyboard users could not get the hint when focusing the tab.
Move the accessible name and title to the parent <a> (the actual
focusable element), and mark the icon aria-hidden since it is
purely decorative now.
2026-05-03 08:31:44 +00:00
Glary-Bot
bf022a07e8 fix(manager): clear pending failure count on resetTaskState
Address CodeRabbit review feedback:

- Clear pendingFailureCount in resetTaskState() so an in-flight
  debounced failure toast becomes a no-op when a restart/reset
  happens before the debounce fires. useDebounceFn has no cancel
  API, so we rely on the early-return guard inside the callback.
- Strengthen the corresponding test to actually exercise the race
  (reset before timers flush), removing the pre-flush that made
  the test incapable of catching the regression.
- Switch the toast mock to vi.hoisted() per repo guidelines on
  keeping module mocks contained.
2026-04-20 04:19:27 +00:00
Glary-Bot
fafcc20c7d fix(manager): drive failure toast from task history, not promise rejection
Address review feedback: the previous toast logic fired on Promise
rejection from usePackInstall, but managerStore.installPack routes
through useCachedRequest which catches errors into null, and the
real install outcome arrives asynchronously via task-completion
events. This meant failures never surfaced a toast in practice.

Move the toast to comfyManagerStore and trigger it from a watch on
failedTasksIds length increases, with a short debounce to coalesce
a burst of failures into a single toast. The watcher ignores resets
(oldCount -> 0) so resetTaskState does not trigger a false alarm.

Replace the composable-level mock tests with store-level tests that
exercise the real contract (task history -> partitionTasks ->
failedTasksIds).
2026-04-20 03:34:36 +00:00
Glary-Bot
841990ec05 fix(manager): surface installation failures with toast and tab indicator
Installations could fail silently because the only failure feedback was
buried in a non-default Failed tab inside the progress toast. Users
believed the install succeeded until they restarted and the nodes were
missing.

- Show an error toast immediately when one or more pack installations
  reject in performInstallation, so failures surface in real time.
- Add an exclamation-circle indicator on the Failed tab in
  ManagerProgressToast whenever there are failed tasks, making the tab
  discoverable even when collapsed.
- Add unit tests covering the new toast behavior.

Fixes FE-218
2026-04-20 03:23:22 +00:00
Christian Byrne
60c7471818 feat: enable node replacement by default (#11439)
*PR Created by the Glary-Bot Agent*

---

## Summary

Enable node replacement suggestions by default so users see Quick Fix
options for deprecated/renamed nodes without toggling an experimental
setting.

- Change `Comfy.NodeReplacement.Enabled` default from `false` to `true`
and remove `experimental` flag
- Add `versionModified` metadata for release tracking
- No breaking change — users who previously disabled this setting keep
their preference

## Safety gates

This is an intentional global rollout, gated by two additional
server-side checks:

1. Server must provide `node_replacements` feature flag as true (PostHog
controlled)
2. `GET /api/node_replacements` must return data (cloud PR
Comfy-Org/cloud#2686)

Without both, changing this default alone has no effect. The three gates
ensure safe rollout.

## Companion PRs

- Comfy-Org/cloud#2686 — backend `GET /api/node_replacements` endpoint +
server-side validation bypass

Replicate of #11246, retargeted to `main` for backport automation.

Labels: `needs-backport`, `cloud/1.42`, `cloud/1.43`, `core/1.42`,
`core/1.43`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11439-feat-enable-node-replacement-by-default-3486d73d36508192b77aea9640986106)
by [Unito](https://www.unito.io)

Co-authored-by: Glary-Bot <glary-bot@users.noreply.github.com>
2026-04-20 02:16:54 +00:00
Comfy Org PR Bot
0ac4c3d6c5 1.44.6 (#11433)
Patch version increment to 1.44.6

**Base branch:** `main`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11433-1-44-6-3486d73d365081778622e094f11b500c)
by [Unito](https://www.unito.io)

Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com>
Co-authored-by: Christian Byrne <cbyrne@comfy.org>
2026-04-20 02:12:56 +00:00
Dante
feafdc0b4a fix: chain Load3D node lifecycle callbacks to preserve widget cleanup (#11359)
## Summary

Undo on a workflow with an interactive 3D/camera node (e.g. Qwen
MultiAngle Camera) broke the interactive UI: it disappeared for Vue
Nodes 2.0 and desynced for LiteGraph.

Root cause: `initializeLoad3d` in `useLoad3d.ts` assigned
`node.onRemoved`, `node.onResize`, and the other node lifecycle handlers
by direct assignment, overwriting the cleanup chain that `addWidget()`
had already appended during node construction (line `node.onRemoved =
useChainCallback(node.onRemoved, () => widget.onRemove?.())` in
`domWidget.ts`). When undo cleared the graph, `widget.onRemove` never
ran, so the component widget stayed in `domWidgetStore` pointing at a
detached element while new nodes registered fresh widgets at the same
UUID keys.

Fix: wrap all of those assignments with `useChainCallback` so earlier
subscribers (widget registration, badge composables, extension
nodeCreated hooks) continue to fire.

- Fixes FE-214
(<https://linear.app/comfyorg/issue/FE-214/undo-breaks-and-desyncs-qwen-multiangle-camera-ui>)

## Red-Green Verification

| Commit | CI Status | Purpose |
|--------|-----------|---------|
| `test: add failing test for FE-214 undo losing Load3D widget callback
chain` | 🔴 Red | Proves the test catches the bug |
| `fix: chain Load3D node lifecycle callbacks to preserve widget
cleanup` | 🟢 Green | Proves the fix resolves the bug |

## Test Plan

- [ ] CI red on test-only commit
- [ ] CI green on fix commit
- [ ] Manual: load Qwen MultiAngle Camera workflow, mutate camera, press
Ctrl+Z, confirm interactive UI stays mounted and value reflects restored
state (Vue Nodes 2.0 and LiteGraph)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11359-fix-chain-Load3D-node-lifecycle-callbacks-to-preserve-widget-cleanup-3466d73d365081e2b64de65c26ee6abf)
by [Unito](https://www.unito.io)
2026-04-20 01:55:44 +00:00
Christian Byrne
2fea0aa538 fix: trigger Vue reactivity on output slot type changes in matchType (#9935)
## Summary

Fix VHS unbatch output slot color not updating when slot types change
via matchType resolution in Vue renderer.

## Changes

- **What**: After `changeOutputType` mutates `output.type` on objects
inside a `shallowReactive` array, spread-copy `this.outputs` to trigger
the shallowReactive setter so `SlotConnectionDot` re-evaluates the slot
color.

## Review Focus

The fix adds `this.outputs = [...this.outputs]` after the matchType
resolution loop in `withComfyMatchType`. This forces Vue's
shallowReactive proxy to fire, since mutating a property on an object
inside the array doesn't trigger the setter. The spread is placed after
all outputs are updated to batch the reactivity trigger.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9935-fix-trigger-Vue-reactivity-on-output-slot-type-changes-in-matchType-3246d73d365081c4a293f57931892c61)
by [Unito](https://www.unito.io)
2026-04-20 01:51:08 +00:00
Christian Byrne
a1ba567dbc test: remove --listen 0.0.0.0 from E2E test mock argv (#11021)
## Summary

Remove `--listen 0.0.0.0` from mock `argv` in E2E test fixtures to avoid
normalizing a flag that exposes the server to all network interfaces.

## Changes

- **What**: Removed `--listen` and `0.0.0.0` from
`mockSystemStats.system.argv` in
`browser_tests/fixtures/data/systemStats.ts` (shared fixture) and the
ManagerDialog-specific override in
`browser_tests/tests/dialogs/managerDialog.spec.ts`. Neither value is
required for any test assertion.

Fixes #11008

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11021-test-remove-listen-0-0-0-0-from-E2E-test-mock-argv-33e6d73d365081c59d3fe9610afbeb6f)
by [Unito](https://www.unito.io)
2026-04-20 01:46:20 +00:00
11 changed files with 238 additions and 24 deletions

View File

@@ -7,7 +7,7 @@ export const mockSystemStats: SystemStatsResponse = {
embedded_python: false,
comfyui_version: '0.3.10',
pytorch_version: '2.4.0+cu124',
argv: ['main.py', '--listen', '0.0.0.0'],
argv: ['main.py'],
ram_total: 67108864000,
ram_free: 52428800000
},

View File

@@ -167,7 +167,7 @@ test.describe('ManagerDialog', { tag: '@ui' }, () => {
...mockSystemStats,
system: {
...mockSystemStats.system,
argv: ['main.py', '--listen', '0.0.0.0', '--enable-manager']
argv: ['main.py', '--enable-manager']
}
}
await comfyPage.page.route('**/system_stats**', async (route) => {

View File

@@ -1,6 +1,6 @@
{
"name": "@comfyorg/comfyui-frontend",
"version": "1.44.5",
"version": "1.44.6",
"private": true,
"description": "Official front-end implementation of ComfyUI",
"homepage": "https://comfy.org",

View File

@@ -321,6 +321,39 @@ describe('useLoad3d', () => {
})
})
describe('preserves existing node callbacks through initializeLoad3d', () => {
// Regression: FE-214 — undo triggers rootGraph.clear() which fires
// node.onRemoved on the outgoing node. addWidget() chains a cleanup that
// unregisters the component widget from the DOM widget store. If
// initializeLoad3d overwrites node.onRemoved instead of chaining, that
// cleanup is lost and the interactive UI persists with a stale reference.
it('chains node.onRemoved with a preexisting callback', async () => {
const existingOnRemoved = vi.fn()
mockNode.onRemoved = existingOnRemoved
const composable = useLoad3d(mockNode)
const containerRef = document.createElement('div')
await composable.initializeLoad3d(containerRef)
mockNode.onRemoved?.()
expect(existingOnRemoved).toHaveBeenCalledTimes(1)
})
it('chains node.onResize with a preexisting callback', async () => {
const existingOnResize = vi.fn()
mockNode.onResize = existingOnResize
const composable = useLoad3d(mockNode)
const containerRef = document.createElement('div')
await composable.initializeLoad3d(containerRef)
mockNode.onResize?.([512, 512] as Size)
expect(existingOnResize).toHaveBeenCalledTimes(1)
})
})
describe('waitForLoad3d', () => {
it('should execute callback immediately if Load3d exists', async () => {
const composable = useLoad3d(mockNode)

View File

@@ -4,6 +4,7 @@ import { toRef } from '@vueuse/core'
import { getActivePinia } from 'pinia'
import { ref, toRaw, watch } from 'vue'
import { useChainCallback } from '@/composables/functional/useChainCallback'
import Load3d from '@/extensions/core/load3d/Load3d'
import Load3dUtils from '@/extensions/core/load3d/Load3dUtils'
import {
@@ -133,30 +134,32 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
await restoreConfigurationsFromNode(node)
node.onMouseEnter = function () {
node.onMouseEnter = useChainCallback(node.onMouseEnter, () => {
load3d?.refreshViewport()
load3d?.updateStatusMouseOnNode(true)
}
})
node.onMouseLeave = function () {
node.onMouseLeave = useChainCallback(node.onMouseLeave, () => {
load3d?.updateStatusMouseOnNode(false)
}
})
node.onResize = function () {
node.onResize = useChainCallback(node.onResize, () => {
load3d?.handleResize()
}
})
node.onDrawBackground = function () {
if (load3d) {
load3d.renderer.domElement.hidden = this.flags.collapsed ?? false
node.onDrawBackground = useChainCallback(
node.onDrawBackground,
function (this: LGraphNode) {
if (load3d) {
load3d.renderer.domElement.hidden = this.flags.collapsed ?? false
}
}
}
)
node.onRemoved = function () {
node.onRemoved = useChainCallback(node.onRemoved, () => {
useLoad3dService().removeLoad3d(node)
pendingCallbacks.delete(node)
}
})
nodeToLoad3dMap.set(node, load3d)

View File

@@ -323,6 +323,10 @@ function withComfyMatchType(node: LGraphNode): asserts node is MatchTypeNode {
if (!(outputGroups?.[idx] == matchKey)) return
changeOutputType(this, output, outputType)
})
// Force Vue reactivity update for output slot types.
// Outputs are wrapped in shallowReactive by useGraphNodeManager,
// so mutating output.type alone doesn't trigger re-render.
this.outputs = [...this.outputs]
app.canvas?.setDirty(true, true)
}
)

View File

@@ -384,7 +384,12 @@
"legacyManagerSearchTip": "Looking for ComfyUI-Manager? You can enable the legacy manager UI by starting ComfyUI with the --enable-manager-legacy-ui flag.",
"failed": "Failed",
"failedToInstall": "Failed to Install",
"failedTabIndicatorTooltip": "{count} installation failed | {count} installations failed",
"installError": "Install Error",
"installFailureToast": {
"summary": "Installation failed",
"detail": "{count} extension failed to install. Check the Failed tab for details. | {count} extensions failed to install. Check the Failed tab for details."
},
"importFailedGenericError": "Package failed to import. Check the console for more details.",
"noNodesFound": "No nodes found",
"noNodesFoundDescription": "The pack's nodes either could not be parsed, or the pack is a frontend extension only and doesn't have any nodes.",

View File

@@ -1272,9 +1272,10 @@ export const CORE_SETTINGS: SettingParams[] = [
tooltip:
'When enabled, missing nodes with known replacements will be shown as replaceable in the missing nodes dialog, allowing you to review and apply replacements.',
type: 'boolean',
defaultValue: false,
experimental: true,
versionAdded: '1.40.0'
defaultValue: true,
experimental: false,
versionAdded: '1.40.0',
versionModified: '1.44.5'
},
{
id: 'Comfy.Graph.DeduplicateSubgraphNodeIds',

View File

@@ -18,11 +18,25 @@ const { isRestarting, isRestartCompleted, applyChanges } = useApplyChanges()
const isExpanded = ref(false)
const activeTabIndex = ref(0)
const FAILED_TAB_KEY = 'failed'
const failedCount = computed(() => comfyManagerStore.failedTasksIds.length)
const hasFailures = computed(() => failedCount.value > 0)
const failedTabIndicatorLabel = computed(() =>
t(
'manager.failedTabIndicatorTooltip',
{ count: failedCount.value },
failedCount.value
)
)
const tabs = computed(() => [
{ label: t('manager.installationQueue') },
{
key: FAILED_TAB_KEY,
label: t('manager.failed', {
count: comfyManagerStore.failedTasksIds.length
count: failedCount.value
})
}
])
@@ -169,7 +183,31 @@ onBeforeUnmount(() => {
menuitem: { class: 'font-medium' },
action: { class: 'px-4 py-2' }
}"
/>
>
<template #item="{ item, props, label }">
<a
v-bind="props.action"
class="flex items-center gap-2"
:aria-label="
item.key === FAILED_TAB_KEY && hasFailures
? `${label} ${failedTabIndicatorLabel}`
: undefined
"
:title="
item.key === FAILED_TAB_KEY && hasFailures
? failedTabIndicatorLabel
: undefined
"
>
<span>{{ label }}</span>
<i
v-if="item.key === FAILED_TAB_KEY && hasFailures"
class="pi pi-exclamation-circle text-danger"
aria-hidden="true"
/>
</a>
</template>
</TabMenu>
</div>
<div

View File

@@ -1,6 +1,6 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick, ref } from 'vue'
import { useComfyManagerService } from '@/workbench/extensions/manager/services/comfyManagerService'
@@ -55,6 +55,23 @@ vi.mock('vue-i18n', () => ({
}))
}))
const { toastAddMock } = vi.hoisted(() => ({
toastAddMock: vi.fn()
}))
vi.mock('@/platform/updates/common/toastStore', () => ({
useToastStore: vi.fn(() => ({
add: toastAddMock
}))
}))
vi.mock('@/i18n', () => ({
t: (key: string, params?: Record<string, unknown>) => {
if (params && 'count' in params) return `${key}:${String(params.count)}`
return key
}
}))
interface EnabledDisabledTestCase {
desc: string
installed: Record<string, ManagerPackInstalled>
@@ -79,6 +96,8 @@ describe('useComfyManagerStore', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
vi.clearAllMocks()
toastAddMock.mockClear()
vi.useFakeTimers()
mockManagerService = {
isLoading: ref(false),
error: ref(null),
@@ -102,6 +121,10 @@ describe('useComfyManagerStore', () => {
vi.mocked(useComfyManagerService).mockReturnValue(mockManagerService)
})
afterEach(() => {
vi.useRealTimers()
})
const testCases: EnabledDisabledTestCase[] = [
{
desc: 'Two enabled versions',
@@ -501,4 +524,83 @@ describe('useComfyManagerStore', () => {
expect(store.isPackInstalled('disabled-pack')).toBe(true)
})
})
describe('installation failure toast', () => {
const setTaskHistory = (
store: ReturnType<typeof useComfyManagerStore>,
history: Record<string, ManagerComponents['schemas']['TaskHistoryItem']>
) => {
store.taskHistory = history
}
const errorTask = (
id: string
): ManagerComponents['schemas']['TaskHistoryItem'] => ({
ui_id: id,
client_id: 'test',
kind: 'install',
result: 'failed',
status: { status_str: 'error', completed: false, messages: ['boom'] },
timestamp: new Date().toISOString()
})
const successTask = (
id: string
): ManagerComponents['schemas']['TaskHistoryItem'] => ({
ui_id: id,
client_id: 'test',
kind: 'install',
result: 'success',
status: { status_str: 'success', completed: true, messages: [] },
timestamp: new Date().toISOString()
})
it('shows an error toast when a task fails', async () => {
const store = useComfyManagerStore()
setTaskHistory(store, { a: errorTask('a') })
await nextTick()
await vi.runAllTimersAsync()
expect(toastAddMock).toHaveBeenCalledTimes(1)
const message = toastAddMock.mock.calls[0][0]
expect(message.severity).toBe('error')
expect(message.summary).toBe('manager.installFailureToast.summary')
expect(message.detail).toBe('manager.installFailureToast.detail:1')
})
it('does not show a toast when all tasks succeed', async () => {
const store = useComfyManagerStore()
setTaskHistory(store, { a: successTask('a'), b: successTask('b') })
await nextTick()
await vi.runAllTimersAsync()
expect(toastAddMock).not.toHaveBeenCalled()
})
it('coalesces multiple failures that land in quick succession', async () => {
const store = useComfyManagerStore()
setTaskHistory(store, { a: errorTask('a') })
await nextTick()
setTaskHistory(store, { a: errorTask('a'), b: errorTask('b') })
await nextTick()
await vi.runAllTimersAsync()
expect(toastAddMock).toHaveBeenCalledTimes(1)
expect(toastAddMock.mock.calls[0][0].detail).toBe(
'manager.installFailureToast.detail:2'
)
})
it('does not show a stale toast when resetTaskState runs before the debounce fires', async () => {
const store = useComfyManagerStore()
setTaskHistory(store, { a: errorTask('a') })
await nextTick()
store.resetTaskState()
await nextTick()
await vi.runAllTimersAsync()
expect(toastAddMock).not.toHaveBeenCalled()
})
})
})

View File

@@ -1,4 +1,4 @@
import { useEventListener, whenever } from '@vueuse/core'
import { useDebounceFn, useEventListener, whenever } from '@vueuse/core'
import { defineStore } from 'pinia'
import { v4 as uuidv4 } from 'uuid'
import { ref, watch } from 'vue'
@@ -6,6 +6,7 @@ import { ref, watch } from 'vue'
import { t } from '@/i18n'
import { useCachedRequest } from '@/composables/useCachedRequest'
import { useServerLogs } from '@/composables/useServerLogs'
import { useToastStore } from '@/platform/updates/common/toastStore'
import { api } from '@/scripts/api'
import { app } from '@/scripts/app'
@@ -112,6 +113,30 @@ export const useComfyManagerStore = defineStore('comfyManager', () => {
{ deep: true }
)
const pendingFailureCount = ref(0)
const flushFailureToast = useDebounceFn(() => {
if (pendingFailureCount.value === 0) return
const count = pendingFailureCount.value
pendingFailureCount.value = 0
useToastStore().add({
severity: 'error',
summary: t('manager.installFailureToast.summary'),
detail: t('manager.installFailureToast.detail', { count }, count),
life: 8000
})
}, 300)
watch(
() => failedTasksIds.value.length,
(newCount, oldCount) => {
const delta = newCount - (oldCount ?? 0)
if (delta <= 0) return
pendingFailureCount.value += delta
void flushFailureToast()
}
)
const getPackId = (pack: ManagerPackInstalled) => pack.cnr_id || pack.aux_id
const isInstalledPackId = (packName: string | undefined): boolean =>
@@ -336,7 +361,10 @@ export const useComfyManagerStore = defineStore('comfyManager', () => {
}
const resetTaskState = () => {
// Clear all task-related reactive state for fresh start after restart
// Clear all task-related reactive state for fresh start after restart.
// Also clear pendingFailureCount so any in-flight debounced failure
// toast (which reads this value before firing) becomes a no-op.
pendingFailureCount.value = 0
taskLogs.value = []
taskHistory.value = {}
succeededTasksIds.value = []