Fix image rerender jitter and layout shift (#896)

* fix image jitter and layout shift

* prevent unecessary context rerenders
This commit is contained in:
Keyan 2024-03-06 13:53:46 -06:00 committed by GitHub
parent 48aef15a07
commit 2fc1ef44dd
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 66 additions and 23 deletions

View File

@ -20,7 +20,7 @@ export const BlockHeightProvider = ({ blockHeight, children }) => {
}) })
const value = useMemo(() => ({ const value = useMemo(() => ({
height: data?.blockHeight ?? blockHeight ?? 0 height: data?.blockHeight ?? blockHeight ?? 0
}), [data, blockHeight]) }), [data?.blockHeight, blockHeight])
return ( return (
<BlockHeightContext.Provider value={value}> <BlockHeightContext.Provider value={value}>
{children} {children}

View File

@ -20,7 +20,7 @@ export const ChainFeeProvider = ({ chainFee, children }) => {
}) })
const value = useMemo(() => ({ const value = useMemo(() => ({
fee: Math.floor(data?.chainFee ?? chainFee ?? 0) fee: Math.floor(data?.chainFee ?? chainFee ?? 0)
}), [data, chainFee]) }), [data?.chainFee, chainFee])
return ( return (
<ChainFeeContext.Provider value={value}> <ChainFeeContext.Provider value={value}>
{children} {children}

View File

@ -1,5 +1,5 @@
import styles from './text.module.css' import styles from './text.module.css'
import { Fragment, useState, useEffect, useMemo, useCallback, forwardRef, useRef } from 'react' import { Fragment, useState, useEffect, useMemo, useCallback, forwardRef, useRef, memo } from 'react'
import { IMGPROXY_URL_REGEXP } from '../lib/url' import { IMGPROXY_URL_REGEXP } from '../lib/url'
import { useShowModal } from './modal' import { useShowModal } from './modal'
import { useMe } from './me' import { useMe } from './me'
@ -63,7 +63,7 @@ function ImageOriginal ({ src, topLevel, rel, tab, children, onClick, ...props }
} }
} }
function ImageProxy ({ src, srcSet: srcSetObj, onClick, topLevel, onError, ...props }) { function ImageProxy ({ src, srcSet: { dimensions, ...srcSetObj } = {}, onClick, topLevel, onError, ...props }) {
const srcSet = useMemo(() => { const srcSet = useMemo(() => {
if (!srcSetObj) return undefined if (!srcSetObj) return undefined
// srcSetObj shape: { [widthDescriptor]: <imgproxyUrl>, ... } // srcSetObj shape: { [widthDescriptor]: <imgproxyUrl>, ... }
@ -82,19 +82,45 @@ function ImageProxy ({ src, srcSet: srcSetObj, onClick, topLevel, onError, ...pr
}, { w: 0, url: undefined }).url }, { w: 0, url: undefined }).url
}, [srcSetObj]) }, [srcSetObj])
const handleError = useCallback(onError, [onError])
const handleClick = useCallback(() => onClick(bestResSrc), [onClick, bestResSrc])
return ( return (
<img <Image
className={topLevel ? styles.topLevel : undefined} className={topLevel ? styles.topLevel : undefined}
// browsers that don't support srcSet and sizes will use src. use best resolution possible in that case // browsers that don't support srcSet and sizes will use src. use best resolution possible in that case
src={bestResSrc} src={bestResSrc}
srcSet={srcSet} srcSet={srcSet}
sizes={sizes} sizes={sizes}
onClick={() => onClick(bestResSrc)} width={dimensions?.width}
onError={onError} height={dimensions?.height}
onClick={handleClick}
onError={handleError}
/> />
) )
} }
const Image = memo(({ className, src, srcSet, sizes, width, height, bestResSrc, onClick, onError }) => {
const style = width && height
? { '--height': `${height}px`, '--width': `${width}px`, '--aspect-ratio': `${width / height}` }
: undefined
return (
<img
className={className}
// browsers that don't support srcSet and sizes will use src. use best resolution possible in that case
src={bestResSrc}
srcSet={srcSet}
sizes={sizes}
width={width}
height={height}
onClick={onClick}
onError={onError}
style={style}
/>
)
})
export default function ZoomableImage ({ src, srcSet, ...props }) { export default function ZoomableImage ({ src, srcSet, ...props }) {
const showModal = useShowModal() const showModal = useShowModal()
@ -125,13 +151,15 @@ export default function ZoomableImage ({ src, srcSet, ...props }) {
</Dropdown.Item>) </Dropdown.Item>)
}), [showModal, originalUrl, styles]) }), [showModal, originalUrl, styles])
const handleError = useCallback(() => setImgproxy(false), [setImgproxy])
if (!src) return null if (!src) return null
if (imgproxy) { if (imgproxy) {
return ( return (
<ImageProxy <ImageProxy
src={src} srcSet={srcSet} src={src} srcSet={srcSet}
onClick={handleClick} onError={() => setImgproxy(false)} {...props} onClick={handleClick} onError={handleError} {...props}
/> />
) )
} }

