diff --git a/packages/ui/src/elements/Thumbnail/index.spec.ts b/packages/ui/src/elements/Thumbnail/index.spec.ts new file mode 100644 index 00000000000..7863b5935b7 --- /dev/null +++ b/packages/ui/src/elements/Thumbnail/index.spec.ts @@ -0,0 +1,68 @@ +import React from 'react' +import { renderToStaticMarkup } from 'react-dom/server' +import { describe, expect, it } from 'vitest' + +import { getImageLoadState, Thumbnail, ThumbnailComponent } from './index.js' + +describe('Thumbnail', () => { + it('should treat a cached complete image as loaded', () => { + expect( + getImageLoadState({ + complete: true, + naturalWidth: 100, + }), + ).toBe('loaded') + }) + + it('should treat a cached complete image without dimensions as errored', () => { + expect( + getImageLoadState({ + complete: true, + naturalWidth: 0, + }), + ).toBe('error') + }) + + it('should wait for browser load events when an image is not complete', () => { + expect( + getImageLoadState({ + complete: false, + naturalWidth: 0, + }), + ).toBeUndefined() + }) + + it('should render the image directly so the browser owns thumbnail loading', () => { + const markup = renderToStaticMarkup( + React.createElement(Thumbnail, { + doc: { filename: 'photo.jpg' }, + fileSrc: '/api/media/file/photo.jpg', + imageCacheTag: '2026-04-27T08:00:00.000Z', + size: 'small', + }), + ) + + expect(markup).toContain(' { + const markup = renderToStaticMarkup( + React.createElement(ThumbnailComponent, { + alt: 'Photo alt text', + filename: 'photo.jpg', + fileSrc: '/api/media/file/photo.jpg', + imageCacheTag: '2026-04-27T08:00:00.000Z', + size: 'small', + }), + ) + + expect(markup).toContain(' = (props) => { size, width, } = props - const [fileExists, setFileExists] = React.useState(undefined) const classNames = [baseClass, `${baseClass}--size-${size || 'medium'}`, className].join(' ') @@ -42,28 +43,9 @@ export const Thumbnail: React.FC = (props) => { [fileSrc, imageCacheTag], ) - React.useEffect(() => { - if (!src) { - setFileExists(false) - return - } - setFileExists(undefined) - - const img = new Image() - img.src = src - img.onload = () => { - setFileExists(true) - } - img.onerror = () => { - setFileExists(false) - } - }, [src]) - return (
- {fileExists === undefined && } - {fileExists && {filename} - {fileExists === false && } +
) } @@ -78,7 +60,6 @@ type ThumbnailComponentProps = { } export function ThumbnailComponent(props: ThumbnailComponentProps) { const { alt, className = '', filename, fileSrc, imageCacheTag, size } = props - const [fileExists, setFileExists] = React.useState(undefined) const classNames = [baseClass, `${baseClass}--size-${size || 'medium'}`, className].join(' ') @@ -87,28 +68,64 @@ export function ThumbnailComponent(props: ThumbnailComponentProps) { [fileSrc, imageCacheTag], ) + return ( +
+ +
+ ) +} + +export function getImageLoadState({ + complete, + naturalWidth, +}: Pick): ImageLoadState { + if (!complete) { + return undefined + } + + return naturalWidth > 0 ? 'loaded' : 'error' +} + +function ThumbnailImage({ + alt, + height, + src, + width, +}: { + alt: string + height?: number + src: null | string + width?: number +}) { + const [hasLoaded, setHasLoaded] = React.useState(false) + const [hasError, setHasError] = React.useState(false) + const imageRef = React.useRef(null) + React.useEffect(() => { - if (!src) { - setFileExists(false) - return - } - setFileExists(undefined) - - const img = new Image() - img.src = src - img.onload = () => { - setFileExists(true) - } - img.onerror = () => { - setFileExists(false) - } + const loadState = imageRef.current ? getImageLoadState(imageRef.current) : undefined + + setHasLoaded(loadState === 'loaded') + setHasError(loadState === 'error') }, [src]) + if (!src || hasError) { + return + } + return ( -
- {fileExists === undefined && } - {fileExists && {alt} - {fileExists === false && } -
+ + {!hasLoaded && } + {alt} setHasError(true)} + onLoad={() => setHasLoaded(true)} + ref={imageRef} + src={src} + width={width} + /> + ) }