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