View File

@ -20,6 +20,7 @@ import { Button } from 'react-bootstrap'
import { useRouter } from 'next/router' import { useRouter } from 'next/router'
import Link from 'next/link' import Link from 'next/link'
import { UNKNOWN_LINK_REL } from '../lib/constants' import { UNKNOWN_LINK_REL } from '../lib/constants'
import isEqual from 'lodash/isEqual'
export function SearchText ({ text }) { export function SearchText ({ text }) {
return ( return (
@ -34,7 +35,7 @@ export function SearchText ({ text }) {
} }
// this is one of the slowest components to render // this is one of the slowest components to render
export default memo(function Text ({ rel, imgproxyUrls, children, tab, itemId, outlawed, ...outerProps }) { export default memo(function Text ({ rel, imgproxyUrls, children, tab, itemId, outlawed, topLevel, noFragments }) {
const [overflowing, setOverflowing] = useState(false) const [overflowing, setOverflowing] = useState(false)
const router = useRouter() const router = useRouter()
const [show, setShow] = useState(false) const [show, setShow] = useState(false)
@ -79,7 +80,6 @@ export default memo(function Text ({ rel, imgproxyUrls, children, tab, itemId, o
const Heading = useCallback(({ children, node, ...props }) => { const Heading = useCallback(({ children, node, ...props }) => {
const [copied, setCopied] = useState(false) const [copied, setCopied] = useState(false)
const { noFragments, topLevel } = outerProps
const id = useMemo(() => const id = useMemo(() =>
noFragments ? undefined : slugger?.slug(toString(node).replace(/[^\w\-\s]+/gi, '')), [node, noFragments, slugger]) noFragments ? undefined : slugger?.slug(toString(node).replace(/[^\w\-\s]+/gi, '')), [node, noFragments, slugger])
const h = useMemo(() => { const h = useMemo(() => {
@ -114,7 +114,7 @@ export default memo(function Text ({ rel, imgproxyUrls, children, tab, itemId, o
</a>} </a>}
</span> </span>
) )
}, [outerProps, slugger.current]) }, [topLevel, noFragments, slugger.current])
const Table = useCallback(({ node, ...props }) => const Table = useCallback(({ node, ...props }) =>
<span className='table-responsive'> <span className='table-responsive'>
@ -144,8 +144,8 @@ export default memo(function Text ({ rel, imgproxyUrls, children, tab, itemId, o
return url return url
} }
const srcSet = imgproxyUrls?.[url] const srcSet = imgproxyUrls?.[url]
return <ZoomableImage srcSet={srcSet} tab={tab} src={src} rel={rel ?? UNKNOWN_LINK_REL} {...props} {...outerProps} /> return <ZoomableImage srcSet={srcSet} tab={tab} src={src} rel={rel ?? UNKNOWN_LINK_REL} {...props} topLevel />
}, [imgproxyUrls, outerProps, tab]) }, [imgproxyUrls, topLevel, tab])
return ( return (
<div className={`${styles.text} ${show ? styles.textUncontained : overflowing ? styles.textContained : ''}`} ref={containerRef}> <div className={`${styles.text} ${show ? styles.textUncontained : overflowing ? styles.textContained : ''}`} ref={containerRef}>
@ -208,7 +208,7 @@ export default memo(function Text ({ rel, imgproxyUrls, children, tab, itemId, o
const youtube = href.match(/(https?:\/\/)?((www\.)?(youtube(-nocookie)?|youtube.googleapis)\.com.*(v\/|v=|vi=|vi\/|e\/|embed\/|user\/.*\/u\/\d+\/)|youtu\.be\/)(?<id>[_0-9a-z-]+)((?:\?|&)(?:t|start)=(?<start>\d+))?/i) const youtube = href.match(/(https?:\/\/)?((www\.)?(youtube(-nocookie)?|youtube.googleapis)\.com.*(v\/|v=|vi=|vi\/|e\/|embed\/|user\/.*\/u\/\d+\/)|youtu\.be\/)(?<id>[_0-9a-z-]+)((?:\?|&)(?:t|start)=(?<start>\d+))?/i)
if (youtube?.groups?.id) { if (youtube?.groups?.id) {
return ( return (
<div style={{ maxWidth: outerProps.topLevel ? '640px' : '320px', paddingRight: '15px', margin: '0.5rem 0' }}> <div style={{ maxWidth: topLevel ? '640px' : '320px', paddingRight: '15px', margin: '0.5rem 0' }}>
<YouTube <YouTube
videoId={youtube.groups.id} className={styles.youtubeContainer} opts={{ videoId={youtube.groups.id} className={styles.youtubeContainer} opts={{
playerVars: { playerVars: {
@ -236,4 +236,4 @@ export default memo(function Text ({ rel, imgproxyUrls, children, tab, itemId, o
</Button>} </Button>}
</div> </div>
) )
}) }, isEqual)

View File

@ -129,22 +129,26 @@
} }
.text img { .text img {
--height: 35vh;
--width: 100%;
display: block; display: block;
margin-top: .5rem; margin-top: .5rem;
margin-bottom: .5rem; margin-bottom: .5rem;
width: auto; width: auto;
max-width: 100%; max-width: min(var(--width), 100%);
cursor: zoom-in; cursor: zoom-in;
max-height: 25vh; height: auto;
max-height: min(var(--height), 25vh);
object-fit: contain; object-fit: contain;
object-position: left top; object-position: left top;
min-width: 50%; min-width: 50%;
aspect-ratio: var(--aspect-ratio);
} }
.text img.topLevel { .text img.topLevel {
margin-top: .75rem; margin-top: .75rem;
margin-bottom: .75rem; margin-bottom: .75rem;
max-height: 35vh; max-height: min(var(--height), 35vh);
} }
img.fullScreen { img.fullScreen {

View File

@ -1,6 +1,7 @@
import { createHmac } from 'node:crypto' import { createHmac } from 'node:crypto'
import { extractUrls } from '../lib/md.js' import { extractUrls } from '../lib/md.js'
import { isJob } from '../lib/item.js' import { isJob } from '../lib/item.js'
import path from 'node:path'
const imgProxyEnabled = process.env.NODE_ENV === 'production' || const imgProxyEnabled = process.env.NODE_ENV === 'production' ||
(process.env.NEXT_PUBLIC_IMGPROXY_URL && process.env.IMGPROXY_SALT && process.env.IMGPROXY_KEY) (process.env.NEXT_PUBLIC_IMGPROXY_URL && process.env.IMGPROXY_SALT && process.env.IMGPROXY_KEY)
@ -100,21 +101,31 @@ export const createImgproxyUrls = async (id, text, { models, forceFetch }) => {
console.log('[imgproxy] id:', id, '-- not image url:', url) console.log('[imgproxy] id:', id, '-- not image url:', url)
continue continue
} }
imgproxyUrls[url] = {} imgproxyUrls[url] = {
dimensions: await getDimensions(url)
}
for (const res of resolutions) { for (const res of resolutions) {
const [w, h] = res.split('x') const [w, h] = res.split('x')
const processingOptions = `/rs:fit:${w}:${h}` const processingOptions = `/rs:fit:${w}:${h}`
imgproxyUrls[url][`${w}w`] = createImgproxyUrl(url, processingOptions) imgproxyUrls[url][`${w}w`] = createImgproxyUrl({ url, options: processingOptions })
} }
} }
return imgproxyUrls return imgproxyUrls
} }
const createImgproxyUrl = (url, processingOptions) => { const getDimensions = async (url) => {
const options = '/d:1'
const imgproxyUrl = createImgproxyUrl({ url, options, pathname: 'info' })
const res = await fetch(imgproxyUrl)
const { width, height } = await res.json()
return { width, height }
}
const createImgproxyUrl = ({ url, pathname = '', options }) => {
const b64Url = Buffer.from(url, 'utf-8').toString('base64url') const b64Url = Buffer.from(url, 'utf-8').toString('base64url')
const target = `${processingOptions}/${b64Url}` const target = path.join(options, b64Url)
const signature = sign(target) const signature = sign(target)
return `${IMGPROXY_URL}${signature}${target}` return new URL(path.join(pathname, signature, target), IMGPROXY_URL).toString()
} }
async function fetchWithTimeout (resource, { timeout = 1000, ...options } = {}) { async function fetchWithTimeout (resource, { timeout = 1000, ...options } = {}) {