From 3b4d1e851d31f41369b3cfa57f50a9c19d39f538 Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Sat, 30 May 2026 04:06:58 -0700 Subject: [PATCH] Clear image preview failure after successful load Co-authored-by: Orca --- .../components/editor/ImageViewer.test.tsx | 170 ++++++++++++++++++ .../src/components/editor/ImageViewer.tsx | 1 + 2 files changed, 171 insertions(+) create mode 100644 src/renderer/src/components/editor/ImageViewer.test.tsx diff --git a/src/renderer/src/components/editor/ImageViewer.test.tsx b/src/renderer/src/components/editor/ImageViewer.test.tsx new file mode 100644 index 00000000000..c7808dbea0e --- /dev/null +++ b/src/renderer/src/components/editor/ImageViewer.test.tsx @@ -0,0 +1,170 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const reactHookRuntime = vi.hoisted(() => ({ + states: [] as unknown[], + index: 0 +})) + +vi.mock('react', async () => { + const actual = await vi.importActual('react') // eslint-disable-line @typescript-eslint/consistent-type-imports -- vi.importActual requires inline import() + return { + ...actual, + useMemo(factory: () => T) { + return factory() + }, + useState(initial: T | (() => T)) { + const stateIndex = reactHookRuntime.index++ + if (!(stateIndex in reactHookRuntime.states)) { + reactHookRuntime.states[stateIndex] = + typeof initial === 'function' ? (initial as () => T)() : initial + } + const setState = (next: T | ((previous: T) => T)): void => { + reactHookRuntime.states[stateIndex] = + typeof next === 'function' + ? (next as (previous: T) => T)(reactHookRuntime.states[stateIndex] as T) + : next + } + return [reactHookRuntime.states[stateIndex] as T, setState] as const + } + } +}) + +vi.mock('lucide-react', () => ({ + Image: function Image(props: Record) { + return { type: 'Image', props } + }, + RotateCcw: function RotateCcw(props: Record) { + return { type: 'RotateCcw', props } + }, + X: function X(props: Record) { + return { type: 'X', props } + }, + ZoomIn: function ZoomIn(props: Record) { + return { type: 'ZoomIn', props } + }, + ZoomOut: function ZoomOut(props: Record) { + return { type: 'ZoomOut', props } + } +})) + +vi.mock('@/components/ui/dialog', () => ({ + Dialog: function Dialog(props: { children?: unknown }) { + return { type: 'Dialog', props } + }, + DialogContent: function DialogContent(props: { children?: unknown }) { + return { type: 'DialogContent', props } + }, + DialogDescription: function DialogDescription(props: { children?: unknown }) { + return { type: 'DialogDescription', props } + }, + DialogTitle: function DialogTitle(props: { children?: unknown }) { + return { type: 'DialogTitle', props } + } +})) + +vi.mock('./PdfViewer', () => ({ + default: function PdfViewer(props: Record) { + return { type: 'PdfViewer', props } + } +})) + +type ReactElementLike = { + type: unknown + props: Record +} + +function expandNode(node: unknown): unknown { + if (node == null || typeof node === 'string' || typeof node === 'number') { + return node + } + if (Array.isArray(node)) { + return node.map(expandNode) + } + const el = node as ReactElementLike + if (typeof el.type === 'function') { + return expandNode(el.type(el.props)) + } + return { + ...el, + props: { + ...el.props, + children: expandNode(el.props?.children) + } + } +} + +function findElementsByType(node: unknown, typeName: string): ReactElementLike[] { + const results: ReactElementLike[] = [] + const visit = (current: unknown): void => { + if (current == null || typeof current === 'string' || typeof current === 'number') { + return + } + if (Array.isArray(current)) { + for (const child of current) { + visit(child) + } + return + } + const el = current as ReactElementLike + if (el.type === typeName) { + results.push(el) + } + visit(el.props?.children) + } + visit(node) + return results +} + +function findPreviewImage(node: unknown): ReactElementLike { + const image = findElementsByType(node, 'img').find((element) => element.props.onError) + if (!image) { + throw new Error('preview image not found') + } + return image +} + +async function renderExpandedImageViewer(content: string): Promise { + reactHookRuntime.index = 0 + const module = await import('./ImageViewer') + return expandNode( + module.default({ + content, + filePath: '/repo/preview.png', + mimeType: 'image/png' + }) + ) +} + +describe('ImageViewer preview source retry', () => { + beforeEach(() => { + reactHookRuntime.states = [] + reactHookRuntime.index = 0 + vi.clearAllMocks() + }) + + it('retries an earlier failed source after a later source loads successfully', async () => { + const failedContent = 'failed-source' + const loadedContent = 'loaded-source' + + const firstRender = await renderExpandedImageViewer(failedContent) + const firstImage = findPreviewImage(firstRender) + expect(firstImage.props.src).toBe(`data:image/png;base64,${failedContent}`) + + ;(firstImage.props.onError as () => void)() + const failedRender = await renderExpandedImageViewer(failedContent) + expect(findElementsByType(failedRender, 'Image')).toHaveLength(1) + expect(findElementsByType(failedRender, 'img')).toHaveLength(0) + + const loadedRender = await renderExpandedImageViewer(loadedContent) + const loadedImage = findPreviewImage(loadedRender) + ;( + loadedImage.props.onLoad as (event: { + currentTarget: { naturalWidth: number; naturalHeight: number } + }) => void + )({ currentTarget: { naturalWidth: 12, naturalHeight: 10 } }) + + const retryRender = await renderExpandedImageViewer(failedContent) + const retryImage = findPreviewImage(retryRender) + expect(retryImage.props.src).toBe(`data:image/png;base64,${failedContent}`) + }) +}) diff --git a/src/renderer/src/components/editor/ImageViewer.tsx b/src/renderer/src/components/editor/ImageViewer.tsx index ea0e24e41fc..6e2c6cadfa4 100644 --- a/src/renderer/src/components/editor/ImageViewer.tsx +++ b/src/renderer/src/components/editor/ImageViewer.tsx @@ -112,6 +112,7 @@ export default function ImageViewer({ onLoad={(event) => { const img = event.currentTarget setImageDimensions({ width: img.naturalWidth, height: img.naturalHeight }) + setFailedPreviewSrc(null) }} // Why: track the failed source identity, not a boolean, so a new // image retries immediately without waiting for an Effect reset.