mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-07-07 23:57:53 +00:00
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.
This commit is contained in:
@@ -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<HTMLImageElement | undefined> | null,
|
||||
isReady: null as Ref<boolean> | null,
|
||||
error: null as Ref<unknown> | null
|
||||
}))
|
||||
|
||||
vi.mock('@vueuse/core', async () => {
|
||||
const actual = await vi.importActual('@vueuse/core')
|
||||
const { ref } = await import('vue')
|
||||
useImageMock.state = ref<HTMLImageElement | undefined>(undefined)
|
||||
useImageMock.isReady = ref(false)
|
||||
useImageMock.error = ref<unknown>(null)
|
||||
return {
|
||||
...(actual as Record<string, unknown>),
|
||||
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()
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
<div class="text-node-component-header-text mt-1 text-center text-xs">
|
||||
{{
|
||||
@@ -26,7 +24,9 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue'
|
||||
import { useImage } from '@vueuse/core'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
interface LivePreviewProps {
|
||||
imageUrl: string
|
||||
@@ -34,29 +34,36 @@ interface LivePreviewProps {
|
||||
|
||||
const props = defineProps<LivePreviewProps>()
|
||||
|
||||
const actualDimensions = ref<string | null>(null)
|
||||
const imageError = ref(false)
|
||||
const { t } = useI18n()
|
||||
|
||||
watch(
|
||||
() => props.imageUrl,
|
||||
() => {
|
||||
// Reset error state when URL changes, but keep previous dimensions
|
||||
// to avoid flickering "Calculating dimensions" text during live preview
|
||||
imageError.value = false
|
||||
}
|
||||
const {
|
||||
state: imageState,
|
||||
isReady,
|
||||
error
|
||||
} = useImage(
|
||||
computed(() => ({ src: props.imageUrl, alt: t('g.liveSamplingPreview') }))
|
||||
)
|
||||
|
||||
const handleImageLoad = (event: Event) => {
|
||||
if (!event.target || !(event.target instanceof HTMLImageElement)) return
|
||||
const img = event.target
|
||||
imageError.value = false
|
||||
if (img.naturalWidth && img.naturalHeight) {
|
||||
actualDimensions.value = `${img.naturalWidth} x ${img.naturalHeight}`
|
||||
}
|
||||
}
|
||||
// Cache last successfully loaded dimensions so the placeholder text does not
|
||||
// flicker back to "Calculating dimensions" each time `imageUrl` changes during
|
||||
// live preview streaming. Update only when a new image is ready, never on
|
||||
// URL change alone.
|
||||
const cachedWidth = ref<number | null>(null)
|
||||
const cachedHeight = ref<number | null>(null)
|
||||
|
||||
const handleImageError = () => {
|
||||
imageError.value = true
|
||||
actualDimensions.value = null
|
||||
}
|
||||
watch([isReady, imageState], ([ready, img]) => {
|
||||
if (!ready || !img) return
|
||||
if (img.naturalWidth && img.naturalHeight) {
|
||||
cachedWidth.value = img.naturalWidth
|
||||
cachedHeight.value = img.naturalHeight
|
||||
}
|
||||
})
|
||||
|
||||
const imageError = computed(() => !!error.value)
|
||||
|
||||
const actualDimensions = computed(() =>
|
||||
cachedWidth.value && cachedHeight.value
|
||||
? `${cachedWidth.value} x ${cachedHeight.value}`
|
||||
: null
|
||||
)
|
||||
</script>
|
||||
|
||||
Reference in New Issue
Block a user