Compare commits

..

7 Commits

Author SHA1 Message Date
Christian Byrne
5cd24e562b Merge branch 'main' into perf/batch-progress-events 2026-03-26 21:09:49 -07:00
bymyself
95a1042854 refactor: extract createRafCoalescer utility for last-write-wins RAF batching
Replace manual _pending* variables + createRafBatch + null-check
callbacks with a typed createRafCoalescer<T> that encapsulates the
coalescing pattern. Extract cancelPendingProgressUpdates() helper
to deduplicate the 3 cancel sites in executionStore.
2026-03-25 12:45:06 -07:00
GitHub Action
3a4ab844db [automated] Apply ESLint and Oxfmt fixes 2026-03-25 12:45:06 -07:00
bymyself
31c51d9a2e test: cover pending RAF discard when execution completes
Adds tests verifying that pending progress and progress_state RAFs
are cancelled when execution ends via success, error, or interruption.

https://github.com/Comfy-Org/ComfyUI_frontend/pull/9303#discussion_r2923353907
2026-03-25 12:45:06 -07:00
bymyself
c77f461817 fix: cancel pending RAFs in resetExecutionState and handleExecuting
Prevents a race condition where:
1. A progress WebSocket event arrives and schedules a RAF
2. An execution-end event fires and clears all state
3. The pending RAF callback fires and writes stale data back

https://github.com/Comfy-Org/ComfyUI_frontend/pull/9303#discussion_r2923324838
2026-03-25 12:45:06 -07:00
bymyself
72b1f464cb fix: use createRafBatch utility instead of manual RAF management
Replaces four raw `let` variables and manual requestAnimationFrame/
cancelAnimationFrame calls with two `createRafBatch` instances from
the existing `src/utils/rafBatch.ts` utility.

https://github.com/Comfy-Org/ComfyUI_frontend/pull/9303#discussion_r2923338919
2026-03-25 12:45:06 -07:00
bymyself
c4919523c5 fix: RAF-batch WebSocket progress events to reduce reactive update storms
Amp-Thread-ID: https://ampcode.com/threads/T-019ca43d-b7a5-759f-b88f-1319faac8a01
2026-03-25 12:45:06 -07:00
22 changed files with 510 additions and 599 deletions

View File

@@ -1,48 +0,0 @@
{
"last_node_id": 1,
"last_link_id": 0,
"nodes": [
{
"id": 1,
"type": "Painter",
"pos": [50, 50],
"size": [450, 550],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [
{
"name": "image",
"type": "IMAGE",
"link": null
}
],
"outputs": [
{
"name": "IMAGE",
"type": "IMAGE",
"links": null
},
{
"name": "MASK",
"type": "MASK",
"links": null
}
],
"properties": {
"Node name for S&R": "Painter"
},
"widgets_values": ["", 512, 512, "#000000"]
}
],
"links": [],
"groups": [],
"config": {},
"extra": {
"ds": {
"offset": [0, 0],
"scale": 1
}
},
"version": 0.4
}

View File

