Files
supabase/apps/studio/compat/next/image.tsx
Alaister Young 6946ec2b2d build(studio): Next-compat shims (stack 2/6, from #46424) (#47110)
**Stack 2/6** of the TanStack Start migration (#46424). Stacked on
**#47107** (S1) — review that first; this PR's diff is just the compat
shims.

> [!NOTE]
> Purely additive. Next never imports these files — under TanStack
they're wired in via Vite aliases (`next/*` → `@/compat/next/*`). No
routes consume them yet (that begins in stack 3).

## What's in this PR
`apps/studio/compat/next/*` — drop-in shims so the existing pages-router
code runs unchanged under TanStack Start:
- `link`, `router`, `navigation`, `head`, `image`, `legacy/image`,
`script`, `dynamic`, `server`, `_router-events` — React/runtime shims
over `@tanstack/react-router`.
- `api.ts` — `toWebHandler`, which adapts a pages-router API handler
`(req, res)` into a TanStack server-route Web `fetch` handler.

## Verification
On top of S1: `studio` typecheck ✓, lint (0 errors) ✓. Next build is
unaffected (nothing imports these under tsc).


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Added broad Next.js compatibility support for routing, links, dynamic
imports, images, scripts, head metadata, navigation hooks, server
responses, and API handlers.
* Improved handling of redirects, pathname/search params, base paths,
and event callbacks for smoother app behavior.

* **Tests**
* Added coverage for URL resolution and dynamic route interpolation to
verify Next-style routing behavior.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Alaister Young <10985857+alaister@users.noreply.github.com>
Co-authored-by: Ivan Vasilov <vasilov.ivan@gmail.com>
2026-06-25 16:52:34 +08:00

160 lines
5.1 KiB
TypeScript

import {
forwardRef,
useEffect,
useRef,
type ComponentPropsWithoutRef,
type CSSProperties,
type ForwardedRef,
type SyntheticEvent,
} from 'react'
import { BASE_PATH } from '@/lib/constants'
// 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.
//
// basePath: Next auto-prepends the configured basePath to absolute `src`
// values that point at app-served assets (the optimizer URL Next builds
// internally is itself prefixed). Our shim has no optimizer, so we
// prepend basePath directly on the rendered <img src> for absolute
// paths. Full URLs (http:, data:, etc.) and already-prefixed paths are
// left alone. When a custom `loader` is provided the loader is
// responsible for the final URL — Next behaves the same way.
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
loading?: 'lazy' | 'eager'
quality?: number | `${number}`
loader?: ImageLoader
placeholder?: 'blur' | 'empty' | `data:image/${string}`
blurDataURL?: string
unoptimized?: boolean
onLoadingComplete?: (img: HTMLImageElement) => void
}
function applyBasePath(src: string): string {
if (!BASE_PATH) return src
// Schemes (http:, https:, data:, blob:) and protocol-relative URLs.
if (/^[a-zA-Z][a-zA-Z\d+\-.]*:/.test(src) || src.startsWith('//')) return src
// Already prefixed — happens when callers manually prepend BASE_PATH
// (several studio components do this today).
if (src === BASE_PATH || src.startsWith(`${BASE_PATH}/`)) return src
// Absolute app path — prepend.
if (src.startsWith('/')) return `${BASE_PATH}${src}`
// Relative path — leave alone.
return src
}
function resolveSrc(
src: ImageProps['src'],
width?: ImageProps['width'],
quality?: ImageProps['quality'],
loader?: ImageLoader
): string {
const raw = typeof src === 'string' ? src : src.src
if (loader) {
// Loader is the source of truth for the final URL — match Next and
// don't auto-prepend basePath. The loader receives the original src.
return loader({
src: raw,
width: typeof width === 'number' ? width : Number(width ?? 0),
quality: quality !== undefined ? Number(quality) : undefined,
})
}
return applyBasePath(raw)
}
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)
// Keep the latest callback in a ref so firing doesn't depend on the
// caller memoizing onLoadingComplete.
const onLoadingCompleteRef = useRef(onLoadingComplete)
useEffect(() => {
onLoadingCompleteRef.current = onLoadingComplete
})
const resolvedSrc = resolveSrc(src, width, quality, loader)
// Mirror Next's onLoadingComplete, firing at most once per resolved src.
const firedForSrc = useRef<string | null>(null)
const fireLoadingComplete = (img: HTMLImageElement) => {
if (firedForSrc.current === resolvedSrc) return
firedForSrc.current = resolvedSrc
onLoadingCompleteRef.current?.(img)
}
const handleLoad = (e: SyntheticEvent<HTMLImageElement>) => {
onLoad?.(e)
if (e.currentTarget) fireLoadingComplete(e.currentTarget)
}
// If the image is already cached (loaded synchronously before our
// handler attaches), fire on mount so the contract holds. Keyed on src
// so it re-arms when the image changes.
useEffect(() => {
const img = innerRef.current
if (img?.complete && img.naturalWidth > 0) fireLoadingComplete(img)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [resolvedSrc])
const finalStyle: CSSProperties | undefined = fill
? { position: 'absolute', inset: 0, width: '100%', height: '100%', ...style }
: style
return (
<img
{...rest}
ref={(node) => {
innerRef.current = node
if (typeof forwardedRef === 'function') forwardedRef(node)
else if (forwardedRef) forwardedRef.current = node
}}
src={resolvedSrc}
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}
/>
)
})
// eslint-disable-next-line no-restricted-exports
export default Image