Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 68 additions & 0 deletions packages/ui/src/elements/Thumbnail/index.spec.ts
Original file line number Diff line number Diff line change
@@ -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('<img')
expect(markup).toContain('loading="lazy"')
expect(markup).toContain('decoding="async"')
expect(markup).toContain('/api/media/file/photo.jpg?2026-04-27T08%3A00%3A00.000Z')
})

it('should render the upload relationship image directly so the browser owns thumbnail loading', () => {
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('<img')
expect(markup).toContain('loading="lazy"')
expect(markup).toContain('decoding="async"')
expect(markup).toContain('alt="Photo alt text"')
expect(markup).toContain('/api/media/file/photo.jpg?2026-04-27T08%3A00%3A00.000Z')
})
})
99 changes: 58 additions & 41 deletions packages/ui/src/elements/Thumbnail/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ import { File } from '../../graphics/File/index.js'
import { appendCacheTag } from '../../utilities/appendCacheTag.js'
import { ShimmerEffect } from '../ShimmerEffect/index.js'

type ImageLoadState = 'error' | 'loaded' | undefined

export type ThumbnailProps = {
className?: string
collectionSlug?: string
Expand All @@ -33,7 +35,6 @@ export const Thumbnail: React.FC<ThumbnailProps> = (props) => {
size,
width,
} = props
const [fileExists, setFileExists] = React.useState(undefined)

const classNames = [baseClass, `${baseClass}--size-${size || 'medium'}`, className].join(' ')

Expand All @@ -42,28 +43,9 @@ export const Thumbnail: React.FC<ThumbnailProps> = (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 (
<div className={classNames}>
{fileExists === undefined && <ShimmerEffect height="100%" />}
{fileExists && <img alt={filename as string} height={height} src={src} width={width} />}
{fileExists === false && <File />}
<ThumbnailImage alt={filename as string} height={height} src={src} width={width} />
</div>
)
}
Expand All @@ -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(' ')

Expand All @@ -87,28 +68,64 @@ export function ThumbnailComponent(props: ThumbnailComponentProps) {
[fileSrc, imageCacheTag],
)

return (
<div className={classNames}>
<ThumbnailImage alt={alt || filename} src={src} />
</div>
)
}

export function getImageLoadState({
complete,
naturalWidth,
}: Pick<HTMLImageElement, 'complete' | 'naturalWidth'>): 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<HTMLImageElement>(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 <File />
}

return (
<div className={classNames}>
{fileExists === undefined && <ShimmerEffect height="100%" />}
{fileExists && <img alt={alt || filename} src={src} />}
{fileExists === false && <File />}
</div>
<React.Fragment>
{!hasLoaded && <ShimmerEffect height="100%" />}
<img
alt={alt}
decoding="async"
height={height}
loading="lazy"
onError={() => setHasError(true)}
onLoad={() => setHasLoaded(true)}
ref={imageRef}
src={src}
width={width}
/>
</React.Fragment>
)
}