From 8db082fc863706db5ca454662ce7dd48caa3eebf Mon Sep 17 00:00:00 2001 From: Alaister Young <10985857+alaister@users.noreply.github.com> Date: Fri, 8 May 2026 15:30:51 +0800 Subject: [PATCH] fix(studio): beef up next/image + next/legacy/image compat shims Both shims still degrade to a plain (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. --- apps/studio/compat/next/image.tsx | 126 ++++++++++++++++++++--- apps/studio/compat/next/legacy/image.tsx | 118 +++++++++++++++++---- 2 files changed, 209 insertions(+), 35 deletions(-) diff --git a/apps/studio/compat/next/image.tsx b/apps/studio/compat/next/image.tsx index db4cae97c18..69c19e3846e 100644 --- a/apps/studio/compat/next/image.tsx +++ b/apps/studio/compat/next/image.tsx @@ -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, 'src' | 'alt'> & { - src: string +// Next/Image is a smart wrapper around `` 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 `` 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, '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 +) { + const innerRef = useRef(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) => { + 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 -} + + return ( + { + 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 diff --git a/apps/studio/compat/next/legacy/image.tsx b/apps/studio/compat/next/legacy/image.tsx index 470eb3b8d7c..fafb20a909a 100644 --- a/apps/studio/compat/next/legacy/image.tsx +++ b/apps/studio/compat/next/legacy/image.tsx @@ -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 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, 'src' | 'alt'> & { - src: string +interface ImageProps extends Omit, '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 +) { + const innerRef = useRef(null) + + const handleLoad = (e: SyntheticEvent) => { + 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 -} + return ( + { + 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