mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-19 13:59:28 +00:00
Compare commits
6 Commits
perf/batch
...
glary/fix-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a8aa1ec5b0 | ||
|
|
17314a6892 | ||
|
|
f98af98733 | ||
|
|
e22cc1cb95 | ||
|
|
56536ce52b | ||
|
|
b2becca33d |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -99,4 +99,4 @@ vitest.config.*.timestamp*
|
||||
# Weekly docs check output
|
||||
/output.txt
|
||||
|
||||
.amp
|
||||
.amp.glary/
|
||||
|
||||
@@ -20,7 +20,7 @@ export class ConfirmDialog {
|
||||
this.overwrite = this.root.getByRole('button', { name: 'Overwrite' })
|
||||
this.reject = this.root.getByRole('button', { name: 'Cancel' })
|
||||
this.confirm = this.root.getByRole('button', { name: 'Confirm' })
|
||||
this.save = this.root.getByRole('button', { name: 'Save' })
|
||||
this.save = this.root.getByRole('button', { name: 'Save', exact: true })
|
||||
}
|
||||
|
||||
async click(locator: KeysOfType<ConfirmDialog, Locator>) {
|
||||
|
||||
@@ -450,7 +450,10 @@ test.describe('Workflow Persistence', () => {
|
||||
})
|
||||
|
||||
// Click "Save" in the dirty close dialog
|
||||
const saveButton = comfyPage.page.getByRole('button', { name: 'Save' })
|
||||
const saveButton = comfyPage.page.getByRole('button', {
|
||||
name: 'Save',
|
||||
exact: true
|
||||
})
|
||||
await saveButton.waitFor({ state: 'visible' })
|
||||
await saveButton.click()
|
||||
await comfyPage.workflow.waitForWorkflowIdle()
|
||||
|
||||
@@ -86,7 +86,7 @@
|
||||
<template v-else-if="type === 'dirtyClose'">
|
||||
<Button variant="secondary" @click="onDeny">
|
||||
<i class="pi pi-times" />
|
||||
{{ $t('g.no') }}
|
||||
{{ $t('g.dontSave') }}
|
||||
</Button>
|
||||
<Button @click="onConfirm">
|
||||
<i class="pi pi-save" />
|
||||
|
||||
163
src/composables/auth/useAuthActions.test.ts
Normal file
163
src/composables/auth/useAuthActions.test.ts
Normal file
@@ -0,0 +1,163 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { useAuthActions } from '@/composables/auth/useAuthActions'
|
||||
|
||||
const {
|
||||
mockConfirm,
|
||||
mockAuthLogout,
|
||||
mockSaveWorkflow,
|
||||
mockModifiedWorkflows,
|
||||
mockToastAdd
|
||||
} = vi.hoisted(() => ({
|
||||
mockConfirm: vi.fn(),
|
||||
mockAuthLogout: vi.fn(),
|
||||
mockSaveWorkflow: vi.fn(),
|
||||
mockModifiedWorkflows: { value: [] as Array<{ path: string }> },
|
||||
mockToastAdd: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/services/dialogService', () => ({
|
||||
useDialogService: () => ({
|
||||
confirm: mockConfirm,
|
||||
showSignInDialog: vi.fn()
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/authStore', () => ({
|
||||
useAuthStore: () => ({
|
||||
logout: mockAuthLogout
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
|
||||
useWorkflowStore: () => ({
|
||||
get modifiedWorkflows() {
|
||||
return mockModifiedWorkflows.value
|
||||
}
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/workflow/core/services/workflowService', () => ({
|
||||
useWorkflowService: () => ({
|
||||
saveWorkflow: mockSaveWorkflow
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/updates/common/toastStore', () => ({
|
||||
useToastStore: () => ({
|
||||
add: mockToastAdd
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/distribution/types', () => ({
|
||||
isCloud: false
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/telemetry', () => ({
|
||||
useTelemetry: () => null
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/billing/useBillingContext', () => ({
|
||||
useBillingContext: () => ({
|
||||
isActiveSubscription: { value: false }
|
||||
})
|
||||
}))
|
||||
|
||||
describe('useAuthActions', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
setActivePinia(createTestingPinia())
|
||||
mockModifiedWorkflows.value = []
|
||||
mockAuthLogout.mockResolvedValue(undefined)
|
||||
mockSaveWorkflow.mockResolvedValue(undefined)
|
||||
})
|
||||
|
||||
describe('logout', () => {
|
||||
it('should log out directly when no modified workflows exist', async () => {
|
||||
const { logout } = useAuthActions()
|
||||
|
||||
await logout()
|
||||
|
||||
expect(mockConfirm).not.toHaveBeenCalled()
|
||||
expect(mockAuthLogout).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('should show unsaved changes dialog when modified workflows exist', async () => {
|
||||
mockModifiedWorkflows.value = [{ path: 'workflow1.json' }]
|
||||
mockConfirm.mockResolvedValue(false)
|
||||
|
||||
const { logout } = useAuthActions()
|
||||
await logout()
|
||||
|
||||
expect(mockConfirm).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ type: 'dirtyClose' })
|
||||
)
|
||||
})
|
||||
|
||||
it('should cancel logout when user closes the dialog', async () => {
|
||||
mockModifiedWorkflows.value = [{ path: 'workflow1.json' }]
|
||||
mockConfirm.mockResolvedValue(null)
|
||||
|
||||
const { logout } = useAuthActions()
|
||||
await logout()
|
||||
|
||||
expect(mockAuthLogout).not.toHaveBeenCalled()
|
||||
expect(mockSaveWorkflow).not.toHaveBeenCalled()
|
||||
expect(mockToastAdd).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should save all modified workflows then log out when user clicks Save', async () => {
|
||||
const workflows = [{ path: 'workflow1.json' }, { path: 'workflow2.json' }]
|
||||
mockModifiedWorkflows.value = workflows
|
||||
mockConfirm.mockResolvedValue(true)
|
||||
|
||||
const { logout } = useAuthActions()
|
||||
await logout()
|
||||
|
||||
expect(mockSaveWorkflow).toHaveBeenCalledTimes(2)
|
||||
expect(mockSaveWorkflow).toHaveBeenCalledWith(workflows[0])
|
||||
expect(mockSaveWorkflow).toHaveBeenCalledWith(workflows[1])
|
||||
expect(mockAuthLogout).toHaveBeenCalledOnce()
|
||||
|
||||
const lastSaveOrder = Math.max(
|
||||
...mockSaveWorkflow.mock.invocationCallOrder
|
||||
)
|
||||
const logoutOrder = mockAuthLogout.mock.invocationCallOrder[0]
|
||||
expect(lastSaveOrder).toBeLessThan(logoutOrder)
|
||||
})
|
||||
|
||||
it('should not log out when saving a workflow fails', async () => {
|
||||
mockModifiedWorkflows.value = [{ path: 'workflow1.json' }]
|
||||
mockConfirm.mockResolvedValue(true)
|
||||
mockSaveWorkflow.mockRejectedValueOnce(new Error('Save failed'))
|
||||
|
||||
const { logout } = useAuthActions()
|
||||
await logout()
|
||||
|
||||
expect(mockSaveWorkflow).toHaveBeenCalledOnce()
|
||||
expect(mockAuthLogout).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("should log out without saving when user clicks Don't Save", async () => {
|
||||
mockModifiedWorkflows.value = [{ path: 'workflow1.json' }]
|
||||
mockConfirm.mockResolvedValue(false)
|
||||
|
||||
const { logout } = useAuthActions()
|
||||
await logout()
|
||||
|
||||
expect(mockSaveWorkflow).not.toHaveBeenCalled()
|
||||
expect(mockAuthLogout).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('should show success toast after logout', async () => {
|
||||
const { logout } = useAuthActions()
|
||||
await logout()
|
||||
|
||||
expect(mockToastAdd).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ severity: 'success' })
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -9,6 +9,7 @@ import { t } from '@/i18n'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { useToastStore } from '@/platform/updates/common/toastStore'
|
||||
import { useWorkflowService } from '@/platform/workflow/core/services/workflowService'
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import { useAuthStore } from '@/stores/authStore'
|
||||
@@ -60,7 +61,16 @@ export const useAuthActions = () => {
|
||||
message: t('auth.signOut.unsavedChangesMessage'),
|
||||
type: 'dirtyClose'
|
||||
})
|
||||
if (!confirmed) return
|
||||
|
||||
if (confirmed === null) return
|
||||
|
||||
if (confirmed === true) {
|
||||
const workflowService = useWorkflowService()
|
||||
const toSave = [...workflowStore.modifiedWorkflows]
|
||||
for (const workflow of toSave) {
|
||||
await workflowService.saveWorkflow(workflow)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await authStore.logout()
|
||||
|
||||
@@ -129,6 +129,7 @@
|
||||
"saveAnyway": "Save Anyway",
|
||||
"saving": "Saving",
|
||||
"no": "No",
|
||||
"dontSave": "Don't Save",
|
||||
"cancel": "Cancel",
|
||||
"close": "Close",
|
||||
"closeDialog": "Close dialog",
|
||||
@@ -2173,7 +2174,7 @@
|
||||
"success": "Signed out successfully",
|
||||
"successDetail": "You have been signed out of your account.",
|
||||
"unsavedChangesTitle": "Unsaved Changes",
|
||||
"unsavedChangesMessage": "You have unsaved changes that will be lost when you sign out. Do you want to continue?"
|
||||
"unsavedChangesMessage": "You have unsaved changes that will be lost when you sign out. Would you like to save before signing out?"
|
||||
},
|
||||
"passwordUpdate": {
|
||||
"success": "Password Updated",
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { 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'
|
||||
@@ -332,12 +331,9 @@ 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 }))
|
||||
@@ -345,10 +341,6 @@ 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}`))
|
||||
@@ -703,309 +695,3 @@ 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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -33,7 +33,6 @@ 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 {
|
||||
/**
|
||||
@@ -243,8 +242,6 @@ 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>) {
|
||||
@@ -293,10 +290,6 @@ 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
|
||||
|
||||
@@ -339,15 +332,8 @@ export const useExecutionStore = defineStore('execution', () => {
|
||||
nodeProgressStatesByJob.value = pruned
|
||||
}
|
||||
|
||||
const progressStateCoalescer =
|
||||
createRafCoalescer<ProgressStateWsMessage>(_applyProgressState)
|
||||
|
||||
function handleProgressState(e: CustomEvent<ProgressStateWsMessage>) {
|
||||
progressStateCoalescer.push(e.detail)
|
||||
}
|
||||
|
||||
function _applyProgressState(detail: ProgressStateWsMessage) {
|
||||
const { nodes, prompt_id: jobId } = detail
|
||||
const { nodes, prompt_id: jobId } = e.detail
|
||||
|
||||
// Revoke previews for nodes that are starting to execute
|
||||
const previousForJob = nodeProgressStatesByJob.value[jobId] || {}
|
||||
@@ -383,17 +369,8 @@ export const useExecutionStore = defineStore('execution', () => {
|
||||
}
|
||||
}
|
||||
|
||||
const progressCoalescer = createRafCoalescer<ProgressWsMessage>((detail) => {
|
||||
_executingNodeProgress.value = detail
|
||||
})
|
||||
|
||||
function handleProgress(e: CustomEvent<ProgressWsMessage>) {
|
||||
progressCoalescer.push(e.detail)
|
||||
}
|
||||
|
||||
function cancelPendingProgressUpdates() {
|
||||
progressCoalescer.cancel()
|
||||
progressStateCoalescer.cancel()
|
||||
_executingNodeProgress.value = e.detail
|
||||
}
|
||||
|
||||
function handleStatus() {
|
||||
@@ -515,8 +492,6 @@ 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
|
||||
|
||||
@@ -1,85 +0,0 @@
|
||||
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)
|
||||
})
|
||||
})
|
||||
@@ -27,40 +27,3 @@ 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 }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user