@@ -19,12 +19,10 @@ import { ContextMenu } from './components/ContextMenu'
import { SettingDialog } from './components/SettingDialog'
import { BottomPanel } from './components/BottomPanel'
import {
AssetsSidebarTab,
NodeLibrarySidebarTab,
WorkflowsSidebarTab
} from './components/SidebarTab'
import { Topbar } from './components/Topbar'
import { AssetsHelper } from './helpers/AssetsHelper'
import { CanvasHelper } from './helpers/CanvasHelper'
import { PerformanceHelper } from './helpers/PerformanceHelper'
import { QueueHelper } from './helpers/QueueHelper'
@@ -57,7 +55,6 @@ class ComfyPropertiesPanel {
}
class ComfyMenu {
private _assetsTab: AssetsSidebarTab | null = null
private _nodeLibraryTab: NodeLibrarySidebarTab | null = null
private _workflowsTab: WorkflowsSidebarTab | null = null
private _topbar: Topbar | null = null
@@ -81,11 +78,6 @@ class ComfyMenu {
return this._nodeLibraryTab
}
get assetsTab() {
this._assetsTab ??= new AssetsSidebarTab(this.page)
return this._assetsTab
}
get workflowsTab() {
this._workflowsTab ??= new WorkflowsSidebarTab(this.page)
return this._workflowsTab
@@ -200,7 +192,6 @@ export class ComfyPage {
public readonly command: CommandHelper
public readonly bottomPanel: BottomPanel
public readonly perf: PerformanceHelper
public readonly assets: AssetsHelper
public readonly queue: QueueHelper
/** Worker index to test user ID */
@@ -247,7 +238,6 @@ export class ComfyPage {
this.command = new CommandHelper(page)
this.bottomPanel = new BottomPanel(page)
this.perf = new PerformanceHelper(page)
this.assets = new AssetsHelper(page)
this.queue = new QueueHelper(page)
}

View File

@@ -168,32 +168,3 @@ export class WorkflowsSidebarTab extends SidebarTab {
.click()
}
}
export class AssetsSidebarTab extends SidebarTab {
constructor(public override readonly page: Page) {
super(page, 'assets')
}
get generatedTab() {
return this.page.getByRole('tab', { name: 'Generated' })
}
get importedTab() {
return this.page.getByRole('tab', { name: 'Imported' })
}
get emptyStateMessage() {
return this.page.getByText(
'Upload files or generate content to see them here'
)
}
emptyStateTitle(title: string) {
return this.page.getByText(title)
}
override async open() {
await super.open()
await this.generatedTab.waitFor({ state: 'visible' })
}
}

View File

@@ -1,147 +0,0 @@
import type { Page, Route } from '@playwright/test'
import type { RawJobListItem } from '../../../src/platform/remote/comfyui/jobs/jobTypes'
const jobsListRoutePattern = /\/api\/jobs(?:\?.*)?$/
const inputFilesRoutePattern = /\/internal\/files\/input(?:\?.*)?$/
function parseLimit(url: URL, total: number): number {
const value = Number(url.searchParams.get('limit'))
if (!Number.isInteger(value) || value <= 0) {
return total
}
return value
}
function parseOffset(url: URL): number {
const value = Number(url.searchParams.get('offset'))
if (!Number.isInteger(value) || value < 0) {
return 0
}
return value
}
function getExecutionDuration(job: RawJobListItem): number {
const start = job.execution_start_time ?? 0
const end = job.execution_end_time ?? 0
return end - start
}
export class AssetsHelper {
private jobsRouteHandler: ((route: Route) => Promise<void>) | null = null
private inputFilesRouteHandler: ((route: Route) => Promise<void>) | null =
null
private generatedJobs: RawJobListItem[] = []
private importedFiles: string[] = []
constructor(private readonly page: Page) {}
async mockOutputHistory(jobs: RawJobListItem[]): Promise<void> {
this.generatedJobs = [...jobs]
if (this.jobsRouteHandler) {
return
}
this.jobsRouteHandler = async (route: Route) => {
const url = new URL(route.request().url())
const statuses = url.searchParams
.get('status')
?.split(',')
.map((status) => status.trim())
.filter(Boolean)
const workflowId = url.searchParams.get('workflow_id')
const sortBy = url.searchParams.get('sort_by')
const sortOrder = url.searchParams.get('sort_order') === 'asc' ? 1 : -1
let filteredJobs = [...this.generatedJobs]
if (statuses?.length) {
filteredJobs = filteredJobs.filter((job) =>
statuses.includes(job.status)
)
}
if (workflowId) {
filteredJobs = filteredJobs.filter(
(job) => job.workflow_id === workflowId
)
}
filteredJobs.sort((left, right) => {
const leftValue =
sortBy === 'execution_duration'
? getExecutionDuration(left)
: left.create_time
const rightValue =
sortBy === 'execution_duration'
? getExecutionDuration(right)
: right.create_time
return (leftValue - rightValue) * sortOrder
})
const offset = parseOffset(url)
const total = filteredJobs.length
const limit = parseLimit(url, total)
const visibleJobs = filteredJobs.slice(offset, offset + limit)
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
jobs: visibleJobs,
pagination: {
offset,
limit,
total,
has_more: offset + visibleJobs.length < total
}
})
})
}
await this.page.route(jobsListRoutePattern, this.jobsRouteHandler)
}
async mockInputFiles(files: string[]): Promise<void> {
this.importedFiles = [...files]
if (this.inputFilesRouteHandler) {
return
}
this.inputFilesRouteHandler = async (route: Route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(this.importedFiles)
})
}
await this.page.route(inputFilesRoutePattern, this.inputFilesRouteHandler)
}
async mockEmptyState(): Promise<void> {
await this.mockOutputHistory([])
await this.mockInputFiles([])
}
async clearMocks(): Promise<void> {
this.generatedJobs = []
this.importedFiles = []
if (this.jobsRouteHandler) {
await this.page.unroute(jobsListRoutePattern, this.jobsRouteHandler)
this.jobsRouteHandler = null
}
if (this.inputFilesRouteHandler) {
await this.page.unroute(
inputFilesRoutePattern,
this.inputFilesRouteHandler
)
this.inputFilesRouteHandler = null
}
}
}

View File

