Compare commits

...

10 Commits

Author SHA1 Message Date
Connor Byrne
2f6461ddf8 fix: defer removeDraft until after switch succeeds
Moves removeDraft() call to after the switch/close logic completes,
preserving the draft if closeWorkflow returns false due to a failed
switch. Adds test coverage for draft preservation on switch failure.

Addresses review feedback:
https://github.com/Comfy-Org/ComfyUI_frontend/pull/11389#pullrequestreview-2960339131

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-05-13 13:49:25 -07:00
bymyself
65dad9f112 test: pin isActive postcondition with silent loadGraphData regression
The two existing closeWorkflow regression tests only cover thrown errors
from app.loadGraphData. The actual #7840 regression is the silent path
where loadGraphData() catches the configure() error internally and
resolves without ever switching the active workflow.

Without this case the suite would still pass if someone removed the
!workflowStore.isActive(workflow) postcondition from trySwitch() and
kept only the catch block.

Mock loadGraphData to resolve unconditionally, leaving activeWorkflow
on the closing workflow, and assert closeWorkflow returns false and
keeps the tab open.

Addresses review feedback:
https://github.com/Comfy-Org/ComfyUI_frontend/pull/11389#discussion_r3128091229
https://github.com/Comfy-Org/ComfyUI_frontend/pull/11389#discussion_r3128117574
2026-05-13 13:43:13 -07:00
bymyself
4ec08b3af3 fix: route single-tab close through trySwitch for failure resilience
Previously the last-tab path in closeWorkflow called loadDefaultWorkflow()
directly. loadGraphData() only swallows configure() errors; it can still
reject from validation, extension hooks, or node-replacement loading,
which would throw out of closeWorkflow instead of returning false like
the multi-tab path does.

Wrap the call in trySwitch() so the last-tab case follows the same
'do not close if we failed to activate a replacement' contract.

