fix: 3D asset disappears when switching to image output in app mode (#9622)

## Summary

Fix 3D asset disappearing when switching between 3D and image outputs in
app mode — missing `onUnmounted` cleanup leaked WebGL contexts.

## Changes

- **What**: Add `onUnmounted` hook to `Preview3d.vue` that calls
`viewer.cleanup()`, releasing the WebGL context when Vue destroys the
component via its v-if chain. Add unit tests covering init, cleanup on
unmount, and remount behavior.

## Review Focus

When switching outputs in app mode, Vue's v-if chain destroys and
recreates `Preview3d`. Without `onUnmounted` cleanup, the old `Load3d`
instance (WebGL context, RAF loop, ResizeObserver) leaks. After ~8-16
toggles, the browser's WebGL context limit is exhausted and new 3D
viewers silently fail to render.

<!-- Pipeline-Ticket: e36489d2-a9fb-47ca-9e27-88eb3170836b -->

---------

Co-authored-by: Alexander Brown <drjkl@comfy.org>
This commit is contained in:
Christian Byrne
2026-03-17 18:08:04 -07:00
committed by GitHub
parent c2cf8e4fb1
commit 0e5bd539ec
12 changed files with 521 additions and 111 deletions

View File

@@ -82,4 +82,71 @@ describe(flattenNodeOutput, () => {
const result = flattenNodeOutput(['1', output])
expect(result).toEqual([])
})
it('flattens non-standard output keys with ResultItem-like values', () => {
const output = makeOutput({
a_images: [{ filename: 'before.png', subfolder: '', type: 'output' }],
b_images: [{ filename: 'after.png', subfolder: '', type: 'output' }]
} as unknown as Partial<NodeExecutionOutput>)
const result = flattenNodeOutput(['10', output])
expect(result).toHaveLength(2)
expect(result.map((r) => r.filename)).toContain('before.png')
expect(result.map((r) => r.filename)).toContain('after.png')
})
it('excludes animated key', () => {
const output = makeOutput({
images: [{ filename: 'img.png', subfolder: '', type: 'output' }],
animated: [true]
})
const result = flattenNodeOutput(['1', output])
expect(result).toHaveLength(1)
expect(result[0].mediaType).toBe('images')
})
it('excludes non-ResultItem array items', () => {
const output = {
images: [{ filename: 'img.png', subfolder: '', type: 'output' }],
custom_data: [{ randomKey: 123 }]
} as unknown as NodeExecutionOutput
const result = flattenNodeOutput(['1', output])
expect(result).toHaveLength(1)
expect(result[0].mediaType).toBe('images')
})
it('accepts items with filename but no subfolder', () => {
const output = {
images: [
{ filename: 'valid.png', subfolder: '', type: 'output' },
{ filename: 'no-subfolder.png' }
]
} as unknown as NodeExecutionOutput
const result = flattenNodeOutput(['1', output])
expect(result).toHaveLength(2)
expect(result[0].filename).toBe('valid.png')
expect(result[1].filename).toBe('no-subfolder.png')
expect(result[1].subfolder).toBe('')
})
it('excludes items missing filename', () => {
const output = {
images: [
{ filename: 'valid.png', subfolder: '', type: 'output' },
{ subfolder: '', type: 'output' }
]
} as unknown as NodeExecutionOutput
const result = flattenNodeOutput(['1', output])
expect(result).toHaveLength(1)
expect(result[0].filename).toBe('valid.png')
})
})