@@ -20,12 +20,7 @@ export const TestIds = {
main: 'graph-canvas',
contextMenu: 'canvas-context-menu',
toggleMinimapButton: 'toggle-minimap-button',
toggleLinkVisibilityButton: 'toggle-link-visibility-button',
zoomControlsButton: 'zoom-controls-button',
zoomInAction: 'zoom-in-action',
zoomOutAction: 'zoom-out-action',
zoomToFitAction: 'zoom-to-fit-action',
zoomPercentageInput: 'zoom-percentage-input'
toggleLinkVisibilityButton: 'toggle-link-visibility-button'
},
dialogs: {
settings: 'settings-dialog',
@@ -51,8 +46,7 @@ export const TestIds = {
topbar: {
queueButton: 'queue-button',
queueModeMenuTrigger: 'queue-mode-menu-trigger',
saveButton: 'save-workflow-button',
subscribeButton: 'topbar-subscribe-button'
saveButton: 'save-workflow-button'
},
nodeLibrary: {
bookmarksSection: 'node-library-bookmarks-section'

View File

@@ -1,28 +0,0 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
import { TestIds } from '../fixtures/selectors'
test.describe('Cloud distribution UI @cloud', () => {
test('subscribe button is attached in cloud mode @cloud', async ({
comfyPage
}) => {
const subscribeButton = comfyPage.page.getByTestId(
TestIds.topbar.subscribeButton
)
await expect(subscribeButton).toBeAttached()
})
test('bottom panel toggle is hidden in cloud mode @cloud', async ({
comfyPage
}) => {
const sideToolbar = comfyPage.page.getByTestId(TestIds.sidebar.toolbar)
await expect(sideToolbar).toBeVisible()
// In cloud mode, the bottom panel toggle button should not be rendered
const bottomPanelToggle = sideToolbar.getByRole('button', {
name: /bottom panel|terminal/i
})
await expect(bottomPanelToggle).toHaveCount(0)
})
})

View File

@@ -1,92 +0,0 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
test.describe('Painter', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
await comfyPage.workflow.loadWorkflow('widgets/painter_widget')
await comfyPage.vueNodes.waitForNodes()
})
test(
'Renders canvas and controls',
{ tag: ['@smoke', '@screenshot'] },
async ({ comfyPage }) => {
const node = comfyPage.vueNodes.getNodeLocator('1')
await expect(node).toBeVisible()
const painterWidget = node.locator('.widget-expands')
await expect(painterWidget).toBeVisible()
await expect(painterWidget.locator('canvas')).toBeVisible()
await expect(painterWidget.getByText('Brush')).toBeVisible()
await expect(painterWidget.getByText('Eraser')).toBeVisible()
await expect(painterWidget.getByText('Clear')).toBeVisible()
await expect(
painterWidget.locator('input[type="color"]').first()
).toBeVisible()
await expect(node).toHaveScreenshot('painter-default-state.png')
}
)
test(
'Drawing a stroke changes the canvas',
{ tag: ['@smoke', '@screenshot'] },
async ({ comfyPage }) => {
const node = comfyPage.vueNodes.getNodeLocator('1')
const canvas = node.locator('.widget-expands canvas')
await expect(canvas).toBeVisible()
const isEmptyBefore = await canvas.evaluate((el) => {
const ctx = (el as HTMLCanvasElement).getContext('2d')
if (!ctx) return true
const data = ctx.getImageData(
0,
0,
(el as HTMLCanvasElement).width,
(el as HTMLCanvasElement).height
)
return data.data.every((v, i) => (i % 4 === 3 ? v === 0 : true))
})
expect(isEmptyBefore).toBe(true)
const box = await canvas.boundingBox()
if (!box) throw new Error('Canvas bounding box not found')
await comfyPage.page.mouse.move(
box.x + box.width * 0.3,
box.y + box.height * 0.5
)
await comfyPage.page.mouse.down()
await comfyPage.page.mouse.move(
box.x + box.width * 0.7,
box.y + box.height * 0.5,
{ steps: 10 }
)
await comfyPage.page.mouse.up()
await comfyPage.nextFrame()
await expect(async () => {
const hasContent = await canvas.evaluate((el) => {
const ctx = (el as HTMLCanvasElement).getContext('2d')
if (!ctx) return false
const data = ctx.getImageData(
0,
0,
(el as HTMLCanvasElement).width,
(el as HTMLCanvasElement).height
)
for (let i = 3; i < data.data.length; i += 4) {
if (data.data[i] > 0) return true
}
return false
})
expect(hasContent).toBe(true)
}).toPass()
await expect(node).toHaveScreenshot('painter-after-stroke.png')
}
)
})

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

View File

@@ -1,30 +0,0 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../../fixtures/ComfyPage'
test.describe('Assets sidebar', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.assets.mockEmptyState()
await comfyPage.setup()
})
test.afterEach(async ({ comfyPage }) => {
await comfyPage.assets.clearMocks()
})
test('Shows empty-state copy for generated and imported tabs', async ({
comfyPage
}) => {
const tab = comfyPage.menu.assetsTab
await tab.open()
await expect(tab.emptyStateTitle('No generated files found')).toBeVisible()
await expect(tab.emptyStateMessage).toBeVisible()
await tab.importedTab.click()
await expect(tab.emptyStateTitle('No imported files found')).toBeVisible()
await expect(tab.emptyStateMessage).toBeVisible()
})
})

View File

