mirror of
https://github.com/supabase/supabase.git
synced 2026-06-28 19:39:19 -04:00
6946ec2b2d
**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>
160 lines
5.1 KiB
TypeScript
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
|