Addresses review feedback:
https://github.com/Comfy-Org/ComfyUI_frontend/pull/11389#discussion_r3128117872
2026-05-13 13:43:12 -07:00
bymyself
3527b293b7 revert: remove A1111 import changes (split to separate PR)
Addresses review feedback:
https://github.com/Comfy-Org/ComfyUI_frontend/pull/11389#discussion_r3107775864
2026-05-13 13:43:12 -07:00
bymyself
61f2be5eae refactor: extract switchAwayFrom helper for closeWorkflow readability
Addresses review feedback:
https://github.com/Comfy-Org/ComfyUI_frontend/pull/11389#discussion_r3107775265
2026-05-13 13:43:12 -07:00
bymyself
09642e2173 fix: prevent tab removal on failed workflow switch and fix A1111 import overwriting current tab (#7840)
- closeWorkflow: wrap replacement switch in try-catch with postcondition
  check; fall back to loadDefaultWorkflow if first switch fails; return
  false without removing the tab if both fail
- handleFile (A1111): clean graph before import, properly await
  importA1111() and afterLoadNewGraph()
- Add 2 regression tests for closeWorkflow resilience

Fixes #7840
2026-05-13 13:43:12 -07:00
pythongosssss
4321013798 fix: resolve widget input link position drift on reload (#12214)
## Summary
The position of a link relative to its slot was able to drift on load,
due to widgets inside a node being able to resize without triggering an
node-level resize event (min-height node with space at the bottom could
have widgets expand into free space, causing misalignment).

Recreation:
1. Add KSampler
2. Add Float
3. Connect Float to KSamper.denoise
4. Reload workflow (F5)
5. Observe misalignment

## Changes

- **What**: 
- track widget grid element as signal only that triggers resync
- node bound calculations skipped for widget signals
- prevent setDirty on non-graph nodes (e.g. LGraphNodePreview)
- tests

## Review Focus
This is a small focused approach to fix the reported issue - it does not
address the underlying issue of the layout not being a SSOT. This fix is
a small bandaid and investigation into resolving the layout SOT issue is
not impacted by this.

## Screenshots (if applicable)

Before:
<img width="673" height="374" alt="image"
src="https://github.com/user-attachments/assets/2d34b8e3-0731-4fd2-8553-4dd429010ced"
/>

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12214-fix-resolve-widget-input-link-position-drift-on-reload-35f6d73d3650814eb31bebb3042ff58b)
by [Unito](https://www.unito.io)
2026-05-13 20:00:50 +00:00
pythongosssss
7ce0973386 fix: prevent first user template popup when following shared link (#12024)
## Summary

When a user who has not used the app before first loads up, they are
presented with the template selection dialog. This conflicts when the
first-time user visits the app via a share link - both the share &
template dialog are triggered.

## Changes

- **What**: 
- Skip the templates browser when share param is in URL
- Tests
- Add `url` to `setup`/`goto` to allow specifying the `share` parameter

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12024-fix-prevent-first-user-template-popup-when-following-shared-link-3586d73d365081cbbcecdba45a1ad1ea)
by [Unito](https://www.unito.io)
2026-05-13 19:19:54 +00:00
Yourz
6e9be7b164 feat: add Anthropic partner icon (#12216)
*PR Created by the Glary-Bot Agent*

---

## Summary

Adds the Anthropic logo to the partner-node icon set so nodes whose
category ends in `Anthropic` (e.g. the Claude node added in
Comfy-Org/ComfyUI#13867 with `category="api node/text/Anthropic"`)
render the correct provider badge in the node library.

## Changes

- `packages/design-system/src/icons/anthropic.svg` — new auto-discovered
partner icon (Anthropic A glyph, sourced from
[lobehub/lobe-icons](https://github.com/lobehub/lobe-icons), uses
`fill="currentColor"` for theme adaptation)
- `src/utils/categoryUtil.ts` — register Anthropic's brand coral
`#D97757` as the badge border color
- `packages/design-system/src/css/style.css` — add `anthropic` to the
dynamic comfy-icon safelist so Tailwind/Iconify emits CSS for
`icon-[comfy--anthropic]` in production builds
- `src/utils/categoryUtil.test.ts` — regression tests for
`getProviderIcon('Anthropic')` and `getProviderBorderStyle('Anthropic')`

## Verification

- `pnpm typecheck` ✓
- `pnpm lint` ✓ (0 errors; 3 pre-existing warnings in unrelated files)
- `pnpm format:check` ✓
- `pnpm test:unit -- src/utils/categoryUtil.test.ts` ✓ (13/13)
- `pnpm build` ✓ — confirmed `comfy--anthropic` class is emitted into
`dist/assets/index-*.css`
- Manual visual check via Playwright against `pnpm dev`: injected `<i
class="icon-[comfy--anthropic]">` elements at badge size (10px) and 48px
alongside the existing OpenAI and BFL icons and confirmed the Anthropic
"A" glyph renders correctly in coral. See screenshot.

End-to-end visual verification of the live badge in the node library
requires Comfy-Org/ComfyUI#13867 to land first (the Claude node is what
produces the `Anthropic` category that triggers the icon lookup).

Related: Comfy-Org/ComfyUI#13867

## Screenshots

![Anthropic icon rendered in coral alongside OpenAI and BFL partner
icons at badge and large
sizes](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/c0076decedd8863eec6253b44e583da6b3eaacc20081d126aaf5267c72c8cc84/pr-images/1778683078329-49e37a7b-86ed-4ef2-988f-5702433f8412.png)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12216-feat-add-Anthropic-partner-icon-35f6d73d36508133a134fcafaf72f4f8)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Glary-Bot <glary-bot@users.noreply.github.com>
2026-05-13 17:01:49 +00:00
Terry Jia
4b5b184cad FE-566: fix Painter mask submission edge cases on cloud (#12196)
## Summary
Rework the painter always hands the backend a valid asset reference:
- Drop the `hasStrokes` flag and the `isCanvasEmpty` check.
- `serializeValue` falls back to the existing `modelValue` when the
canvas element is transiently unmounted, reuses the cached upload when
not dirty and a value is present, and otherwise uploads the current
canvas (a fully transparent PNG is a valid no-op mask, Painter's Python
`execute()` treats painter_alpha=0 the same as "no mask painted").
- `handleClear` now also clears `modelValue` so a user-initiated clear
doesn't resurrect a stale upload on the next serialize.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12196-FE-566-fix-Painter-mask-submission-edge-cases-on-cloud-35e6d73d365081dd8856ddb785952526)
by [Unito](https://www.unito.io)
2026-05-13 10:19:39 -04:00
20 changed files with 606 additions and 53 deletions

View File

@@ -0,0 +1,90 @@
{
"id": "06e5b524-5a40-40b9-b561-199dfab18cf0",
"revision": 0,
"last_node_id": 12,
"last_link_id": 10,
"nodes": [
{
"id": 10,
"type": "KSampler",
"pos": [230, 110],
"size": [270, 317.5666809082031],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
{
"name": "model",
"type": "MODEL",
"link": null
},
{
"name": "positive",
"type": "CONDITIONING",
"link": null
},
{
"name": "negative",
"type": "CONDITIONING",
"link": null
},
{
"name": "latent_image",
"type": "LATENT",
"link": null
},
{
"name": "denoise",
"type": "FLOAT",
"widget": {
"name": "denoise"
},
"link": 10
}
],
"outputs": [
{
"name": "LATENT",
"type": "LATENT",
"links": null
}
],
"properties": {
"Node name for S&R": "KSampler"
},
"widgets_values": [0, "randomize", 20, 8, "euler", "simple", 1]
},
{
"id": 11,
"type": "PrimitiveFloat",
"pos": [-80.55032348632812, 375.2260443115233],
"size": [270, 80.23332977294922],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "FLOAT",
"type": "FLOAT",
"links": [10]
}
],
"properties": {
"Node name for S&R": "PrimitiveFloat"
},
"widgets_values": [0]
}
],
"links": [[10, 11, 0, 10, 4, "FLOAT"]],
"groups": [],
"config": {},
"extra": {
"ds": {
"scale": 0.8264462809917354,
"offset": [1335.8909766107738, 692.7345403667316]
},
"frontendVersion": "1.45.4"
},
"version": 0.4
}

View File

@@ -285,10 +285,12 @@ export class ComfyPage {
async setup({
clearStorage = true,
mockReleases = true
mockReleases = true,
url
}: {
clearStorage?: boolean
mockReleases?: boolean
url?: string
} = {}) {
// Mock release endpoint to prevent changelog popups (before navigation)
if (mockReleases) {
@@ -320,7 +322,7 @@ export class ComfyPage {
}, this.id)
}
await this.goto()
await this.goto({ url })
await this.page.waitForFunction(() => document.fonts.ready)
await this.waitForAppReady()
@@ -347,8 +349,8 @@ export class ComfyPage {
return assetPath(fileName)
}
async goto() {
await this.page.goto(this.url)
async goto({ url }: { url?: string } = {}) {
await this.page.goto(url ? new URL(url, this.url).toString() : this.url)
}
async nextFrame() {

View File

@@ -549,7 +549,7 @@ test.describe('Painter', { tag: ['@widget', '@vue-nodes'] }, () => {
expect(uploadCount, 'should upload exactly once').toBe(1)
})
test('Empty canvas does not upload on serialization', async ({
test('Empty canvas uploads a transparent placeholder on serialization', async ({
comfyPage
}) => {
let uploadCount = 0
@@ -566,7 +566,10 @@ test.describe('Painter', { tag: ['@widget', '@vue-nodes'] }, () => {
await triggerSerialization(comfyPage.page)
expect(uploadCount, 'empty canvas should not upload').toBe(0)
expect(
uploadCount,
'empty canvas should upload a transparent PNG so the backend receives a valid asset reference (Painter.execute treats painter_alpha=0 as no-mask)'
).toBe(1)
})
test('Upload failure shows error toast', async ({ comfyPage }) => {

View File

@@ -106,6 +106,49 @@ test.describe('Templates', { tag: ['@slow', '@workflow'] }, () => {
await expect(comfyPage.templates.content).toBeVisible()
})
test('dialog should not be shown when first-time user opens a shared workflow link', async ({
comfyPage
}) => {
await comfyPage.page.route(
'**/workflows/published/test-share-id',
async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
share_id: 'test-share-id',
workflow_id: 'wf-1',
name: 'Shared Workflow',
listed: true,
publish_time: new Date().toISOString(),
workflow_json: {
version: 0.4,
nodes: [],
links: [],
groups: [],
config: {},
extra: {}
},
assets: []
})
})
}
)
await comfyPage.settings.setSetting('Comfy.TutorialCompleted', false)
await comfyPage.setup({
clearStorage: true,
url: '/?share=test-share-id'
})
await expect(
comfyPage.page.getByRole('heading', { name: 'Open shared workflow' })
).toBeVisible()
await expect(comfyPage.templates.content).toBeHidden()
})
test('Uses proper locale files for templates', async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.Locale', 'fr')

View File

@@ -1133,3 +1133,108 @@ test.describe(
})
}
)
test.describe('Vue Node Widget Link Position', { tag: '@vue-nodes' }, () => {
test('should keep widget-input link aligned after persisted-workflow reload', async ({
comfyPage
}) => {
test.setTimeout(30000)
await comfyPage.workflow.loadWorkflow(
'vueNodes/ksampler-denoise-widget-link'
)
await comfyPage.vueNodes.waitForNodes(2)
await comfyPage.workflow.waitForDraftPersisted()
await comfyPage.workflow.reloadAndWaitForApp()
await comfyPage.vueNodes.waitForNodes(2)
const ksampler = await comfyPage.page.evaluate(() => {
const node = window.app!.graph.nodes.find((n) => n.type === 'KSampler')
if (!node) return null
const findIndex = (name: string) =>
node.inputs.findIndex(
(input) => input.name === name || input.widget?.name === name
)
return {
id: node.id,
denoiseIndex: findIndex('denoise'),
schedulerIndex: findIndex('scheduler')
}
})
if (!ksampler) {
throw new Error('KSampler should be present in fixture')
}
expect(
ksampler.denoiseIndex,
'denoise input slot not found'
).toBeGreaterThanOrEqual(0)
expect(
ksampler.schedulerIndex,
'scheduler input slot not found'
).toBeGreaterThanOrEqual(0)
const denoiseSlot = slotLocator(
comfyPage.page,
ksampler.id,
ksampler.denoiseIndex,
true
)
const schedulerSlot = slotLocator(
comfyPage.page,
ksampler.id,
ksampler.schedulerIndex,
true
)
await expectVisibleAll(denoiseSlot, schedulerSlot)
await expect
.poll(() =>
getInputLinkDetails(comfyPage.page, ksampler.id, ksampler.denoiseIndex)
)
.toMatchObject({
targetId: ksampler.id,
targetSlot: ksampler.denoiseIndex
})
// If the regression returns, getInputPos stays stale relative to the
// grown slot DOM and the endpoint drifts toward scheduler. Re-read
// positions each retry so layout settle doesn't cause flakes.
await expect(async () => {
const linkEnd = await comfyPage.page.evaluate(
([nodeId, targetSlotIndex]) => {
const node = window.app!.graph.getNodeById(nodeId)
if (!node) return null
const slotPos = node.getInputPos(targetSlotIndex)
const [cx, cy] = window.app!.canvas.ds.convertOffsetToCanvas([
slotPos[0],
slotPos[1]
])
const rect = window.app!.canvas.canvas.getBoundingClientRect()
return { x: cx + rect.left, y: cy + rect.top }
},
[ksampler.id, ksampler.denoiseIndex] as const
)
expect(linkEnd, 'link endpoint should resolve').not.toBeNull()
const denoiseCenter = await getCenter(denoiseSlot)
const schedulerCenter = await getCenter(schedulerSlot)
const distToDenoise = Math.hypot(
linkEnd!.x - denoiseCenter.x,
linkEnd!.y - denoiseCenter.y
)
const rowGap = Math.hypot(
denoiseCenter.x - schedulerCenter.x,
denoiseCenter.y - schedulerCenter.y
)
// Bound at rowGap / 4 - half the inter-slot midpoint, so any drift
// toward scheduler fails well before reaching it.
expect(
distToDenoise,
`Link endpoint (${linkEnd!.x.toFixed(1)}, ${linkEnd!.y.toFixed(1)}) is ` +
`${distToDenoise.toFixed(1)}px from denoise — should be within ` +
`${(rowGap / 4).toFixed(1)}px (quarter of inter-slot gap ${rowGap.toFixed(1)}px)`
).toBeLessThan(rowGap / 4)
}).toPass({ timeout: 5000 })
})
})

View File

@@ -16,7 +16,7 @@
@plugin "./lucideStrokePlugin.js";
/* Safelist dynamic comfy icons for node library folders */
@source inline("icon-[comfy--{ai-model,bfl,bria,bytedance,credits,elevenlabs,extensions-blocks,file-output,gemini,grok,hitpaw,ideogram,image-ai-edit,kling,ltxv,luma,magnific,mask,meshy,minimax,moonvalley-marey,node,openai,pin,pixverse,play,recraft,reve,rodin,runway,sora,stability-ai,template,tencent,topaz,tripo,veo,vidu,wan,wavespeed,workflow,quiver}]");
@source inline("icon-[comfy--{ai-model,anthropic,bfl,bria,bytedance,credits,elevenlabs,extensions-blocks,file-output,gemini,grok,hitpaw,ideogram,image-ai-edit,kling,ltxv,luma,magnific,mask,meshy,minimax,moonvalley-marey,node,openai,pin,pixverse,play,recraft,reve,rodin,runway,sora,stability-ai,template,tencent,topaz,tripo,veo,vidu,wan,wavespeed,workflow,quiver}]");
/* Safelist dynamic comfy icons for essential nodes (kebab-case of node names) */
@source inline("icon-[comfy--{save-image,load-video,save-video,load-3-d,save-glb,image-batch,batch-images-node,image-crop,image-scale,image-rotate,image-blur,image-invert,canny,recraft-remove-background-node,kling-lip-sync-audio-to-video-node,load-audio,save-audio,stability-text-to-audio,lora-loader,lora-loader-model-only,primitive-string-multiline,get-video-components,video-slice,tencent-text-to-model-node,tencent-image-to-model-node,open-ai-chat-node,preview-image,image-and-mask-preview,layer-mask-mask-preview,mask-preview,image-preview-from-latent,i-tools-preview-image,i-tools-compare-image,canny-to-image,image-edit,text-to-image,pose-to-image,depth-to-video,image-to-image,canny-to-video,depth-to-image,image-to-video,pose-to-video,text-to-video,image-inpainting,image-outpainting}]");

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" fill-rule="evenodd"><title>Anthropic</title><path d="M13.827 3.52h3.603L24 20h-3.603l-6.57-16.48zm-7.258 0h3.767L16.906 20h-3.674l-1.343-3.461H5.017l-1.344 3.46H0L6.57 3.522zm4.132 9.959L8.453 7.687 6.205 13.48H10.7z"/></svg>

After

Width:  |  Height:  |  Size: 306 B

View File

@@ -349,25 +349,75 @@ describe('usePainter', () => {
})
describe('serializeValue', () => {
it('returns empty string when canvas has no strokes', async () => {
it('returns existing modelValue when not dirty (preserves workflow-restored mask reference across WidgetPainter remount)', async () => {
const maskWidget = makeWidget('mask', '')
mockWidgets.push(maskWidget)
mountPainter()
mountPainter('test-node', 'painter/existing.png [temp]')
const result = await maskWidget.serializeValue!({} as LGraphNode, 0)
expect(result).toBe('')
expect(result).toBe('painter/existing.png [temp]')
})
it('returns empty string when canvas has no strokes even if modelValue is set', async () => {
it('uploads the current canvas when no cached modelValue is present, even if nothing has been painted yet', async () => {
const maskWidget = makeWidget('mask', '')
mockWidgets.push(maskWidget)
const { modelValue } = mountPainter()
modelValue.value = 'painter/existing.png [temp]'
const fetchApiMock = vi.mocked(api.fetchApi)
fetchApiMock.mockResolvedValueOnce({
status: 200,
json: async () => ({ name: 'uploaded.png' })
} as Response)
const fakeCanvas = {
width: 4,
height: 4,
toBlob: (cb: BlobCallback) => cb(new Blob(['x']))
} as unknown as HTMLCanvasElement
const { canvasEl } = mountPainter('test-node', '')
canvasEl.value = fakeCanvas
await nextTick()
const result = await maskWidget.serializeValue!({} as LGraphNode, 0)
expect(result).toBe('')
expect(fetchApiMock).toHaveBeenCalledWith(
'/upload/image',
expect.objectContaining({ method: 'POST' })
)
expect(result).toBe('painter/uploaded.png [temp]')
})
it('returns existing modelValue when canvas element is unmounted at serialize time', async () => {
const maskWidget = makeWidget('mask', '')
mockWidgets.push(maskWidget)
mountPainter('test-node', 'painter/cached.png [temp]')
const result = await maskWidget.serializeValue!({} as LGraphNode, 0)
expect(result).toBe('painter/cached.png [temp]')
})
it('clears the cached upload reference when the user clears the canvas', () => {
const maskWidget = makeWidget('mask', '')
mockWidgets.push(maskWidget)
const fakeCanvas = {
width: 4,
height: 4,
getContext: vi.fn(() => ({
clearRect: vi.fn()
}))
} as unknown as HTMLCanvasElement
const { painter, canvasEl, modelValue } = mountPainter(
'test-node',
'painter/old-upload.png [temp]'
)
canvasEl.value = fakeCanvas
painter.handleClear()
expect(modelValue.value).toBe('')
})
})

View File

@@ -61,7 +61,6 @@ export function usePainter(nodeId: string, options: UsePainterOptions) {
let baseCanvas: HTMLCanvasElement | null = null
let baseCtx: CanvasRenderingContext2D | null = null
let hasBaseSnapshot = false
let hasStrokes = false
let dirtyX0 = 0
let dirtyY0 = 0
@@ -413,7 +412,6 @@ export function usePainter(nodeId: string, options: UsePainterOptions) {
isDrawing = true
isDirty.value = true
hasStrokes = true
snapshotBrush()
strokeProcessor = new StrokeProcessor(Math.max(1, strokeBrush!.radius / 2))
strokeProcessor.addPoint(point)
@@ -513,7 +511,7 @@ export function usePainter(nodeId: string, options: UsePainterOptions) {
if (!el || !ctx) return
ctx.clearRect(0, 0, el.width, el.height)
isDirty.value = true
hasStrokes = false
modelValue.value = ''
}
function updateCursorPos(e: PointerEvent) {
@@ -619,17 +617,11 @@ export function usePainter(nodeId: string, options: UsePainterOptions) {
return { filename, subfolder, type }
}
function isCanvasEmpty(): boolean {
return !hasStrokes
}
async function serializeValue(): Promise<string> {
const el = canvasEl.value
if (!el) return ''
if (!el) return modelValue.value
if (isCanvasEmpty()) return ''
if (!isDirty.value) return modelValue.value
if (!isDirty.value && modelValue.value) return modelValue.value
const blob = await new Promise<Blob | null>((resolve) =>
el.toBlob(resolve, 'image/png')
@@ -717,7 +709,6 @@ export function usePainter(nodeId: string, options: UsePainterOptions) {
mainCtx = null
getCtx()?.drawImage(img, 0, 0)
isDirty.value = false
hasStrokes = true
}
img.onerror = () => {
modelValue.value = ''

View File

@@ -11,6 +11,7 @@ import { useSettingStore } from '@/platform/settings/settingStore'
import { useToastStore } from '@/platform/updates/common/toastStore'
import type { ComfyWorkflow } from '@/platform/workflow/management/stores/workflowStore'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { useWorkflowDraftStore } from '@/platform/workflow/persistence/stores/workflowDraftStore'
import { useWorkflowService } from '@/platform/workflow/core/services/workflowService'
import { useMissingNodesErrorStore } from '@/platform/nodeReplacement/missingNodesErrorStore'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
@@ -1249,4 +1250,110 @@ describe('useWorkflowService', () => {
expect(workflowStore.saveWorkflow).toHaveBeenCalledWith(workflow)
})
})
describe('closeWorkflow', () => {
let workflowStore: ReturnType<typeof useWorkflowStore>
let service: ReturnType<typeof useWorkflowService>
beforeEach(() => {
workflowStore = useWorkflowStore()
service = useWorkflowService()
})
function createAndRegister(
path: string,
index: number
): LoadedComfyWorkflow {
const workflow = new ComfyWorkflowClass({
path,
modified: Date.now(),
size: 100
})
workflow.changeTracker = createMockChangeTracker()
workflow.content = '{}'
workflow.originalContent = '{}'
workflowStore.attachWorkflow(workflow, index)
return workflow as LoadedComfyWorkflow
}
it('does not close tab when switching to replacement fails', async () => {
const active = createAndRegister('workflows/active.json', 0)
createAndRegister('workflows/other.json', 1)
workflowStore.activeWorkflow = active as LoadedComfyWorkflow
vi.mocked(app.loadGraphData).mockRejectedValue(
new Error('configure failed')
)
const result = await service.closeWorkflow(active, {
warnIfUnsaved: false
})
expect(result).toBe(false)
expect(workflowStore.isOpen(active)).toBe(true)
})
it('does not remove draft when switch fails', async () => {
const draftStore = useWorkflowDraftStore()
const active = createAndRegister('workflows/active.json', 0)
createAndRegister('workflows/other.json', 1)
workflowStore.activeWorkflow = active as LoadedComfyWorkflow
vi.mocked(app.loadGraphData).mockRejectedValue(
new Error('configure failed')
)
await service.closeWorkflow(active, { warnIfUnsaved: false })
expect(draftStore.removeDraft).not.toHaveBeenCalled()
})
it('falls back to default workflow when replacement throws', async () => {
const active = createAndRegister('workflows/active.json', 0)
createAndRegister('workflows/other.json', 1)
workflowStore.activeWorkflow = active as LoadedComfyWorkflow
let callCount = 0
vi.mocked(app.loadGraphData).mockImplementation(async () => {
callCount++
if (callCount === 1) {
throw new Error('replacement failed')
}
workflowStore.activeWorkflow = createAndRegister(
'workflows/Unsaved Workflow.json',
2
)
})
const result = await service.closeWorkflow(active, {
warnIfUnsaved: false
})
expect(result).toBe(true)
expect(app.loadGraphData).toHaveBeenCalledTimes(2)
})
// Regression for #7840 silent path: loadGraphData() resolves but the active
// workflow stays unchanged because configure() errors are caught internally.
// closeWorkflow must still refuse to remove the tab. This test exists to
// pin the !workflowStore.isActive(workflow) postcondition in trySwitch().
it('does not close tab when loadGraphData resolves but active workflow does not change', async () => {
const active = createAndRegister('workflows/active.json', 0)
createAndRegister('workflows/other.json', 1)
workflowStore.activeWorkflow = active as LoadedComfyWorkflow
// Both the replacement switch and the default-workflow fallback resolve
// without changing activeWorkflow — simulating loadGraphData swallowing
// configure() errors internally.
vi.mocked(app.loadGraphData).mockResolvedValue(undefined)
const result = await service.closeWorkflow(active, {
warnIfUnsaved: false
})
expect(result).toBe(false)
expect(workflowStore.isOpen(active)).toBe(true)
expect(workflowStore.isActive(active)).toBe(true)
})
})
})

View File

@@ -266,6 +266,35 @@ export const useWorkflowService = () => {
})
}
async function trySwitch(
action: () => Promise<void>,
workflow: ComfyWorkflow
): Promise<boolean> {
try {
await action()
return !workflowStore.isActive(workflow)
} catch (error) {
console.error('Failed to switch workflow', error)
return false
}
}
async function switchAwayFrom(workflow: ComfyWorkflow): Promise<boolean> {
const replacement =
workflowStore.getMostRecentWorkflow() ??
workflowStore.openedWorkflowIndexShift(1)
if (replacement) {
const switched = await trySwitch(
() => openWorkflow(replacement),
workflow
)
if (switched) return true
}
return trySwitch(() => loadDefaultWorkflow(), workflow)
}
/**
* Close a workflow with confirmation if there are unsaved changes
* @param workflow The workflow to close
@@ -295,23 +324,24 @@ export const useWorkflowService = () => {
}
}
workflowDraftStore.removeDraft(workflow.path)
// If this is the last workflow, create a new default temporary workflow
// If this is the last workflow, create a new default temporary workflow.
// Route through trySwitch so a rejection from loadGraphData
// (validation / extension hooks / node-replacement loading) keeps the tab
// open instead of throwing, matching the multi-tab contract below.
if (workflowStore.openWorkflows.length === 1) {
await loadDefaultWorkflow()
const switched = await trySwitch(() => loadDefaultWorkflow(), workflow)
if (!switched) return false
}
// If this is the active workflow, load the most recent workflow from history
if (workflowStore.isActive(workflow)) {
const mostRecentWorkflow = workflowStore.getMostRecentWorkflow()
if (mostRecentWorkflow) {
await openWorkflow(mostRecentWorkflow)
} else {
// Fallback to next workflow if no history
await loadNextOpenedWorkflow()
}
// If this is the active workflow, switch to another before closing
else if (workflowStore.isActive(workflow)) {
const didSwitch = await switchAwayFrom(workflow)
if (!didSwitch) return false
}
// Remove draft only after switch succeeds — keeps draft intact if close is
// aborted due to switch failure, so the tab retains its persisted state.
workflowDraftStore.removeDraft(workflow.path)
await workflowStore.closeWorkflow(workflow)
return true
}

View File

@@ -76,15 +76,25 @@ vi.mock(
})
)
const commandStoreMocks = vi.hoisted(() => ({
execute: vi.fn()
}))
vi.mock('@/stores/commandStore', () => ({
useCommandStore: () => ({
execute: vi.fn()
execute: commandStoreMocks.execute
})
}))
const routeMocks = vi.hoisted(() => ({
query: {} as Record<string, unknown>
}))
vi.mock('vue-router', () => ({
useRoute: () => ({
query: {}
get query() {
return routeMocks.query
}
}),
useRouter: () => ({
replace: vi.fn()
@@ -97,13 +107,30 @@ vi.mock('@/composables/auth/useCurrentUser', () => ({
})
}))
const preservedQueryMocks = vi.hoisted(() => ({
payloads: {} as Record<string, Record<string, string> | undefined>
}))
vi.mock('@/platform/navigation/preservedQueryManager', () => ({
hydratePreservedQuery: vi.fn(),
mergePreservedQueryIntoQuery: vi.fn(() => null)
mergePreservedQueryIntoQuery: vi.fn(
(namespace: string, query: Record<string, unknown> = {}) => {
const payload = preservedQueryMocks.payloads[namespace]
if (!payload) return undefined
const next: Record<string, unknown> = { ...query }
let changed = false
for (const [key, value] of Object.entries(payload)) {
if (typeof next[key] === 'string') continue
next[key] = value
changed = true
}
return changed ? next : undefined
}
)
}))
vi.mock('@/platform/navigation/preservedQueryNamespaces', () => ({
PRESERVED_QUERY_NAMESPACES: { TEMPLATE: 'template' }
PRESERVED_QUERY_NAMESPACES: { TEMPLATE: 'template', SHARE: 'share' }
}))
vi.mock('@/platform/distribution/types', () => ({
@@ -178,6 +205,9 @@ describe('useWorkflowPersistenceV2', () => {
mocks.apiMock.removeEventListener.mockImplementation(() => {})
openWorkflowMock.mockReset()
loadBlankWorkflowMock.mockReset()
commandStoreMocks.execute.mockReset()
routeMocks.query = {}
preservedQueryMocks.payloads = {}
})
afterEach(() => {
@@ -357,4 +387,43 @@ describe('useWorkflowPersistenceV2', () => {
expect(openWorkflowMock).not.toHaveBeenCalled()
})
})
describe('loadDefaultWorkflow', () => {
it('opens templates browser for first-time users', async () => {
const { initializeWorkflow } = useWorkflowPersistenceV2()
await initializeWorkflow()
expect(loadBlankWorkflowMock).toHaveBeenCalled()
expect(commandStoreMocks.execute).toHaveBeenCalledWith(
'Comfy.BrowseTemplates'
)
})
it('does not open templates browser when share param is in URL', async () => {
routeMocks.query = { share: 'test-share-id' }
const { initializeWorkflow } = useWorkflowPersistenceV2()
await initializeWorkflow()
expect(loadBlankWorkflowMock).toHaveBeenCalled()
expect(commandStoreMocks.execute).not.toHaveBeenCalledWith(
'Comfy.BrowseTemplates'
)
})
it('does not open templates browser when share intent is preserved across /user-select redirect', async () => {
// No-local-user flow: ?share=... was captured into sessionStorage and the
// URL query was dropped during the /user-select redirect before
// initializeWorkflow() runs.
preservedQueryMocks.payloads.share = { share: 'test-share-id' }
const { initializeWorkflow } = useWorkflowPersistenceV2()
await initializeWorkflow()
expect(loadBlankWorkflowMock).toHaveBeenCalled()
expect(commandStoreMocks.execute).not.toHaveBeenCalledWith(
'Comfy.BrowseTemplates'
)
})
})
})

View File

@@ -48,6 +48,7 @@ export function useWorkflowPersistenceV2() {
const sharedWorkflowUrlLoader = useSharedWorkflowUrlLoader()
const templateUrlLoader = useTemplateUrlLoader()
const TEMPLATE_NAMESPACE = PRESERVED_QUERY_NAMESPACES.TEMPLATE
const SHARE_NAMESPACE = PRESERVED_QUERY_NAMESPACES.SHARE
const draftStore = useWorkflowDraftStoreV2()
const tabState = useWorkflowTabState()
const toast = useToast()
@@ -160,11 +161,20 @@ export function useWorkflowPersistenceV2() {
})
}
const hasSharedWorkflowIntent = () => {
if (typeof route.query.share === 'string') return true
hydratePreservedQuery(SHARE_NAMESPACE)
const merged = mergePreservedQueryIntoQuery(SHARE_NAMESPACE, route.query)
return typeof merged?.share === 'string'
}
const loadDefaultWorkflow = async () => {
if (!settingStore.get('Comfy.TutorialCompleted')) {
await settingStore.set('Comfy.TutorialCompleted', true)
await useWorkflowService().loadBlankWorkflow()
await useCommandStore().execute('Comfy.BrowseTemplates')
if (!hasSharedWorkflowIntent()) {
await useCommandStore().execute('Comfy.BrowseTemplates')
}
} else {
await comfyApp.loadGraphData()
}

View File

@@ -90,6 +90,7 @@ import { useCanvasInteractions } from '@/renderer/core/canvas/useCanvasInteracti
import AppInput from '@/renderer/extensions/linearMode/AppInput.vue'
import { useNodeZIndex } from '@/renderer/extensions/vueNodes/composables/useNodeZIndex'
import { useProcessedWidgets } from '@/renderer/extensions/vueNodes/composables/useProcessedWidgets'
import { useVueElementTracking } from '@/renderer/extensions/vueNodes/composables/useVueNodeResizeTracking'
import { cn } from '@comfyorg/tailwind-utils'
import InputSlot from './InputSlot.vue'
@@ -134,4 +135,9 @@ const {
processedWidgets,
showAdvanced
} = useProcessedWidgets(() => nodeData)
// Tracks widget-row growth that the node-level RO can't see
if (nodeData?.id != null) {
useVueElementTracking(String(nodeData.id), 'widgets-grid')
}
</script>

View File

@@ -29,7 +29,10 @@ const raf = createRafBatch(() => {
flushScheduledSlotLayoutSync()
})
function scheduleSlotLayoutSync(nodeId: string) {
export function scheduleSlotLayoutSync(nodeId: string) {
// Drop signals for unregistered nodes (e.g. preview nodes with synthetic
// ids from LGraphNodePreview) - they'd otherwise pump setDirty per RAF.
if (!useNodeSlotRegistryStore().getNode(nodeId)) return
pendingNodes.add(nodeId)
raf.schedule()
}

View File

@@ -43,7 +43,8 @@ const testState = vi.hoisted(() => ({
nodeLayouts: new Map<NodeId, NodeLayout>(),
batchUpdateNodeBounds: vi.fn(),
setSource: vi.fn(),
syncNodeSlotLayoutsFromDOM: vi.fn()
syncNodeSlotLayoutsFromDOM: vi.fn(),
scheduleSlotLayoutSync: vi.fn()
}))
vi.mock('@vueuse/core', () => ({
@@ -73,6 +74,7 @@ vi.mock('@/renderer/core/layout/store/layoutStore', () => ({
}))
vi.mock('./useSlotElementTracking', () => ({
scheduleSlotLayoutSync: testState.scheduleSlotLayoutSync,
syncNodeSlotLayoutsFromDOM: testState.syncNodeSlotLayoutsFromDOM
}))
@@ -159,6 +161,7 @@ describe('useVueNodeResizeTracking', () => {
testState.batchUpdateNodeBounds.mockReset()
testState.setSource.mockReset()
testState.syncNodeSlotLayoutsFromDOM.mockReset()
testState.scheduleSlotLayoutSync.mockReset()
resizeObserverState.observe.mockReset()
resizeObserverState.unobserve.mockReset()
resizeObserverState.disconnect.mockReset()
@@ -317,4 +320,25 @@ describe('useVueNodeResizeTracking', () => {
expect(testState.setSource).toHaveBeenCalledWith(LayoutSource.DOM)
expect(testState.batchUpdateNodeBounds).toHaveBeenCalled()
})
it('widgets-grid resize schedules a slot resync without writing node bounds', () => {
const parentNodeId: NodeId = 'parent-node'
const element = document.createElement('div')
element.dataset.widgetsGridNodeId = parentNodeId
const boxSizes = [{ inlineSize: 200, blockSize: 80 }]
const entry = {
target: element,
borderBoxSize: boxSizes,
contentBoxSize: boxSizes,
devicePixelContentBoxSize: boxSizes,
contentRect: new DOMRect(0, 0, 200, 80)
} satisfies ResizeEntryLike
resizeObserverState.callback?.([entry], createObserverMock())
expect(testState.scheduleSlotLayoutSync).toHaveBeenCalledWith(parentNodeId)
expect(testState.batchUpdateNodeBounds).not.toHaveBeenCalled()
expect(testState.setSource).not.toHaveBeenCalled()
expect(testState.syncNodeSlotLayoutsFromDOM).not.toHaveBeenCalled()
})
})

View File

@@ -24,7 +24,10 @@ import {
} from '@/renderer/core/layout/utils/geometry'
import { removeNodeTitleHeight } from '@/renderer/core/layout/utils/nodeSizeUtil'
import { syncNodeSlotLayoutsFromDOM } from './useSlotElementTracking'
import {
scheduleSlotLayoutSync,
syncNodeSlotLayoutsFromDOM
} from './useSlotElementTracking'
/**
* Generic update item for element bounds tracking
@@ -47,14 +50,14 @@ interface CachedNodeMeasurement {
interface ElementTrackingConfig {
/** Data attribute name (e.g., 'nodeId') */
dataAttribute: string
/** Handler for processing bounds updates */
updateHandler: (updates: ElementBoundsUpdate[]) => void
/** Handler for processing bounds updates. Omit for signal-only entries. */
updateHandler?: (updates: ElementBoundsUpdate[]) => void
}
/**
* Registry of tracking configurations by element type
*/
const trackingConfigs: Map<string, ElementTrackingConfig> = new Map([
const trackingConfigs = new Map<string, ElementTrackingConfig>([
[
'node',
{
@@ -67,7 +70,10 @@ const trackingConfigs: Map<string, ElementTrackingConfig> = new Map([
layoutStore.batchUpdateNodeBounds(nodeUpdates)
}
}
]
],
// Signal-only: outer node stays at its persisted min-h floor during
// widget hydration, so the inner grid's RO is the only slot-drift signal.
['widgets-grid', { dataAttribute: 'widgetsGridNodeId' }]
])
// Elements whose ResizeObserver fired while the tab was hidden
@@ -121,6 +127,14 @@ const resizeObserver = new ResizeObserver((entries) => {
if (!(entry.target instanceof HTMLElement)) continue
const element = entry.target
// Signal-only widgets-grid resize - route the parent node through the
// slot-layout pipeline and skip bounds processing entirely.
const widgetsGridParentNodeId = element.dataset.widgetsGridNodeId
if (widgetsGridParentNodeId) {
scheduleSlotLayoutSync(widgetsGridParentNodeId as NodeId)
continue
}
// Find which type this element belongs to
let elementType: string | undefined
let elementId: string | undefined
@@ -238,7 +252,7 @@ const resizeObserver = new ResizeObserver((entries) => {
// Flush per-type
for (const [type, updates] of updatesByType) {
const config = trackingConfigs.get(type)
if (config && updates.length) config.updateHandler(updates)
if (config?.updateHandler && updates.length) config.updateHandler(updates)
}
}

View File

@@ -28,6 +28,7 @@ describe('getProviderIcon', () => {
it('returns icon class for simple provider name', () => {
expect(getProviderIcon('BFL')).toBe('icon-[comfy--bfl]')
expect(getProviderIcon('OpenAI')).toBe('icon-[comfy--openai]')
expect(getProviderIcon('Anthropic')).toBe('icon-[comfy--anthropic]')
})
it('converts spaces to hyphens', () => {
@@ -47,6 +48,7 @@ describe('getProviderBorderStyle', () => {
expect(getProviderBorderStyle('BFL')).toBe('#ffffff')
expect(getProviderBorderStyle('OpenAI')).toBe('#B6B6B6')
expect(getProviderBorderStyle('Bria')).toBe('#B6B6B6')
expect(getProviderBorderStyle('Anthropic')).toBe('#D97757')
})
it('returns gradient for dual-color providers', () => {

View File

@@ -56,6 +56,7 @@ export const getCategoryIcon = (categoryId: string): string => {
* Each entry can be a single color or [color1, color2] for gradient.
*/
const PROVIDER_COLORS: Record<string, string | [string, string]> = {
anthropic: '#D97757',
bfl: '#ffffff',
bria: '#B6B6B6',
elevenlabs: '#B6B6B6',

View File

@@ -1,6 +1,7 @@
import { createTestingPinia } from '@pinia/testing'
import ProgressSpinner from 'primevue/progressspinner'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ref } from 'vue'
import { render, screen } from '@testing-library/vue'
@@ -56,7 +57,8 @@ vi.mock('@vueuse/core', () => ({
createSharedComposable: vi.fn((fn) => {
let cached: ReturnType<typeof fn>
return (...args: Parameters<typeof fn>) => (cached ??= fn(...args))
})
}),
useDocumentVisibility: vi.fn(() => ref<'visible' | 'hidden'>('visible'))
}))
vi.mock('@/config', () => ({