From 8b53d5c807fc61c99b3fd155303f017e5060aed5 Mon Sep 17 00:00:00 2001 From: Christian Byrne Date: Thu, 12 Mar 2026 08:50:14 -0700 Subject: [PATCH] fix: preserve input asset previews across execution updates (#9123) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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. --- src/stores/nodeOutputStore.test.ts | 104 +++++++++++++++++++++++++++++ src/stores/nodeOutputStore.ts | 36 ++++++++++ 2 files changed, 140 insertions(+) diff --git a/src/stores/nodeOutputStore.test.ts b/src/stores/nodeOutputStore.test.ts index a8d1785480..0af664f986 100644 --- a/src/stores/nodeOutputStore.test.ts +++ b/src/stores/nodeOutputStore.test.ts @@ -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 })) diff --git a/src/stores/nodeOutputStore.ts b/src/stores/nodeOutputStore.ts index 5d2d7ccaa4..a087421c2c 100644 --- a/src/stores/nodeOutputStore.ts +++ b/src/stores/nodeOutputStore.ts @@ -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) {