@@ -1,138 +0,0 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
import { TestIds } from '../fixtures/selectors'
test.describe('Zoom Controls', { tag: '@canvas' }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.Graph.CanvasMenu', true)
await comfyPage.workflow.loadWorkflow('default')
await comfyPage.page.waitForFunction(() => window.app && window.app.canvas)
})
test('Default zoom is 100% and node has a size', async ({ comfyPage }) => {
const nodeSize = await comfyPage.page.evaluate(
() => window.app!.graph.nodes[0].size
)
expect(nodeSize[0]).toBeGreaterThan(0)
expect(nodeSize[1]).toBeGreaterThan(0)
const zoomButton = comfyPage.page.getByTestId(
TestIds.canvas.zoomControlsButton
)
await expect(zoomButton).toContainText('100%')
const scale = await comfyPage.canvasOps.getScale()
expect(scale).toBeCloseTo(1.0, 1)
})
test('Zoom to fit reduces percentage', async ({ comfyPage }) => {
const zoomButton = comfyPage.page.getByTestId(
TestIds.canvas.zoomControlsButton
)
await zoomButton.click()
await comfyPage.nextFrame()
const zoomToFit = comfyPage.page.getByTestId(TestIds.canvas.zoomToFitAction)
await expect(zoomToFit).toBeVisible()
await zoomToFit.click()
await expect
.poll(() => comfyPage.canvasOps.getScale(), { timeout: 2000 })
.toBeLessThan(1.0)
await expect(zoomButton).not.toContainText('100%')
})
test('Zoom out reduces percentage', async ({ comfyPage }) => {
const initialScale = await comfyPage.canvasOps.getScale()
const zoomButton = comfyPage.page.getByTestId(
TestIds.canvas.zoomControlsButton
)
await zoomButton.click()
await comfyPage.nextFrame()
const zoomOut = comfyPage.page.getByTestId(TestIds.canvas.zoomOutAction)
await zoomOut.click()
await comfyPage.nextFrame()
const newScale = await comfyPage.canvasOps.getScale()
expect(newScale).toBeLessThan(initialScale)
})
test('Zoom out clamps at 10% minimum', async ({ comfyPage }) => {
const zoomButton = comfyPage.page.getByTestId(
TestIds.canvas.zoomControlsButton
)
await zoomButton.click()
await comfyPage.nextFrame()
const zoomOut = comfyPage.page.getByTestId(TestIds.canvas.zoomOutAction)
for (let i = 0; i < 30; i++) {
await zoomOut.click()
}
await comfyPage.nextFrame()
await expect
.poll(() => comfyPage.canvasOps.getScale(), { timeout: 2000 })
.toBeCloseTo(0.1, 1)
await expect(zoomButton).toContainText('10%')
})
test('Manual percentage entry allows zoom in and zoom out', async ({
comfyPage
}) => {
const zoomButton = comfyPage.page.getByTestId(
TestIds.canvas.zoomControlsButton
)
await zoomButton.click()
await comfyPage.nextFrame()
const input = comfyPage.page
.getByTestId(TestIds.canvas.zoomPercentageInput)
.locator('input')
await input.focus()
await comfyPage.page.keyboard.press('Control+a')
await input.pressSequentially('100')
await input.press('Enter')
await comfyPage.nextFrame()
await expect
.poll(() => comfyPage.canvasOps.getScale(), { timeout: 2000 })
.toBeCloseTo(1.0, 1)
const zoomIn = comfyPage.page.getByTestId(TestIds.canvas.zoomInAction)
await zoomIn.click()
await comfyPage.nextFrame()
const scaleAfterZoomIn = await comfyPage.canvasOps.getScale()
expect(scaleAfterZoomIn).toBeGreaterThan(1.0)
const zoomOut = comfyPage.page.getByTestId(TestIds.canvas.zoomOutAction)
await zoomOut.click()
await comfyPage.nextFrame()
const scaleAfterZoomOut = await comfyPage.canvasOps.getScale()
expect(scaleAfterZoomOut).toBeLessThan(scaleAfterZoomIn)
})
test('Clicking zoom button toggles zoom controls visibility', async ({
comfyPage
}) => {
const zoomButton = comfyPage.page.getByTestId(
TestIds.canvas.zoomControlsButton
)
await zoomButton.click()
await comfyPage.nextFrame()
const zoomToFit = comfyPage.page.getByTestId(TestIds.canvas.zoomToFitAction)
await expect(zoomToFit).toBeVisible()
await zoomButton.click()
await comfyPage.nextFrame()
await expect(zoomToFit).not.toBeVisible()
})
})

View File

