From 84f08712776aab113163290bae3df87519e4bf0a Mon Sep 17 00:00:00 2001 From: bymyself Date: Mon, 4 May 2026 11:00:26 -0700 Subject: [PATCH] refactor(LivePreview): use useImage and cache last good dimensions Replace manual @load/@error handlers and imageError ref with useImage from @vueuse/core. Source is wrapped in computed() so useImage re-runs when imageUrl changes. Cache the last successfully loaded naturalWidth/Height in refs that update only when isReady fires. This avoids the placeholder text flickering back to 'Calculating dimensions' each time imageUrl changes during live preview streaming. Test mocks useImage at module level and drives state/isReady/error manually while preserving @testing-library/vue assertions. --- .../vueNodes/components/LivePreview.test.ts | 96 ++++++++++++++----- .../vueNodes/components/LivePreview.vue | 55 ++++++----- 2 files changed, 104 insertions(+), 47 deletions(-) diff --git a/src/renderer/extensions/vueNodes/components/LivePreview.test.ts b/src/renderer/extensions/vueNodes/components/LivePreview.test.ts index 2b57f523be..71fce90302 100644 --- a/src/renderer/extensions/vueNodes/components/LivePreview.test.ts +++ b/src/renderer/extensions/vueNodes/components/LivePreview.test.ts @@ -1,9 +1,32 @@ import { createTestingPinia } from '@pinia/testing' -import { fireEvent, render, screen } from '@testing-library/vue' -import { describe, expect, it, vi } from 'vitest' +import { render, screen } from '@testing-library/vue' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { Ref } from 'vue' import { nextTick } from 'vue' import { createI18n } from 'vue-i18n' +const useImageMock = vi.hoisted(() => ({ + state: null as Ref | null, + isReady: null as Ref | null, + error: null as Ref | null +})) + +vi.mock('@vueuse/core', async () => { + const actual = await vi.importActual('@vueuse/core') + const { ref } = await import('vue') + useImageMock.state = ref(undefined) + useImageMock.isReady = ref(false) + useImageMock.error = ref(null) + return { + ...(actual as Record), + useImage: () => ({ + state: useImageMock.state, + isReady: useImageMock.isReady, + error: useImageMock.error + }) + } +}) + import LivePreview from '@/renderer/extensions/vueNodes/components/LivePreview.vue' const i18n = createI18n({ @@ -21,6 +44,19 @@ const i18n = createI18n({ } }) +function makeFakeLoadedImage(width: number, height: number): HTMLImageElement { + const img = new Image() + Object.defineProperty(img, 'naturalWidth', { + configurable: true, + value: width + }) + Object.defineProperty(img, 'naturalHeight', { + configurable: true, + value: height + }) + return img +} + describe('LivePreview', () => { const defaultProps = { imageUrl: '/api/view?filename=test_sample.png&type=temp' @@ -43,6 +79,12 @@ describe('LivePreview', () => { }) } + beforeEach(() => { + useImageMock.state!.value = undefined + useImageMock.isReady!.value = false + useImageMock.error!.value = null + }) + it('renders preview when imageUrl provided', () => { renderLivePreview() @@ -74,54 +116,62 @@ describe('LivePreview', () => { it('handles image load event', async () => { const { container } = renderLivePreview() - const img = screen.getByRole('img') - Object.defineProperty(img, 'naturalWidth', { - writable: false, - value: 512 - }) - Object.defineProperty(img, 'naturalHeight', { - writable: false, - value: 512 - }) - - await fireEvent.load(img) + useImageMock.state!.value = makeFakeLoadedImage(512, 512) + useImageMock.isReady!.value = true await nextTick() expect(container.textContent).toContain('512 x 512') }) + it('keeps last good dimensions when imageUrl changes (no flicker)', async () => { + const { container, rerender } = renderLivePreview() + + useImageMock.state!.value = makeFakeLoadedImage(800, 600) + useImageMock.isReady!.value = true + await nextTick() + expect(container.textContent).toContain('800 x 600') + + // Simulate the source changing during live preview streaming. useImage + // would normally reset isReady to false until the next image is ready. + useImageMock.isReady!.value = false + await rerender({ + imageUrl: '/api/view?filename=test_sample_2.png&type=temp' + }) + await nextTick() + + // Dimensions should still display, not flicker back to "Calculating". + expect(container.textContent).toContain('800 x 600') + expect(container.textContent).not.toContain('Calculating dimensions') + }) + it('handles image error state', async () => { renderLivePreview() - const img = screen.getByRole('img') - - await fireEvent.error(img) + useImageMock.error!.value = new Event('error') await nextTick() expect(screen.queryByRole('img')).not.toBeInTheDocument() screen.getByText('Image failed to load') }) - it('resets state when imageUrl changes', async () => { + it('resets error state when imageUrl changes', async () => { const { container, rerender } = renderLivePreview() - const img = screen.getByRole('img') - await fireEvent.error(img) + useImageMock.error!.value = new Event('error') await nextTick() expect(container.textContent).toContain('Error loading image') + // useImage resets error automatically when src changes. + useImageMock.error!.value = null await rerender({ imageUrl: '/new-image.png' }) await nextTick() - expect(container.textContent).toContain('Calculating dimensions') expect(container.textContent).not.toContain('Error loading image') }) it('shows error state when image fails to load', async () => { const { container } = renderLivePreview() - const img = screen.getByRole('img') - - await fireEvent.error(img) + useImageMock.error!.value = new Event('error') await nextTick() expect(screen.queryByRole('img')).not.toBeInTheDocument() diff --git a/src/renderer/extensions/vueNodes/components/LivePreview.vue b/src/renderer/extensions/vueNodes/components/LivePreview.vue index 546d758217..a074110045 100644 --- a/src/renderer/extensions/vueNodes/components/LivePreview.vue +++ b/src/renderer/extensions/vueNodes/components/LivePreview.vue @@ -12,8 +12,6 @@ :src="imageUrl" :alt="$t('g.liveSamplingPreview')" class="pointer-events-none min-h-55 w-full flex-1 object-contain contain-size" - @load="handleImageLoad" - @error="handleImageError" />
{{ @@ -26,7 +24,9 @@