mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-03-13 09:00:16 +00:00
fix: preserve input asset previews across execution updates (#9123)
## Summary Fix input asset previews (images/videos) disappearing from LoadImage/LoadVideo nodes after execution and a browser tab switch. ## Changes - **What**: Guard `setOutputsByLocatorId` in `imagePreviewStore` to preserve existing input-type preview images (`type: 'input'`) when the incoming execution output has no images. Execution outputs with actual images still overwrite as expected. ## Review Focus - The guard only applies when existing output is an input preview (`type === 'input'` for all images) AND incoming output has no images — this is the exact scenario where execution clobbers upload widget previews. - Root cause: execution results from the backend overwrite the upload widget's synthetic preview for LoadImage/LoadVideo nodes (which produce no output images). Combined with the deferred resize-observer re-observation from PR #8805, returning to a hidden tab reads the now-empty store entry.
This commit is contained in:
@@ -151,6 +151,110 @@ describe('nodeOutputStore restoreOutputs', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('nodeOutputStore input preview preservation', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
vi.clearAllMocks()
|
||||
app.nodeOutputs = {}
|
||||
app.nodePreviewImages = {}
|
||||
})
|
||||
|
||||
it('should preserve input preview when execution sends empty output', () => {
|
||||
const store = useNodeOutputStore()
|
||||
const executionId = '3'
|
||||
|
||||
const inputPreview = createMockOutputs([
|
||||
{ filename: 'example.png', subfolder: '', type: 'input' }
|
||||
])
|
||||
store.setNodeOutputsByExecutionId(executionId, inputPreview)
|
||||
|
||||
expect(store.nodeOutputs[executionId]?.images).toHaveLength(1)
|
||||
|
||||
const emptyExecutionOutput = createMockOutputs()
|
||||
store.setNodeOutputsByExecutionId(executionId, emptyExecutionOutput)
|
||||
|
||||
expect(store.nodeOutputs[executionId]?.images).toHaveLength(1)
|
||||
expect(store.nodeOutputs[executionId]?.images?.[0].filename).toBe(
|
||||
'example.png'
|
||||
)
|
||||
})
|
||||
|
||||
it('should preserve input preview when execution sends output with empty images array', () => {
|
||||
const store = useNodeOutputStore()
|
||||
const executionId = '3'
|
||||
|
||||
const inputPreview = createMockOutputs([
|
||||
{ filename: 'example.png', subfolder: '', type: 'input' }
|
||||
])
|
||||
store.setNodeOutputsByExecutionId(executionId, inputPreview)
|
||||
|
||||
const emptyImagesOutput = createMockOutputs([])
|
||||
store.setNodeOutputsByExecutionId(executionId, emptyImagesOutput)
|
||||
|
||||
expect(store.nodeOutputs[executionId]?.images).toHaveLength(1)
|
||||
expect(store.nodeOutputs[executionId]?.images?.[0].type).toBe('input')
|
||||
})
|
||||
|
||||
it('should allow execution output with images to overwrite input preview', () => {
|
||||
const store = useNodeOutputStore()
|
||||
const executionId = '3'
|
||||
|
||||
const inputPreview = createMockOutputs([
|
||||
{ filename: 'example.png', subfolder: '', type: 'input' }
|
||||
])
|
||||
store.setNodeOutputsByExecutionId(executionId, inputPreview)
|
||||
|
||||
const executionOutput = createMockOutputs([
|
||||
{ filename: 'output.png', subfolder: '', type: 'output' }
|
||||
])
|
||||
store.setNodeOutputsByExecutionId(executionId, executionOutput)
|
||||
|
||||
expect(store.nodeOutputs[executionId]?.images).toHaveLength(1)
|
||||
expect(store.nodeOutputs[executionId]?.images?.[0].filename).toBe(
|
||||
'output.png'
|
||||
)
|
||||
})
|
||||
|
||||
it('should not preserve non-input outputs from being overwritten', () => {
|
||||
const store = useNodeOutputStore()
|
||||
const executionId = '4'
|
||||
|
||||
const tempOutput = createMockOutputs([
|
||||
{ filename: 'temp.png', subfolder: '', type: 'temp' }
|
||||
])
|
||||
store.setNodeOutputsByExecutionId(executionId, tempOutput)
|
||||
|
||||
const emptyOutput = createMockOutputs()
|
||||
store.setNodeOutputsByExecutionId(executionId, emptyOutput)
|
||||
|
||||
expect(store.nodeOutputs[executionId]?.images).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should pass through non-image fields while preserving input preview images', () => {
|
||||
const store = useNodeOutputStore()
|
||||
const executionId = '5'
|
||||
|
||||
const inputPreview = createMockOutputs([
|
||||
{ filename: 'example.png', subfolder: '', type: 'input' }
|
||||
])
|
||||
store.setNodeOutputsByExecutionId(executionId, inputPreview)
|
||||
|
||||
const videoOutput: ExecutedWsMessage['output'] = {
|
||||
video: [{ filename: 'output.mp4', subfolder: '', type: 'output' }]
|
||||
}
|
||||
store.setNodeOutputsByExecutionId(executionId, videoOutput)
|
||||
|
||||
expect(store.nodeOutputs[executionId]?.images).toHaveLength(1)
|
||||
expect(store.nodeOutputs[executionId]?.images?.[0].filename).toBe(
|
||||
'example.png'
|
||||
)
|
||||
expect(store.nodeOutputs[executionId]?.video).toHaveLength(1)
|
||||
expect(store.nodeOutputs[executionId]?.video?.[0].filename).toBe(
|
||||
'output.mp4'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('nodeOutputStore getPreviewParam', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
|
||||
@@ -126,6 +126,22 @@ export const useNodeOutputStore = defineStore('nodeOutput', () => {
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an output contains input-type preview images (from upload widgets).
|
||||
* These are synthetic previews set by LoadImage/LoadVideo widgets, not
|
||||
* execution results from the backend.
|
||||
*/
|
||||
function isInputPreviewOutput(
|
||||
output: ExecutedWsMessage['output'] | ResultItem | undefined
|
||||
): boolean {
|
||||
const images = (output as ExecutedWsMessage['output'] | undefined)?.images
|
||||
return (
|
||||
Array.isArray(images) &&
|
||||
images.length > 0 &&
|
||||
images.every((i) => i?.type === 'input')
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal function to set outputs by NodeLocatorId.
|
||||
* Handles the merge logic when needed.
|
||||
@@ -140,6 +156,26 @@ export const useNodeOutputStore = defineStore('nodeOutput', () => {
|
||||
// (e.g., two LoadImage nodes selecting the same image)
|
||||
if (outputs == null) return
|
||||
|
||||
// Preserve input preview images (from upload widgets) when execution
|
||||
// sends outputs with no images. Without this guard, execution results
|
||||
// overwrite the upload widget's preview, causing LoadImage/LoadVideo
|
||||
// nodes to lose their preview after execution + tab switch.
|
||||
// Note: intentional preview clears go through setNodeOutputs (widget
|
||||
// path), not setNodeOutputsByExecutionId, so this guard does not
|
||||
// interfere with user-initiated clears.
|
||||
const incomingImages = (outputs as ExecutedWsMessage['output']).images
|
||||
const hasIncomingImages =
|
||||
Array.isArray(incomingImages) && incomingImages.length > 0
|
||||
if (
|
||||
!hasIncomingImages &&
|
||||
isInputPreviewOutput(app.nodeOutputs[nodeLocatorId])
|
||||
) {
|
||||
outputs = {
|
||||
...outputs,
|
||||
images: app.nodeOutputs[nodeLocatorId].images
|
||||
}
|
||||
}
|
||||
|
||||
if (options.merge) {
|
||||
const existingOutput = app.nodeOutputs[nodeLocatorId]
|
||||
if (existingOutput && outputs) {
|
||||
|
||||
Reference in New Issue
Block a user