@@ -8,7 +8,6 @@
"repository": "https://github.com/Comfy-Org/ComfyUI_frontend",
"type": "module",
"scripts": {
"build:cloud": "cross-env DISTRIBUTION=cloud NODE_OPTIONS='--max-old-space-size=8192' nx build",
"build:desktop": "nx build @comfyorg/desktop-ui",
"build-storybook": "storybook build",
"build:types": "nx build --config vite.types.config.mts && node scripts/prepare-types.js",

View File

@@ -36,7 +36,7 @@ export default defineConfig({
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
timeout: 15000,
grepInvert: /@mobile|@perf|@audit|@cloud/ // Run all tests except those tagged with @mobile, @perf, @audit, or @cloud
grepInvert: /@mobile|@perf|@audit/ // Run all tests except those tagged with @mobile, @perf, or @audit
},
{
@@ -85,14 +85,6 @@ export default defineConfig({
// use: { ...devices['Desktop Safari'] },
// },
{
name: 'cloud',
use: { ...devices['Desktop Chrome'] },
timeout: 15000,
grep: /@cloud/, // Run only tests tagged with @cloud
grepInvert: /@oss/ // Exclude tests tagged with @oss
},
/* Test against mobile viewports. */
{
name: 'mobile-chrome',

View File

@@ -11,7 +11,6 @@
<div class="flex flex-col gap-1">
<div
class="flex cursor-pointer items-center justify-between rounded-sm px-3 py-2 text-sm hover:bg-node-component-surface-hovered"
data-testid="zoom-in-action"
@mousedown="startRepeat('Comfy.Canvas.ZoomIn')"
@mouseup="stopRepeat"
@mouseleave="stopRepeat"
@@ -24,7 +23,6 @@
<div
class="flex cursor-pointer items-center justify-between rounded-sm px-3 py-2 text-sm hover:bg-node-component-surface-hovered"
data-testid="zoom-out-action"
@mousedown="startRepeat('Comfy.Canvas.ZoomOut')"
@mouseup="stopRepeat"
@mouseleave="stopRepeat"
@@ -37,7 +35,6 @@
<div
class="flex cursor-pointer items-center justify-between rounded-sm px-3 py-2 text-sm hover:bg-node-component-surface-hovered"
data-testid="zoom-to-fit-action"
@click="executeCommand('Comfy.Canvas.FitView')"
>
<span class="font-medium">{{ $t('zoomControls.zoomToFit') }}</span>
@@ -49,7 +46,6 @@
<div
ref="zoomInputContainer"
class="zoomInputContainer flex items-center gap-1 rounded-sm bg-input-surface p-2"
data-testid="zoom-percentage-input"
>
<InputNumber
:default-value="canvasStore.appScalePercentage"

View File

@@ -67,14 +67,7 @@ Sentry.init({
replaysOnErrorSampleRate: 0,
// Only set these for non-cloud builds
...(isCloud
? {
integrations: [
// Disable event target wrapping to reduce overhead on high-frequency
// DOM events (pointermove, mousemove, wheel). Sentry still captures
// errors via window.onerror and unhandledrejection.
Sentry.browserApiErrorsIntegration({ eventTarget: false })
]
}
? {}
: {
integrations: [],
autoSessionTracking: false,

View File

@@ -141,7 +141,6 @@ import { computed, defineAsyncComponent, provide, ref, toRef } from 'vue'
import IconGroup from '@/components/button/IconGroup.vue'
import LoadingOverlay from '@/components/common/LoadingOverlay.vue'
import Button from '@/components/ui/button/Button.vue'
import { isCloud } from '@/platform/distribution/types'
import { useAssetsStore } from '@/stores/assetsStore'
import {
formatDuration,
@@ -280,8 +279,7 @@ const formattedDuration = computed(() => {
// Get metadata info based on file kind
const metaInfo = computed(() => {
if (!asset) return ''
// TODO(assets): Re-enable once /assets API returns original image dimensions in metadata (#10590)
if (fileKind.value === 'image' && imageDimensions.value && !isCloud) {
if (fileKind.value === 'image' && imageDimensions.value) {
return `${imageDimensions.value.width}x${imageDimensions.value.height}`
}
if (asset.size && ['video', 'audio', '3D'].includes(fileKind.value)) {

View File

@@ -300,25 +300,6 @@ describe('TeamWorkspacesDialogContent', () => {
expect(mockCreateWorkspace).not.toHaveBeenCalled()
})
it('resets loading state after createWorkspace fails', async () => {
mockCreateWorkspace.mockRejectedValue(new Error('Limit reached'))
const wrapper = mountComponent()
await typeAndCreate(wrapper, 'New Team')
expect(findCreateButton(wrapper).props('loading')).toBe(false)
})
it('resets loading state after onConfirm fails', async () => {
mockCreateWorkspace.mockResolvedValue({ id: 'new-ws' })
const onConfirm = vi.fn().mockRejectedValue(new Error('Setup failed'))
const wrapper = mountComponent({ onConfirm })
await typeAndCreate(wrapper, 'New Team')
expect(findCreateButton(wrapper).props('loading')).toBe(false)
})
})
describe('close button', () => {

View File

@@ -201,30 +201,28 @@ async function handleSwitch(workspaceId: string) {
async function onCreate() {
if (!isValidName.value || loading.value) return
loading.value = true
const name = workspaceName.value.trim()
try {
const name = workspaceName.value.trim()
try {
await workspaceStore.createWorkspace(name)
} catch (error) {
toast.add({
severity: 'error',
summary: t('workspacePanel.toast.failedToCreateWorkspace'),
detail: error instanceof Error ? error.message : t('g.unknownError')
})
return
}
try {
await onConfirm?.(name)
} catch (error) {
toast.add({
severity: 'error',
summary: t('teamWorkspacesDialog.confirmCallbackFailed'),
detail: error instanceof Error ? error.message : t('g.unknownError')
})
}
dialogStore.closeDialog({ key: DIALOG_KEY })
} finally {
await workspaceStore.createWorkspace(name)
} catch (error) {
toast.add({
severity: 'error',
summary: t('workspacePanel.toast.failedToCreateWorkspace'),
detail: error instanceof Error ? error.message : t('g.unknownError')
})
loading.value = false
return
}
try {
await onConfirm?.(name)
} catch (error) {
toast.add({
severity: 'error',
summary: t('teamWorkspacesDialog.confirmCallbackFailed'),
detail: error instanceof Error ? error.message : t('g.unknownError')
})
}
dialogStore.closeDialog({ key: DIALOG_KEY })
loading.value = false
}
</script>

View File

@@ -1,6 +1,7 @@
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { app } from '@/scripts/app'
import { api } from '@/scripts/api'
import { MAX_PROGRESS_JOBS, useExecutionStore } from '@/stores/executionStore'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { useMissingNodesErrorStore } from '@/platform/nodeReplacement/missingNodesErrorStore'
@@ -16,6 +17,27 @@ import type { NodeProgressState } from '@/schemas/apiSchema'
import { createMockLGraphNode } from '@/utils/__tests__/litegraphTestUtils'
import { createTestingPinia } from '@pinia/testing'
vi.mock('@/scripts/api', () => ({
api: {
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
clientId: null,
apiURL: vi.fn((path: string) => path)
}
}))
vi.mock('@/stores/imagePreviewStore', () => ({
useNodeOutputStore: () => ({
revokePreviewsByExecutionId: vi.fn()
})
}))
vi.mock('@/stores/jobPreviewStore', () => ({
useJobPreviewStore: () => ({
clearPreview: vi.fn()
})
}))
// Mock the workflowStore
vi.mock('@/platform/workflow/management/stores/workflowStore', async () => {
const { ComfyWorkflow } = await vi.importActual<typeof WorkflowStoreModule>(
@@ -331,9 +353,12 @@ describe('useExecutionStore - nodeProgressStatesByJob eviction', () => {
handler(
new CustomEvent('progress_state', { detail: { nodes, prompt_id: jobId } })
)
// Flush the RAF so the batched update is applied immediately
vi.advanceTimersByTime(16)
}
beforeEach(() => {
vi.useFakeTimers()
vi.clearAllMocks()
apiEventHandlers.clear()
setActivePinia(createTestingPinia({ stubActions: false }))
@@ -341,6 +366,10 @@ describe('useExecutionStore - nodeProgressStatesByJob eviction', () => {
store.bindExecutionEvents()
})
afterEach(() => {
vi.useRealTimers()
})
it('should retain entries below the limit', () => {
for (let i = 0; i < 5; i++) {
fireProgressState(`job-${i}`, makeProgressNodes(`${i}`, `job-${i}`))
@@ -695,3 +724,309 @@ describe('useMissingNodesErrorStore - setMissingNodeTypes', () => {
expect(store.missingNodesError?.nodeTypes).toEqual(input)
})
})
describe('useExecutionStore - RAF batching', () => {
let store: ReturnType<typeof useExecutionStore>
function getRegisteredHandler(eventName: string) {
const calls = vi.mocked(api.addEventListener).mock.calls
const call = calls.find(([name]) => name === eventName)
return call?.[1] as (e: CustomEvent) => void
}
beforeEach(() => {
vi.useFakeTimers()
vi.clearAllMocks()
setActivePinia(createTestingPinia({ stubActions: false }))
store = useExecutionStore()
store.bindExecutionEvents()
})
afterEach(() => {
vi.useRealTimers()
})
describe('handleProgress', () => {
function makeProgressEvent(value: number, max: number): CustomEvent {
return new CustomEvent('progress', {
detail: { value, max, prompt_id: 'job-1', node: '1' }
})
}
it('batches multiple progress events into one reactive update per frame', () => {
const handler = getRegisteredHandler('progress')
handler(makeProgressEvent(1, 10))
handler(makeProgressEvent(5, 10))
handler(makeProgressEvent(9, 10))
expect(store._executingNodeProgress).toBeNull()
vi.advanceTimersByTime(16)
expect(store._executingNodeProgress).toEqual({
value: 9,
max: 10,
prompt_id: 'job-1',
node: '1'
})
})
it('does not update reactive state before RAF fires', () => {
const handler = getRegisteredHandler('progress')
handler(makeProgressEvent(3, 10))
expect(store._executingNodeProgress).toBeNull()
})
it('allows a new batch after the previous RAF fires', () => {
const handler = getRegisteredHandler('progress')
handler(makeProgressEvent(1, 10))
vi.advanceTimersByTime(16)
expect(store._executingNodeProgress).toEqual(
expect.objectContaining({ value: 1 })
)
handler(makeProgressEvent(7, 10))
vi.advanceTimersByTime(16)
expect(store._executingNodeProgress).toEqual(
expect.objectContaining({ value: 7 })
)
})
})
describe('handleProgressState', () => {
function makeProgressStateEvent(
nodeId: string,
state: string,
value = 0,
max = 10
): CustomEvent {
return new CustomEvent('progress_state', {
detail: {
prompt_id: 'job-1',
nodes: {
[nodeId]: {
value,
max,
state,
node_id: nodeId,
prompt_id: 'job-1',
display_node_id: nodeId
}
}
}
})
}
it('batches multiple progress_state events into one reactive update per frame', () => {
const handler = getRegisteredHandler('progress_state')
handler(makeProgressStateEvent('1', 'running', 1))
handler(makeProgressStateEvent('1', 'running', 5))
handler(makeProgressStateEvent('1', 'running', 9))
expect(Object.keys(store.nodeProgressStates)).toHaveLength(0)
vi.advanceTimersByTime(16)
expect(store.nodeProgressStates['1']).toEqual(
expect.objectContaining({ value: 9, state: 'running' })
)
})
it('does not update reactive state before RAF fires', () => {
const handler = getRegisteredHandler('progress_state')
handler(makeProgressStateEvent('1', 'running'))
expect(Object.keys(store.nodeProgressStates)).toHaveLength(0)
})
})
describe('pending RAF is discarded when execution completes', () => {
it('discards pending progress RAF on execution_success', () => {
const progressHandler = getRegisteredHandler('progress')
const startHandler = getRegisteredHandler('execution_start')
const successHandler = getRegisteredHandler('execution_success')
startHandler(
new CustomEvent('execution_start', {
detail: { prompt_id: 'job-1', timestamp: 0 }
})
)
progressHandler(
new CustomEvent('progress', {
detail: { value: 5, max: 10, prompt_id: 'job-1', node: '1' }
})
)
successHandler(
new CustomEvent('execution_success', {
detail: { prompt_id: 'job-1', timestamp: 0 }
})
)
vi.advanceTimersByTime(16)
expect(store._executingNodeProgress).toBeNull()
})
it('discards pending progress_state RAF on execution_success', () => {
const progressStateHandler = getRegisteredHandler('progress_state')
const startHandler = getRegisteredHandler('execution_start')
const successHandler = getRegisteredHandler('execution_success')
startHandler(
new CustomEvent('execution_start', {
detail: { prompt_id: 'job-1', timestamp: 0 }
})
)
progressStateHandler(
new CustomEvent('progress_state', {
detail: {
prompt_id: 'job-1',
nodes: {
'1': {
value: 5,
max: 10,
state: 'running',
node_id: '1',
prompt_id: 'job-1',
display_node_id: '1'
}
}
}
})
)
successHandler(
new CustomEvent('execution_success', {
detail: { prompt_id: 'job-1', timestamp: 0 }
})
)
vi.advanceTimersByTime(16)
expect(Object.keys(store.nodeProgressStates)).toHaveLength(0)
})
it('discards pending progress RAF on execution_error', () => {
const progressHandler = getRegisteredHandler('progress')
const startHandler = getRegisteredHandler('execution_start')
const errorHandler = getRegisteredHandler('execution_error')
startHandler(
new CustomEvent('execution_start', {
detail: { prompt_id: 'job-1', timestamp: 0 }
})
)
progressHandler(
new CustomEvent('progress', {
detail: { value: 5, max: 10, prompt_id: 'job-1', node: '1' }
})
)
errorHandler(
new CustomEvent('execution_error', {
detail: {
prompt_id: 'job-1',
node_id: '1',
node_type: 'TestNode',
exception_message: 'error',
exception_type: 'RuntimeError',
traceback: []
}
})
)
vi.advanceTimersByTime(16)
expect(store._executingNodeProgress).toBeNull()
})
it('discards pending progress RAF on execution_interrupted', () => {
const progressHandler = getRegisteredHandler('progress')
const startHandler = getRegisteredHandler('execution_start')
const interruptedHandler = getRegisteredHandler('execution_interrupted')
startHandler(
new CustomEvent('execution_start', {
detail: { prompt_id: 'job-1', timestamp: 0 }
})
)
progressHandler(
new CustomEvent('progress', {
detail: { value: 5, max: 10, prompt_id: 'job-1', node: '1' }
})
)
interruptedHandler(
new CustomEvent('execution_interrupted', {
detail: {
prompt_id: 'job-1',
node_id: '1',
node_type: 'TestNode',
executed: []
}
})
)
vi.advanceTimersByTime(16)
expect(store._executingNodeProgress).toBeNull()
})
})
describe('unbindExecutionEvents cancels pending RAFs', () => {
it('cancels pending progress RAF on unbind', () => {
const handler = getRegisteredHandler('progress')
handler(
new CustomEvent('progress', {
detail: { value: 5, max: 10, prompt_id: 'job-1', node: '1' }
})
)
store.unbindExecutionEvents()
vi.advanceTimersByTime(16)
expect(store._executingNodeProgress).toBeNull()
})
it('cancels pending progress_state RAF on unbind', () => {
const handler = getRegisteredHandler('progress_state')
handler(
new CustomEvent('progress_state', {
detail: {
prompt_id: 'job-1',
nodes: {
'1': {
value: 0,
max: 10,
state: 'running',
node_id: '1',
prompt_id: 'job-1',
display_node_id: '1'
}
}
}
})
)
store.unbindExecutionEvents()
vi.advanceTimersByTime(16)
expect(Object.keys(store.nodeProgressStates)).toHaveLength(0)
})
})
})

View File

@@ -33,6 +33,7 @@ import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import type { NodeLocatorId } from '@/types/nodeIdentification'
import { classifyCloudValidationError } from '@/utils/executionErrorUtil'
import { executionIdToNodeLocatorId } from '@/utils/graphTraversalUtil'
import { createRafCoalescer } from '@/utils/rafBatch'
interface QueuedJob {
/**
@@ -242,6 +243,8 @@ export const useExecutionStore = defineStore('execution', () => {
api.removeEventListener('status', handleStatus)
api.removeEventListener('execution_error', handleExecutionError)
api.removeEventListener('progress_text', handleProgressText)
cancelPendingProgressUpdates()
}
function handleExecutionStart(e: CustomEvent<ExecutionStartWsMessage>) {
@@ -290,6 +293,10 @@ export const useExecutionStore = defineStore('execution', () => {
}
function handleExecuting(e: CustomEvent<NodeId | null>): void {
// Cancel any pending progress RAF before clearing state to prevent
// stale data from being written back on the next frame.
progressCoalescer.cancel()
// Clear the current node progress when a new node starts executing
_executingNodeProgress.value = null
@@ -332,8 +339,15 @@ export const useExecutionStore = defineStore('execution', () => {
nodeProgressStatesByJob.value = pruned
}
const progressStateCoalescer =
createRafCoalescer<ProgressStateWsMessage>(_applyProgressState)
function handleProgressState(e: CustomEvent<ProgressStateWsMessage>) {
const { nodes, prompt_id: jobId } = e.detail
progressStateCoalescer.push(e.detail)
}
function _applyProgressState(detail: ProgressStateWsMessage) {
const { nodes, prompt_id: jobId } = detail
// Revoke previews for nodes that are starting to execute
const previousForJob = nodeProgressStatesByJob.value[jobId] || {}
@@ -369,8 +383,17 @@ export const useExecutionStore = defineStore('execution', () => {
}
}
const progressCoalescer = createRafCoalescer<ProgressWsMessage>((detail) => {
_executingNodeProgress.value = detail
})
function handleProgress(e: CustomEvent<ProgressWsMessage>) {
_executingNodeProgress.value = e.detail
progressCoalescer.push(e.detail)
}
function cancelPendingProgressUpdates() {
progressCoalescer.cancel()
progressStateCoalescer.cancel()
}
function handleStatus() {
@@ -492,6 +515,8 @@ export const useExecutionStore = defineStore('execution', () => {
* Reset execution-related state after a run completes or is stopped.
*/
function resetExecutionState(jobIdParam?: string | null) {
cancelPendingProgressUpdates()
executionIdToLocatorCache.clear()
nodeProgressStates.value = {}
const jobId = jobIdParam ?? activeJobId.value ?? null

View File

@@ -0,0 +1,85 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createRafCoalescer } from '@/utils/rafBatch'
describe('createRafCoalescer', () => {
beforeEach(() => {
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
})
it('applies the latest pushed value on the next frame', () => {
const apply = vi.fn()
const coalescer = createRafCoalescer<number>(apply)
coalescer.push(1)
coalescer.push(2)
coalescer.push(3)
expect(apply).not.toHaveBeenCalled()
vi.advanceTimersByTime(16)
expect(apply).toHaveBeenCalledOnce()
expect(apply).toHaveBeenCalledWith(3)
})
it('does not apply after cancel', () => {
const apply = vi.fn()
const coalescer = createRafCoalescer<number>(apply)
coalescer.push(42)
coalescer.cancel()
vi.advanceTimersByTime(16)
expect(apply).not.toHaveBeenCalled()
})
it('applies immediately on flush', () => {
const apply = vi.fn()
const coalescer = createRafCoalescer<number>(apply)
coalescer.push(99)
coalescer.flush()
expect(apply).toHaveBeenCalledOnce()
expect(apply).toHaveBeenCalledWith(99)
})
it('does nothing on flush when no value is pending', () => {
const apply = vi.fn()
const coalescer = createRafCoalescer<number>(apply)
coalescer.flush()
expect(apply).not.toHaveBeenCalled()
})
it('does not double-apply after flush', () => {
const apply = vi.fn()
const coalescer = createRafCoalescer<number>(apply)
coalescer.push(1)
coalescer.flush()
vi.advanceTimersByTime(16)
expect(apply).toHaveBeenCalledOnce()
})
it('reports scheduled state correctly', () => {
const coalescer = createRafCoalescer<number>(vi.fn())
expect(coalescer.isScheduled()).toBe(false)
coalescer.push(1)
expect(coalescer.isScheduled()).toBe(true)
vi.advanceTimersByTime(16)
expect(coalescer.isScheduled()).toBe(false)
})
})

View File

@@ -27,3 +27,40 @@ export function createRafBatch(run: () => void) {
return { schedule, cancel, flush, isScheduled }
}
/**
* Last-write-wins RAF coalescer. Buffers the latest value and applies it
* on the next animation frame, coalescing multiple pushes into a single
* reactive update.
*/
export function createRafCoalescer<T>(apply: (value: T) => void) {
let hasPending = false
let pendingValue: T | undefined
const batch = createRafBatch(() => {
if (!hasPending) return
const value = pendingValue as T
hasPending = false
pendingValue = undefined
apply(value)
})
const push = (value: T) => {
pendingValue = value
hasPending = true
batch.schedule()
}
const cancel = () => {
hasPending = false
pendingValue = undefined
batch.cancel()
}
const flush = () => {
if (!hasPending) return
batch.flush()
}
return { push, cancel, flush, isScheduled: batch.isScheduled }
}