Skip to content

Commit 34a48b8

Browse files
OXeuBad3r
authored andcommitted
fix: resolve blurhash image loading race (openRin#466)
* fix: resolve blurhash image loading race * test: stabilize image load state assertions * test: support bun coverage for client hook tests (cherry picked from commit 54bc2c0)
1 parent d035d9d commit 34a48b8

6 files changed

Lines changed: 237 additions & 27 deletions

File tree

client/src/components/feed_card.tsx

Lines changed: 46 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,54 @@
11
import { asDate, type IsoDateTimeString } from '@rin/api'
2-
import { useMemo } from 'react'
2+
import { useEffect, useMemo, useRef } from 'react'
33
import { useTranslation } from 'react-i18next'
44
import { Link } from 'wouter'
5+
import { drawBlurhashToCanvas } from '../utils/blurhash'
6+
import { parseImageUrlMetadata } from '../utils/image-upload'
57
import { timeago } from '../utils/timeago'
8+
import { useImageLoadState } from '../utils/use-image-load-state'
69
import { HashTag } from './hashtag'
710

11+
function FeedCardImage({ src }: { src: string }) {
12+
const canvasRef = useRef<HTMLCanvasElement>(null)
13+
const { src: cleanSrc, blurhash, width, height } = parseImageUrlMetadata(src)
14+
const { failed, imageRef, loaded, onError, onLoad } = useImageLoadState(cleanSrc)
15+
const aspectRatio = width && height ? `${width} / ${height}` : undefined
16+
17+
useEffect(() => {
18+
if (!blurhash || !canvasRef.current) {
19+
return
20+
}
21+
try {
22+
drawBlurhashToCanvas(canvasRef.current, blurhash)
23+
} catch (error) {
24+
console.error('Failed to render blurhash', error)
25+
}
26+
}, [blurhash])
27+
28+
return (
29+
<div
30+
className='relative mb-2 flex max-h-80 w-full flex-row items-center overflow-hidden rounded-xl'
31+
style={{ aspectRatio }}
32+
>
33+
{blurhash && !loaded ? (
34+
<canvas ref={canvasRef} aria-hidden='true' className='absolute inset-0 h-full w-full scale-110 blur-sm' />
35+
) : null}
36+
<img
37+
ref={imageRef}
38+
src={cleanSrc}
39+
alt=''
40+
width={width}
41+
height={height}
42+
onLoad={onLoad}
43+
onError={onError}
44+
className={`absolute inset-0 h-full w-full object-cover object-center hover:scale-105 translation duration-300 ${
45+
blurhash && (!loaded || failed) ? 'opacity-0' : 'opacity-100'
46+
}`}
47+
/>
48+
</div>
49+
)
50+
}
51+
852
export function FeedCard({
953
id,
1054
title,
@@ -36,15 +80,7 @@ export function FeedCard({
3680

3781
return (
3882
<Link href={`/feed/${id}`} target='_blank' className='w-full rounded-2xl bg-w my-2 p-6 duration-300 bg-button'>
39-
{avatar && (
40-
<div className='flex flex-row items-center mb-2 rounded-xl overflow-clip'>
41-
<img
42-
src={avatar}
43-
alt=''
44-
className='object-cover object-center w-full max-h-96 hover:scale-105 translation duration-300'
45-
/>
46-
</div>
47-
)}
83+
{avatar && <FeedCardImage src={avatar} />}
4884
<h1 className='text-xl font-bold text-gray-700 dark:text-white text-pretty overflow-hidden'>{title || ''}</h1>
4985
<p className='space-x-2'>
5086
<span className='text-gray-400 text-sm' title={createdAtDate.toLocaleString()}>

client/src/components/markdown.tsx

Lines changed: 75 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import 'katex/dist/katex.min.css'
2-
import React, { cloneElement, isValidElement, useCallback, useMemo, useRef } from 'react'
2+
import React, { cloneElement, isValidElement, useCallback, useEffect, useMemo, useRef } from 'react'
33
import ReactMarkdown from 'react-markdown'
44
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'
55
import { base16AteliersulphurpoolLight, vscDarkPlus } from 'react-syntax-highlighter/dist/esm/styles/prism'
@@ -14,7 +14,10 @@ import Download from 'yet-another-react-lightbox/plugins/download'
1414
import Zoom from 'yet-another-react-lightbox/plugins/zoom'
1515
import remarkMermaid from '../remark/remarkMermaid'
1616
import 'yet-another-react-lightbox/styles.css'
17+
import { drawBlurhashToCanvas } from '../utils/blurhash'
1718
import { useColorMode } from '../utils/darkModeUtils'
19+
import { parseImageUrlMetadata } from '../utils/image-upload'
20+
import { useImageLoadState } from '../utils/use-image-load-state'
1821

1922
interface SectionChildProps {
2023
node?: { tagName?: string }
@@ -47,6 +50,69 @@ const isMarkdownImageLinkAtEnd = (text: string) => {
4750
return false
4851
}
4952

53+
function MarkdownImage({
54+
src,
55+
alt,
56+
show,
57+
rounded,
58+
scale,
59+
className,
60+
}: {
61+
src?: string
62+
alt?: string
63+
show: (src?: string) => void
64+
rounded: boolean
65+
scale: string
66+
className?: string
67+
}) {
68+
const canvasRef = useRef<HTMLCanvasElement>(null)
69+
const { src: cleanSrc, blurhash, width, height } = parseImageUrlMetadata(src)
70+
const { failed, imageRef, loaded, onError, onLoad } = useImageLoadState(cleanSrc)
71+
const roundedClass = rounded ? 'rounded-xl' : ''
72+
const aspectRatio = width && height ? `${width} / ${height}` : undefined
73+
74+
useEffect(() => {
75+
if (!blurhash || !canvasRef.current) {
76+
return
77+
}
78+
try {
79+
drawBlurhashToCanvas(canvasRef.current, blurhash)
80+
} catch (error) {
81+
console.error('Failed to render blurhash', error)
82+
}
83+
}, [blurhash])
84+
85+
return (
86+
<span
87+
className={`relative inline-block max-w-full overflow-hidden ${roundedClass}`}
88+
style={{ zoom: scale, aspectRatio }}
89+
>
90+
{blurhash && !loaded ? (
91+
<canvas
92+
ref={canvasRef}
93+
aria-hidden='true'
94+
className={`absolute inset-0 h-full w-full scale-110 blur-sm ${roundedClass}`}
95+
/>
96+
) : null}
97+
<img
98+
ref={imageRef}
99+
src={cleanSrc}
100+
alt={alt}
101+
width={width}
102+
height={height}
103+
onClick={() => {
104+
show(cleanSrc)
105+
}}
106+
onLoad={onLoad}
107+
onError={onError}
108+
className={`mx-auto max-w-full cursor-zoom-in transition-opacity ${roundedClass} ${className || ''} ${
109+
blurhash && (!loaded || failed) ? 'opacity-0' : 'opacity-100'
110+
}`}
111+
/>
112+
</span>
113+
)
114+
}
115+
50116
export function Markdown({ content }: { content: string }) {
51117
const colorMode = useColorMode()
52118
const [index, setIndex] = React.useState(-1)
@@ -97,23 +163,15 @@ export function Markdown({ content }: { content: string }) {
97163
const offset = node?.position?.start.offset ?? 0
98164
const previousContent = content.slice(0, offset)
99165
const newlinesBefore = countNewlinesBeforeNode(previousContent, offset)
100-
const imageAlt = typeof props.alt === 'string' ? props.alt : ''
101166
const Image = ({ rounded, scale }: { rounded: boolean; scale: string }) => (
102-
<button
103-
type='button'
104-
className='bg-transparent border-0 p-0'
105-
onClick={() => {
106-
show(src)
107-
}}
108-
>
109-
<img
110-
src={src}
111-
{...props}
112-
alt={imageAlt}
113-
className={`mx-auto ${rounded ? 'rounded-xl' : ''}`}
114-
style={{ zoom: scale }}
115-
/>
116-
</button>
167+
<MarkdownImage
168+
src={src}
169+
alt={typeof props.alt === 'string' ? props.alt : ''}
170+
show={show}
171+
rounded={rounded}
172+
scale={scale}
173+
className={props.className}
174+
/>
117175
)
118176
if (
119177
newlinesBefore >= 1 ||

client/src/test/setup.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { JSDOM } from 'jsdom'
12
import '@testing-library/jest-dom'
23
import { afterAll, beforeAll, vi } from 'vitest'
34

@@ -11,6 +12,18 @@ const originalConsoleInfo = console.info
1112
const originalConsoleWarn = console.warn
1213
let originalVirtualConsoleEmit: JsdomVirtualConsole['emit'] | undefined
1314

15+
if (typeof document === 'undefined') {
16+
const { window } = new JSDOM('<!doctype html><html><body></body></html>')
17+
18+
Object.assign(globalThis, {
19+
document: window.document,
20+
HTMLElement: window.HTMLElement,
21+
HTMLImageElement: window.HTMLImageElement,
22+
navigator: window.navigator,
23+
window,
24+
})
25+
}
26+
1427
beforeAll(() => {
1528
const virtualConsole = (window as unknown as { _virtualConsole?: JsdomVirtualConsole })._virtualConsole
1629

client/src/types/jsdom.d.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
declare module 'jsdom' {
2+
export class JSDOM {
3+
constructor(html?: string, options?: unknown)
4+
window: Window & typeof globalThis
5+
}
6+
}
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import '../../test/setup'
2+
import { render, waitFor } from '@testing-library/react'
3+
import type { MutableRefObject } from 'react'
4+
import { describe, expect, it } from 'vitest'
5+
import { useImageLoadState } from '../use-image-load-state'
6+
7+
function TestImage({ src, complete, naturalWidth }: { src: string; complete: boolean; naturalWidth: number }) {
8+
const { imageRef, loaded, failed, onLoad, onError } = useImageLoadState(src)
9+
10+
return (
11+
<>
12+
<div data-testid='status'>{JSON.stringify({ failed, loaded })}</div>
13+
<img
14+
ref={node => {
15+
;(imageRef as MutableRefObject<HTMLImageElement | null>).current = node
16+
if (!node) {
17+
return
18+
}
19+
Object.defineProperty(node, 'complete', {
20+
configurable: true,
21+
get: () => complete,
22+
})
23+
Object.defineProperty(node, 'naturalWidth', {
24+
configurable: true,
25+
get: () => naturalWidth,
26+
})
27+
}}
28+
src={src}
29+
alt=''
30+
onLoad={onLoad}
31+
onError={onError}
32+
/>
33+
</>
34+
)
35+
}
36+
37+
describe('useImageLoadState', () => {
38+
it('marks a cached image as loaded after src changes', async () => {
39+
const { getByTestId, rerender } = render(
40+
<TestImage src='https://example.com/a.png' complete={false} naturalWidth={0} />
41+
)
42+
43+
expect(getByTestId('status')).toHaveTextContent('{"failed":false,"loaded":false}')
44+
45+
rerender(<TestImage src='https://example.com/b.png' complete={true} naturalWidth={640} />)
46+
47+
await waitFor(() => {
48+
expect(getByTestId('status')).toHaveTextContent('{"failed":false,"loaded":true}')
49+
})
50+
})
51+
52+
it('marks a completed broken image as failed', async () => {
53+
const { getByTestId } = render(<TestImage src='https://example.com/broken.png' complete={true} naturalWidth={0} />)
54+
55+
await waitFor(() => {
56+
expect(getByTestId('status')).toHaveTextContent('{"failed":true,"loaded":false}')
57+
})
58+
})
59+
})
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import { useEffect, useRef, useState } from 'react'
2+
3+
export function useImageLoadState(src?: string) {
4+
const imageRef = useRef<HTMLImageElement>(null)
5+
const [loaded, setLoaded] = useState(false)
6+
const [failed, setFailed] = useState(false)
7+
8+
useEffect(() => {
9+
setLoaded(false)
10+
setFailed(false)
11+
12+
const image = imageRef.current
13+
if (!src || !image || !image.complete) {
14+
return
15+
}
16+
17+
if (image.naturalWidth > 0) {
18+
setLoaded(true)
19+
return
20+
}
21+
22+
setFailed(true)
23+
}, [src])
24+
25+
return {
26+
failed,
27+
imageRef,
28+
loaded,
29+
onError: () => {
30+
setLoaded(false)
31+
setFailed(true)
32+
},
33+
onLoad: () => {
34+
setLoaded(true)
35+
setFailed(false)
36+
},
37+
}
38+
}

0 commit comments

Comments
 (0)