fix: 3D asset disappears when switching to image output in app mode

- Add cleanup on unmount to prevent WebGL context leaks
- Add cleanup before re-init to prevent stacked Load3d instances
- Use flush:'post' watch to ensure DOM is ready before init
- Add :key on Preview3d for fresh instance on URL change
This commit is contained in:
bymyself
2026-03-07 22:10:59 -08:00
parent b21512303e
commit b077a658f8
3 changed files with 133 additions and 3 deletions

View File

@@ -49,6 +49,7 @@ const attrs = useAttrs()
/>
<Preview3d
v-else-if="getMediaType(output) === '3d'"
:key="output.url"
:class="attrs.class as string"
:model-url="output.url"
/>

View File

@@ -0,0 +1,120 @@
import { mount } from '@vue/test-utils'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
const initializeStandaloneViewer = vi.fn()
const cleanup = vi.fn()
vi.mock('@/composables/useLoad3dViewer', () => ({
useLoad3dViewer: () => ({
initializeStandaloneViewer,
cleanup,
handleMouseEnter: vi.fn(),
handleMouseLeave: vi.fn(),
handleResize: vi.fn(),
handleBackgroundImageUpdate: vi.fn(),
exportModel: vi.fn(),
handleSeek: vi.fn(),
isSplatModel: false,
isPlyModel: false,
hasSkeleton: false,
animations: [],
playing: false,
selectedSpeed: 1,
selectedAnimation: 0,
animationProgress: 0,
animationDuration: 0
})
}))
vi.mock('@/components/load3d/Load3DControls.vue', () => ({
default: { template: '<div />' }
}))
vi.mock('@/components/load3d/controls/AnimationControls.vue', () => ({
default: { template: '<div />' }
}))
describe('Preview3d', () => {
beforeEach(() => {
vi.clearAllMocks()
})
afterEach(() => {
vi.restoreAllMocks()
})
async function mountPreview3d(
modelUrl = 'http://localhost/view?filename=model.glb'
) {
const wrapper = mount(
(await import('@/renderer/extensions/linearMode/Preview3d.vue')).default,
{ props: { modelUrl } }
)
await nextTick()
await nextTick()
return wrapper
}
it('initializes the viewer on mount', async () => {
await mountPreview3d()
expect(initializeStandaloneViewer).toHaveBeenCalledOnce()
expect(initializeStandaloneViewer).toHaveBeenCalledWith(
expect.any(HTMLElement),
'http://localhost/view?filename=model.glb'
)
})
it('cleans up the viewer on unmount', async () => {
const wrapper = await mountPreview3d()
cleanup.mockClear()
wrapper.unmount()
expect(cleanup).toHaveBeenCalledOnce()
})
it('cleans up before reinitializing when modelUrl changes', async () => {
const wrapper = await mountPreview3d('http://localhost/view?filename=a.glb')
vi.clearAllMocks()
await wrapper.setProps({
modelUrl: 'http://localhost/view?filename=b.glb'
})
await nextTick()
await nextTick()
const cleanupOrder = cleanup.mock.invocationCallOrder[0]
const initOrder = initializeStandaloneViewer.mock.invocationCallOrder[0]
expect(cleanupOrder).toBeLessThan(initOrder)
expect(initializeStandaloneViewer).toHaveBeenCalledWith(
expect.any(HTMLElement),
'http://localhost/view?filename=b.glb'
)
})
it('reinitializes correctly after unmount and remount', async () => {
const url = 'http://localhost/view?filename=model.glb'
const wrapper1 = await mountPreview3d(url)
expect(initializeStandaloneViewer).toHaveBeenCalledTimes(1)
cleanup.mockClear()
wrapper1.unmount()
expect(cleanup).toHaveBeenCalledOnce()
vi.clearAllMocks()
const wrapper2 = await mountPreview3d(url)
expect(initializeStandaloneViewer).toHaveBeenCalledTimes(1)
expect(initializeStandaloneViewer).toHaveBeenCalledWith(
expect.any(HTMLElement),
url
)
cleanup.mockClear()
wrapper2.unmount()
expect(cleanup).toHaveBeenCalledOnce()
})
})

View File

@@ -13,10 +13,19 @@ const containerRef = useTemplateRef('containerRef')
const viewer = ref(useLoad3dViewer())
watch([containerRef, () => modelUrl], async () => {
if (!containerRef.value || !modelUrl) return
watch(
[containerRef, () => modelUrl],
async () => {
if (!containerRef.value || !modelUrl) return
await viewer.value.initializeStandaloneViewer(containerRef.value, modelUrl)
viewer.value.cleanup()
await viewer.value.initializeStandaloneViewer(containerRef.value, modelUrl)
},
{ flush: 'post' }
)
onUnmounted(() => {
viewer.value.cleanup()
})
onUnmounted(() => {