mirror of
https://github.com/supabase/supabase.git
synced 2026-10-13 13:19:09 +03:00
**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>
164 lines
4.9 KiB
TypeScript
164 lines
4.9 KiB
TypeScript
import {
|
|
forwardRef,
|
|
useEffect,
|
|
useRef,
|
|
type ComponentPropsWithoutRef,
|
|
type CSSProperties,
|
|
type ForwardedRef,
|
|
type SyntheticEvent,
|
|
} from 'react'
|
|
|
|
import { BASE_PATH } from '@/lib/constants'
|
|
|
|
// `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.
|
|
//
|
|
// basePath: see the matching note in ../image.tsx — absolute `src`
|
|
// values are auto-prefixed with the configured basePath; full URLs and
|
|
// already-prefixed paths pass through; loader-produced URLs are not
|
|
// touched.
|
|
|
|
type ImageLoaderProps = { src: string; width: number; quality?: number }
|
|
type ImageLoader = (props: ImageLoaderProps) => string
|
|
|
|
type Layout = 'fill' | 'fixed' | 'intrinsic' | 'responsive'
|
|
type ObjectFit = CSSProperties['objectFit']
|
|
|
|
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
|
|
loading?: 'lazy' | 'eager'
|
|
quality?: number | `${number}`
|
|
loader?: ImageLoader
|
|
placeholder?: 'blur' | 'empty'
|
|
blurDataURL?: string
|
|
unoptimized?: boolean
|
|
sizes?: string
|
|
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
|
|
if (src === BASE_PATH || src.startsWith(`${BASE_PATH}/`)) return src
|
|
if (src.startsWith('/')) return `${BASE_PATH}${src}`
|
|
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) {
|
|
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,
|
|
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)
|
|
|
|
// 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)
|
|
|
|
// Fire onLoadingComplete at most once per resolved src, matching Next's
|
|
// once-per-load contract.
|
|
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)
|
|
}
|
|
|
|
// Catch the cached-image case where the load event fired before our
|
|
// handler attached. 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 layoutStyle: CSSProperties | undefined =
|
|
layout === 'fill'
|
|
? { position: 'absolute', inset: 0, width: '100%', height: '100%' }
|
|
: layout === 'responsive'
|
|
? { width: '100%', height: 'auto' }
|
|
: undefined
|
|
|
|
const finalStyle: CSSProperties | undefined =
|
|
layoutStyle || objectFit || objectPosition || style
|
|
? { ...layoutStyle, objectFit, objectPosition, ...style }
|
|
: undefined
|
|
|
|
return (
|
|
<img
|
|
{...rest}
|
|
ref={(node) => {
|
|
innerRef.current = node
|
|
if (typeof forwardedRef === 'function') forwardedRef(node)
|
|
else if (forwardedRef) forwardedRef.current = node
|
|
}}
|
|
src={resolvedSrc}
|
|
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}
|
|
/>
|
|
)
|
|
})
|
|
|
|
// eslint-disable-next-line no-restricted-exports
|
|
export default Image
|