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(() => ({
height: data?.blockHeight ?? blockHeight ?? 0
}), [data, blockHeight])
}), [data?.blockHeight, blockHeight])
return (
<BlockHeightContext.Provider value={value}>
{children}

View File

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

View File

@ -1,5 +1,5 @@
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 { useShowModal } from './modal'
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(() => {
if (!srcSetObj) return undefined
// srcSetObj shape: { [widthDescriptor]: <imgproxyUrl>, ... }
@ -82,19 +82,45 @@ function ImageProxy ({ src, srcSet: srcSetObj, onClick, topLevel, onError, ...pr
}, { w: 0, url: undefined }).url
}, [srcSetObj])
const handleError = useCallback(onError, [onError])
const handleClick = useCallback(() => onClick(bestResSrc), [onClick, bestResSrc])
return (
<img
<Image
className={topLevel ? styles.topLevel : undefined}
// browsers that don't support srcSet and sizes will use src. use best resolution possible in that case
src={bestResSrc}
srcSet={srcSet}
sizes={sizes}
onClick={() => onClick(bestResSrc)}
onError={onError}
width={dimensions?.width}
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 }) {
const showModal = useShowModal()
@ -125,13 +151,15 @@ export default function ZoomableImage ({ src, srcSet, ...props }) {
</Dropdown.Item>)
}), [showModal, originalUrl, styles])
const handleError = useCallback(() => setImgproxy(false), [setImgproxy])
if (!src) return null
if (imgproxy) {
return (
<ImageProxy
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 Link from 'next/link'
import { UNKNOWN_LINK_REL } from '../lib/constants'
import isEqual from 'lodash/isEqual'
export function SearchText ({ text }) {
return (
@ -34,7 +35,7 @@ export function SearchText ({ text }) {
}
// 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 router = useRouter()
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 [copied, setCopied] = useState(false)
const { noFragments, topLevel } = outerProps
const id = useMemo(() =>
noFragments ? undefined : slugger?.slug(toString(node).replace(/[^\w\-\s]+/gi, '')), [node, noFragments, slugger])
const h = useMemo(() => {
@ -114,7 +114,7 @@ export default memo(function Text ({ rel, imgproxyUrls, children, tab, itemId, o
</a>}
</span>
)
}, [outerProps, slugger.current])
}, [topLevel, noFragments, slugger.current])
const Table = useCallback(({ node, ...props }) =>
<span className='table-responsive'>
@ -144,8 +144,8 @@ export default memo(function Text ({ rel, imgproxyUrls, children, tab, itemId, o
return url
}
const srcSet = imgproxyUrls?.[url]
return <ZoomableImage srcSet={srcSet} tab={tab} src={src} rel={rel ?? UNKNOWN_LINK_REL} {...props} {...outerProps} />
}, [imgproxyUrls, outerProps, tab])
return <ZoomableImage srcSet={srcSet} tab={tab} src={src} rel={rel ?? UNKNOWN_LINK_REL} {...props} topLevel />
}, [imgproxyUrls, topLevel, tab])
return (
<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)
if (youtube?.groups?.id) {
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
videoId={youtube.groups.id} className={styles.youtubeContainer} opts={{
playerVars: {
@ -236,4 +236,4 @@ export default memo(function Text ({ rel, imgproxyUrls, children, tab, itemId, o
</Button>}
</div>
)
})
}, isEqual)

View File

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

View File

@ -1,6 +1,7 @@
import { createHmac } from 'node:crypto'
import { extractUrls } from '../lib/md.js'
import { isJob } from '../lib/item.js'
import path from 'node:path'
const imgProxyEnabled = process.env.NODE_ENV === 'production' ||
(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)
continue
}
imgproxyUrls[url] = {}
imgproxyUrls[url] = {
dimensions: await getDimensions(url)
}
for (const res of resolutions) {
const [w, h] = res.split('x')
const processingOptions = `/rs:fit:${w}:${h}`
imgproxyUrls[url][`${w}w`] = createImgproxyUrl(url, processingOptions)
imgproxyUrls[url][`${w}w`] = createImgproxyUrl({ url, options: processingOptions })
}
}
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 target = `${processingOptions}/${b64Url}`
const target = path.join(options, b64Url)
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 } = {}) {