fix(studio): beef up next/image + next/legacy/image compat shims

Both shims still degrade to a plain <img> (Vite has no Next image
optimizer), but the prop surface now matches the real APIs so consumer
code compiles and behavioural defaults line up with Next.

Added across both:
- forwardRef so callers can take an HTMLImageElement ref.
- \`loader\` prop that, when provided, is called with { src, width,
  quality } to build the final src URL.
- \`onLoadingComplete\` callback fired on image load, plus an effect
  that fires it on mount when the image is already cached.
- \`unoptimized\` accepted-and-dropped (no optimizer to disable).
- \`sizes\`, \`quality\`, \`placeholder\`, \`blurDataURL\` typed and accepted.
- \`priority\` now drives loading='eager' + fetchPriority='high';
  \`loading\` honoured when explicitly set, otherwise defaults to 'lazy'
  matching Next.
- Object-form src ({ src, width, height }) supported in addition to
  string.

next/image-specific: \`fill\` flag still stretches the image to its
container.
next/legacy/image-specific: \`layout\` / \`objectFit\` / \`objectPosition\`
preserved.
This commit is contained in:
Alaister Young committed 2026-05-08 15:30:51 +08:00
1 parent e5e9711660
commit 8db082fc86
2 files changed
+209 -35

No files matched your search

+109 -17
View File
@@ -1,28 +1,120 @@
import type { ComponentPropsWithoutRef, CSSProperties } from 'react'
import {
forwardRef,
useEffect,
useRef,
type ComponentPropsWithoutRef,
type CSSProperties,
type ForwardedRef,
type SyntheticEvent,
} from 'react'
type ImageProps = Omit<ComponentPropsWithoutRef<'img'>, 'src' | 'alt'> & {
src: string
// Next/Image is a smart wrapper around `<img>` that handles automatic
// resizing, lazy loading, blur placeholders, and a CDN loader. Under
// Vite we don't run the Next image optimizer, so this shim degrades to
// a plain `<img>` while preserving the prop surface so consumer code
// compiles without modification.
type ImageLoaderProps = { src: string; width: number; quality?: number }
type ImageLoader = (props: ImageLoaderProps) => string
interface ImageProps extends Omit<ComponentPropsWithoutRef<'img'>, 'src' | 'alt' | 'loading'> {
src: string | { src: string; width?: number; height?: number }
alt: string
width?: number | `${number}`
height?: number | `${number}`
fill?: boolean
sizes?: string
priority?: boolean
quality?: number
placeholder?: 'blur' | 'empty'
loading?: 'lazy' | 'eager'
quality?: number | `${number}`
loader?: ImageLoader
placeholder?: 'blur' | 'empty' | `data:image/${string}`
blurDataURL?: string
unoptimized?: boolean
onLoadingComplete?: (img: HTMLImageElement) => void
}
export default function Image({
fill,
sizes: _sizes,
priority: _priority,
quality: _quality,
placeholder: _placeholder,
blurDataURL: _blurDataURL,
style,
...rest
}: ImageProps) {
function resolveSrc(
src: ImageProps['src'],
width?: ImageProps['width'],
quality?: ImageProps['quality'],
loader?: ImageLoader
): string {
const raw = typeof src === 'string' ? src : src.src
if (!loader) return raw
return loader({
src: raw,
width: typeof width === 'number' ? width : Number(width ?? 0),
quality: quality !== undefined ? Number(quality) : undefined,
})
}
const Image = forwardRef(function Image(
{
src,
width,
height,
fill,
sizes,
priority,
loading,
quality,
loader,
placeholder: _placeholder,
blurDataURL: _blurDataURL,
unoptimized: _unoptimized,
onLoad,
onLoadingComplete,
style,
...rest
}: ImageProps,
forwardedRef: ForwardedRef<HTMLImageElement>
) {
const innerRef = useRef<HTMLImageElement | null>(null)
// Mirror Next's onLoadingComplete by firing once when the image has
// finished decoding. Next deduplicates against re-fires; we approximate
// by firing on the load event (the practical observable difference is
// negligible for our bundle).
const handleLoad = (e: SyntheticEvent<HTMLImageElement>) => {
onLoad?.(e)
if (onLoadingComplete && e.currentTarget) {
onLoadingComplete(e.currentTarget)
}
}
// If onLoadingComplete is provided and the image is already cached
// (loaded synchronously before our handler attaches), fire it on
// mount so the contract holds.
useEffect(() => {
const img = innerRef.current
if (!onLoadingComplete || !img) return
if (img.complete && img.naturalWidth > 0) onLoadingComplete(img)
}, [onLoadingComplete])
const finalStyle: CSSProperties | undefined = fill
? { position: 'absolute', inset: 0, width: '100%', height: '100%', ...style }
: style
return <img {...rest} style={finalStyle} />
}
return (
<img
{...rest}
ref={(node) => {
innerRef.current = node
if (typeof forwardedRef === 'function') forwardedRef(node)
else if (forwardedRef) forwardedRef.current = node
}}
src={resolveSrc(src, width, quality, loader)}
width={fill ? undefined : width}
height={fill ? undefined : height}
sizes={sizes}
// Match Next's defaults: lazy unless priority/loading says otherwise.
loading={loading ?? (priority ? 'eager' : 'lazy')}
fetchPriority={priority ? 'high' : rest.fetchPriority}
style={finalStyle}
onLoad={handleLoad}
/>
)
})
export default Image
+100 -18
View File
@@ -1,33 +1,96 @@
import type { ComponentPropsWithoutRef, CSSProperties } from 'react'
import {
forwardRef,
useEffect,
useRef,
type ComponentPropsWithoutRef,
type CSSProperties,
type ForwardedRef,
type SyntheticEvent,
} from 'react'
// `next/legacy/image` is the pre-Next-13 Image API. Functionally similar
// to `next/image` but with `layout` and `objectFit`/`objectPosition`
// props instead of `fill` + style. Same shim approach: degrade to a
// plain <img> with the prop surface preserved.
type ImageLoaderProps = { src: string; width: number; quality?: number }
type ImageLoader = (props: ImageLoaderProps) => string
type Layout = 'fill' | 'fixed' | 'intrinsic' | 'responsive'
type ObjectFit = CSSProperties['objectFit']
type ImageProps = Omit<ComponentPropsWithoutRef<'img'>, 'src' | 'alt'> & {
src: string
interface ImageProps extends Omit<ComponentPropsWithoutRef<'img'>, 'src' | 'alt' | 'loading'> {
src: string | { src: string }
alt: string
width?: number | `${number}`
height?: number | `${number}`
layout?: Layout
objectFit?: ObjectFit
objectPosition?: CSSProperties['objectPosition']
priority?: boolean
quality?: number
loading?: 'lazy' | 'eager'
quality?: number | `${number}`
loader?: ImageLoader
placeholder?: 'blur' | 'empty'
blurDataURL?: string
unoptimized?: boolean
sizes?: string
onLoadingComplete?: (img: HTMLImageElement) => void
}
export default function Image({
layout,
objectFit,
objectPosition,
priority: _priority,
quality: _quality,
placeholder: _placeholder,
blurDataURL: _blurDataURL,
sizes: _sizes,
style,
...rest
}: ImageProps) {
function resolveSrc(
src: ImageProps['src'],
width?: ImageProps['width'],
quality?: ImageProps['quality'],
loader?: ImageLoader
): string {
const raw = typeof src === 'string' ? src : src.src
if (!loader) return raw
return loader({
src: raw,
width: typeof width === 'number' ? width : Number(width ?? 0),
quality: quality !== undefined ? Number(quality) : undefined,
})
}
const Image = forwardRef(function Image(
{
src,
width,
height,
layout,
objectFit,
objectPosition,
priority,
loading,
quality,
loader,
placeholder: _placeholder,
blurDataURL: _blurDataURL,
unoptimized: _unoptimized,
sizes,
onLoad,
onLoadingComplete,
style,
...rest
}: ImageProps,
forwardedRef: ForwardedRef<HTMLImageElement>
) {
const innerRef = useRef<HTMLImageElement | null>(null)
const handleLoad = (e: SyntheticEvent<HTMLImageElement>) => {
onLoad?.(e)
if (onLoadingComplete && e.currentTarget) {
onLoadingComplete(e.currentTarget)
}
}
useEffect(() => {
const img = innerRef.current
if (!onLoadingComplete || !img) return
if (img.complete && img.naturalWidth > 0) onLoadingComplete(img)
}, [onLoadingComplete])
const layoutStyle: CSSProperties | undefined =
layout === 'fill'
? { position: 'absolute', inset: 0, width: '100%', height: '100%' }
@@ -40,5 +103,24 @@ export default function Image({
? { ...layoutStyle, objectFit, objectPosition, ...style }
: undefined
return <img {...rest} style={finalStyle} />
}
return (
<img
{...rest}
ref={(node) => {
innerRef.current = node
if (typeof forwardedRef === 'function') forwardedRef(node)
else if (forwardedRef) forwardedRef.current = node
}}
src={resolveSrc(src, width, quality, loader)}
width={layout === 'fill' ? undefined : width}
height={layout === 'fill' ? undefined : height}
sizes={sizes}
loading={loading ?? (priority ? 'eager' : 'lazy')}
fetchPriority={priority ? 'high' : rest.fetchPriority}
style={finalStyle}
onLoad={handleLoad}
/>
)
})
export default Image