diff --git a/apps/studio/compat/next/_router-events.ts b/apps/studio/compat/next/_router-events.ts new file mode 100644 index 00000000000..87c9d3ca83d --- /dev/null +++ b/apps/studio/compat/next/_router-events.ts @@ -0,0 +1,138 @@ +// Adapter that exposes a Next pages-router `events` API on top of +// TanStack Router's `router.subscribe`. Handlers receive Next's +// signature: `(url: string, options: { shallow: boolean })`. +// +// TanStack's `RouterEvents` type (router-core/dist/esm/router.d.ts): +// - onBeforeNavigate — fires before the URL transition begins +// - onBeforeLoad — fires after navigation, before route loaders run +// - onLoad — fires while loaders run +// - onResolved — fires after route is fully resolved +// - onBeforeRouteMount +// - onRendered +// +// Each TanStack event payload carries +// { fromLocation, toLocation, pathChanged, hrefChanged, hashChanged } +// — we forward `toLocation.href` as the URL arg. +// +// Known gap: Next's `routeChangeStart` lets handlers throw to cancel the +// navigation. TanStack's `subscribe` is fire-and-forget; cancellation +// requires `useBlocker` instead. `usePreventNavigationOnUnsavedChanges` +// relies on the throw-to-cancel pattern and will need migrating to +// `useBlocker` separately. + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type AnyRouter = any +type Handler = (url: string, options: { shallow: boolean }) => void + +type NextEventName = + | 'routeChangeStart' + | 'routeChangeComplete' + | 'routeChangeError' + | 'beforeHistoryChange' + | 'hashChangeStart' + | 'hashChangeComplete' + +type TanStackEventName = + | 'onBeforeNavigate' + | 'onBeforeLoad' + | 'onLoad' + | 'onResolved' + | 'onBeforeRouteMount' + | 'onRendered' + +type TanStackNavigationEvent = { + type: TanStackEventName + fromLocation?: { href: string; pathname: string; hash: string } + toLocation: { href: string; pathname: string; hash: string } + pathChanged: boolean + hrefChanged: boolean + hashChanged: boolean +} + +type Mapping = { + tsEvent: TanStackEventName + // Optional filter — only fire for events matching the predicate (used + // to scope `hashChange*` to hash-only navigations). + filter?: (event: TanStackNavigationEvent) => boolean +} + +const EVENT_MAP: Record = { + // `onBeforeLoad` is closer to Next's `routeChangeStart` semantics than + // `onBeforeNavigate` — both fire after the URL is committed but before + // the page renders. + routeChangeStart: { tsEvent: 'onBeforeLoad' }, + routeChangeComplete: { tsEvent: 'onResolved' }, + // TanStack surfaces errors via router state rather than a dedicated + // lifecycle event. No-op for now; if a consumer needs this we can + // subscribe to `router.__store` instead. + routeChangeError: undefined, + // Next fires `beforeHistoryChange` between `routeChangeStart` and the + // pushState call — `onBeforeNavigate` is the closest TanStack stage. + beforeHistoryChange: { tsEvent: 'onBeforeNavigate' }, + hashChangeStart: { + tsEvent: 'onBeforeNavigate', + filter: (e) => e.hashChanged && !e.pathChanged, + }, + hashChangeComplete: { + tsEvent: 'onResolved', + filter: (e) => e.hashChanged && !e.pathChanged, + }, +} + +type EventsProxy = { + on(event: NextEventName, handler: Handler): void + off(event: NextEventName, handler: Handler): void + emit(event: NextEventName, ...args: unknown[]): void +} + +const proxyCache = new WeakMap() + +function createProxy(router: AnyRouter): EventsProxy { + // Tracked per (event, handler) so the same handler can subscribe to + // multiple Next events with independent unsubscribes. + const unsubs = new Map void>>() + + return { + on(event, handler) { + const mapping = EVENT_MAP[event] + if (!mapping) return + + const adapt = (e: TanStackNavigationEvent) => { + if (mapping.filter && !mapping.filter(e)) return + // Next handlers expect the destination URL string + a shallow + // flag. We don't model shallow routing under TanStack, so it's + // always `false`. + handler(e.toLocation.href, { shallow: false }) + } + + const unsub = router.subscribe(mapping.tsEvent, adapt) + let map = unsubs.get(event) + if (!map) { + map = new Map() + unsubs.set(event, map) + } + map.set(handler, unsub) + }, + off(event, handler) { + const map = unsubs.get(event) + if (!map) return + const unsub = map.get(handler) + if (unsub) { + unsub() + map.delete(handler) + } + }, + emit() { + // Next exposes `events.emit` but nothing in studio calls it. + }, + } +} + +export function getRouterEventsProxy(router: AnyRouter): EventsProxy { + let proxy = proxyCache.get(router) + if (!proxy) { + proxy = createProxy(router) + proxyCache.set(router, proxy) + } + return proxy +} diff --git a/apps/studio/compat/next/api.ts b/apps/studio/compat/next/api.ts new file mode 100644 index 00000000000..15e46eac39f --- /dev/null +++ b/apps/studio/compat/next/api.ts @@ -0,0 +1,370 @@ +import type { NextApiRequest, NextApiResponse } from 'next' + +// Adapter that lets Next.js pages-router API handlers run under TanStack +// Start server routes without rewriting them. The adapter builds a +// NextApiRequest-shaped object from the Web `Request`, plus a +// NextApiResponse proxy that captures `status` / `setHeader` / `json` / +// `send` / `write` / `end` / `redirect` and produces a Web `Response`. +// +// Limitations (acceptable for the bulk of studio API routes): +// - Body is buffered: streaming handlers that want to flush to the client +// before finishing will still buffer in memory. Studio has 2 such routes +// (functions body.ts, mcp/index.ts) that need bespoke handling. +// - Body parsing covers JSON and urlencoded. Multipart/raw binary inbound +// is not implemented — no studio route reads multipart in. + +type NextHandler = (req: NextApiRequest, res: NextApiResponse) => unknown | Promise + +interface RouteCtx { + request: Request + params?: Record +} + +export function toWebHandler(handler: NextHandler) { + return async ({ request, params = {} }: RouteCtx): Promise => { + const req = await buildRequest(request, params) + const { res, finalize } = buildResponse() + + const result = await handler(req, res) + + // Handlers like /api/ai/docs.ts already return a Web Response directly + // (they were written for the edge runtime). Pass it through. + if (result instanceof Response) return result + + return finalize() + } +} + +async function buildRequest( + request: Request, + params: Record +): Promise { + const url = new URL(request.url) + const method = request.method.toUpperCase() + + const headers: Record = {} + request.headers.forEach((value, key) => { + const k = key.toLowerCase() + const existing = headers[k] + if (existing === undefined) { + headers[k] = value + } else if (Array.isArray(existing)) { + existing.push(value) + } else { + headers[k] = [existing, value] + } + }) + + const cookies: Record = {} + const cookieHeader = request.headers.get('cookie') + if (cookieHeader) { + for (const part of cookieHeader.split(';')) { + const trimmed = part.trim() + if (!trimmed) continue + const eq = trimmed.indexOf('=') + const name = eq >= 0 ? trimmed.slice(0, eq) : trimmed + const value = eq >= 0 ? trimmed.slice(eq + 1) : '' + cookies[name] = decodeURIComponent(value) + } + } + + // Next merges route params and URL search params into `req.query`. + // Duplicate keys become arrays (URLSearchParams.getAll). Route params + // take precedence over search params of the same name, so they're + // applied last. + const query: Record = {} + for (const key of new Set(url.searchParams.keys())) { + const values = url.searchParams.getAll(key) + query[key] = values.length === 1 ? values[0] : values + } + for (const [k, v] of Object.entries(params)) { + if (v !== undefined) query[k] = v + } + + let body: unknown = undefined + if (method !== 'GET' && method !== 'HEAD' && request.body) { + const contentType = request.headers.get('content-type') ?? '' + if (contentType.includes('application/json')) { + const text = await request.text() + body = text ? JSON.parse(text) : undefined + } else if (contentType.includes('application/x-www-form-urlencoded')) { + const text = await request.text() + body = Object.fromEntries(new URLSearchParams(text)) + } else { + body = await request.text() + } + } + + // Surface a tiny subset of Node's `IncomingMessage` EventEmitter API so + // handlers that wire client-abort via `req.on('close', …)` / + // `req.on('aborted', …)` (e.g. AI streaming routes) can still hook into + // the Web `Request.signal`. We don't model arbitrary event types — only + // `close` and `aborted`, both of which fire when the signal aborts. + const listeners: Record void>> = {} + const fireAbort = () => { + for (const event of ['close', 'aborted'] as const) { + const set = listeners[event] + if (!set) continue + for (const fn of set) { + try { + fn() + } catch { + // Swallow — Node would emit `error` on the request, but we + // have nothing meaningful to do with it here. + } + } + } + } + if (request.signal.aborted) { + queueMicrotask(fireAbort) + } else { + request.signal.addEventListener('abort', fireAbort, { once: true }) + } + + return { + method, + url: url.pathname + url.search, + headers, + query, + body, + cookies, + on(event: string, fn: (...args: unknown[]) => void) { + ;(listeners[event] ??= new Set()).add(fn) + return this + }, + off(event: string, fn: (...args: unknown[]) => void) { + listeners[event]?.delete(fn) + return this + }, + once(event: string, fn: (...args: unknown[]) => void) { + const wrapper = (...args: unknown[]) => { + listeners[event]?.delete(wrapper) + fn(...args) + } + ;(listeners[event] ??= new Set()).add(wrapper) + return this + }, + removeListener(event: string, fn: (...args: unknown[]) => void) { + listeners[event]?.delete(fn) + return this + }, + removeAllListeners(event?: string) { + if (event) listeners[event]?.clear() + else for (const set of Object.values(listeners)) set.clear() + return this + }, + emit(event: string, ...args: unknown[]) { + const set = listeners[event] + if (!set) return false + for (const fn of set) fn(...args) + return true + }, + } as unknown as NextApiRequest +} + +function buildResponse() { + const responseHeaders = new Headers() + const chunks: Uint8Array[] = [] + const encoder = new TextEncoder() + + const encode = (data: unknown): Uint8Array => { + if (data instanceof Uint8Array) return data + if (typeof data === 'string') return encoder.encode(data) + if (data === undefined || data === null) return new Uint8Array(0) + if (typeof Buffer !== 'undefined' && Buffer.isBuffer(data)) { + return new Uint8Array(data) + } + return encoder.encode(String(data)) + } + + // Streaming mode is entered as soon as the handler calls + // `res.writeHead(...)` — that's the Node `ServerResponse` signal that + // headers are committed and chunks should flush as they're written + // (matches what `result.pipeUIMessageStreamToResponse(res, …)` from + // the AI SDK does for token-by-token streaming). + let streamMode = false + let streamController: ReadableStreamDefaultController | null = null + let streamClosed = false + // Backpressure for upstream writers: drain when the controller's + // desired-size goes negative. We don't get a true "drain" event + // from a Web ReadableStream, but most handlers just check the + // return value of `res.write()`. + const stream = new ReadableStream({ + start(controller) { + streamController = controller + }, + cancel() { + streamClosed = true + }, + }) + const enterStreamMode = () => { + if (streamMode) return + streamMode = true + // Flush anything that was buffered before the streaming switch. + for (const c of chunks) streamController?.enqueue(c) + chunks.length = 0 + } + + const res = {} as NextApiResponse & Record + + // `res.statusCode` is the single source of truth so handlers that write + // it directly (a standard Node/Next idiom) are honoured at finalize. + res.statusCode = 200 + res.status = (code: number) => { + res.statusCode = code + return res + } + res.setHeader = (name: string, value: number | string | readonly string[]) => { + const key = name.toLowerCase() + if (Array.isArray(value)) { + responseHeaders.delete(key) + for (const v of value) responseHeaders.append(key, String(v)) + } else { + responseHeaders.set(key, String(value)) + } + return res + } + res.getHeader = (name: string) => responseHeaders.get(name.toLowerCase()) ?? undefined + res.getHeaders = () => { + const out: Record = {} + responseHeaders.forEach((v, k) => { + out[k] = v + }) + return out + } + res.hasHeader = (name: string) => responseHeaders.has(name.toLowerCase()) + res.removeHeader = (name: string) => { + responseHeaders.delete(name.toLowerCase()) + } + // Node `ServerResponse.writeHead(statusCode, statusMessage?, headers?)`. + // We honour `statusCode` and `headers`; `statusMessage` is dropped + // (Fetch `Response` doesn't preserve a custom HTTP/1 reason phrase + // when running through TanStack's runtime). + type WriteHead = ( + code: number, + headersOrMessage?: string | Record, + maybeHeaders?: Record + ) => unknown + ;(res as unknown as { writeHead: WriteHead }).writeHead = ( + code: number, + headersOrMessage?: string | Record, + maybeHeaders?: Record + ) => { + res.statusCode = code + const headers = + typeof headersOrMessage === 'object' && headersOrMessage !== null + ? headersOrMessage + : maybeHeaders + if (headers) { + for (const [name, value] of Object.entries(headers)) { + const key = name.toLowerCase() + if (Array.isArray(value)) { + responseHeaders.delete(key) + for (const v of value) responseHeaders.append(key, String(v)) + } else if (value !== undefined && value !== null) { + responseHeaders.set(key, String(value)) + } + } + } + enterStreamMode() + return res + } + ;(res as unknown as { flushHeaders: () => unknown }).flushHeaders = () => { + enterStreamMode() + return res + } + res.json = (data: unknown) => { + if (!responseHeaders.has('content-type')) { + responseHeaders.set('content-type', 'application/json') + } + const payload = encode(JSON.stringify(data)) + if (streamMode) streamController?.enqueue(payload) + else chunks.push(payload) + return res + } + res.send = (data: unknown) => { + if (data === undefined || data === null) return res + if ( + typeof data === 'object' && + !(data instanceof Uint8Array) && + !(typeof Buffer !== 'undefined' && Buffer.isBuffer(data)) + ) { + return res.json(data) + } + const payload = encode(data) + if (streamMode) streamController?.enqueue(payload) + else chunks.push(payload) + return res + } + res.write = (chunk: unknown) => { + const payload = encode(chunk) + if (streamMode) streamController?.enqueue(payload) + else chunks.push(payload) + return true + } + res.end = (data?: unknown) => { + if (streamMode) { + if (data !== undefined && data !== null) streamController?.enqueue(encode(data)) + if (!streamClosed) { + try { + streamController?.close() + } catch { + // Already closed (or never opened): nothing to do. + } + streamClosed = true + } + } else if (data !== undefined) { + chunks.push(encode(data)) + } + return res + } + res.redirect = (...args: unknown[]) => { + const [status, location] = + typeof args[0] === 'number' ? [args[0], args[1] as string] : [302, args[0] as string] + res.statusCode = status + responseHeaders.set('location', location) + return res + } + + // Node `ServerResponse` is a Writable EventEmitter. AI SDK / other + // pipe-style helpers may attach listeners (drain/close/error) for + // backpressure or cleanup. Accept and no-op them so they don't + // crash; abort is already handled at the `req` level via the + // Request.signal subscription. + const noopEE = (...args: unknown[]) => { + void args + return res + } + ;(res as unknown as Record).on = noopEE + ;(res as unknown as Record).once = noopEE + ;(res as unknown as Record).off = noopEE + ;(res as unknown as Record).removeListener = noopEE + ;(res as unknown as Record).removeAllListeners = noopEE + ;(res as unknown as Record).emit = () => false + ;(res as unknown as Record).addListener = noopEE + ;(res as unknown as Record).prependListener = noopEE + ;(res as unknown as { headersSent: boolean }).headersSent = false + ;(res as unknown as { writableEnded: boolean }).writableEnded = false + + const finalize = (): Response => { + if (streamMode) { + // The handler entered streaming mode (writeHead/flushHeaders) and + // may still be pushing chunks asynchronously after this return — + // the stream stays open until res.end() runs. + return new Response(stream, { status: res.statusCode, headers: responseHeaders }) + } + const totalLen = chunks.reduce((n, c) => n + c.length, 0) + const payload = new Uint8Array(totalLen) + let offset = 0 + for (const c of chunks) { + payload.set(c, offset) + offset += c.length + } + return new Response(payload.length === 0 ? null : payload, { + status: res.statusCode, + headers: responseHeaders, + }) + } + + return { res, finalize } +} diff --git a/apps/studio/compat/next/compat/router.ts b/apps/studio/compat/next/compat/router.ts new file mode 100644 index 00000000000..18ff9c9f1f6 --- /dev/null +++ b/apps/studio/compat/next/compat/router.ts @@ -0,0 +1,8 @@ +// Next exports a `next/compat/router` variant whose `useRouter()` returns +// `NextRouter | null` so that components can be rendered outside a +// pages-router context without crashing. Under TanStack we always have a +// router, so this never returns null in practice. Re-export the full +// pages-router shim so the surface stays in sync (back, forward, reload, +// prefetch, isReady, locale, etc.). + +export { useRouter } from '../router' diff --git a/apps/studio/compat/next/dynamic.tsx b/apps/studio/compat/next/dynamic.tsx new file mode 100644 index 00000000000..430b60ca521 --- /dev/null +++ b/apps/studio/compat/next/dynamic.tsx @@ -0,0 +1,58 @@ +import { lazy, Suspense, type ComponentType, type ReactNode } from 'react' + +type DynamicOptions = { + loading?: () => ReactNode + ssr?: boolean + // Accepted-and-ignored: legacy/deprecated Next options. Listed so + // call sites that pass them don't fail TypeScript. + suspense?: boolean + loadableGenerated?: unknown +} + +type Loader

= () => Promise> | Promise<{ default: ComponentType

}> + +type DynamicComponent

= ComponentType

& { + // Next stamps a `.preload()` on the returned component so consumers + // can trigger the loader ahead of render (e.g. on hover). Returns a + // promise that resolves when the loader settles. + preload: () => Promise +} + +function isDefaultExport

( + value: ComponentType

| { default: ComponentType

} +): value is { default: ComponentType

} { + return typeof value === 'object' && value !== null && 'default' in value +} + +// eslint-disable-next-line no-restricted-exports +export default function dynamic

( + loader: Loader

, + options: DynamicOptions = {} +): DynamicComponent

{ + const { loading, ssr = true } = options + + // Cache the in-flight loader promise so calling `preload()` and then + // rendering doesn't kick off a second import. Matches Next's behaviour + // where preload is essentially a head-start on the same module load. + let cached: Promise<{ default: ComponentType

}> | null = null + const load = () => { + if (cached) return cached + cached = loader().then((mod) => (isDefaultExport

(mod) ? mod : { default: mod })) + return cached + } + + const Lazy = lazy(load) + + function DynamicComponent(props: P) { + if (ssr === false && typeof window === 'undefined') return null + return ( + + + + ) + } + + ;(DynamicComponent as DynamicComponent

).preload = () => load().then(() => undefined) + + return DynamicComponent as DynamicComponent

+} diff --git a/apps/studio/compat/next/head.tsx b/apps/studio/compat/next/head.tsx new file mode 100644 index 00000000000..b5e79e6e73a --- /dev/null +++ b/apps/studio/compat/next/head.tsx @@ -0,0 +1,31 @@ +import type { ReactNode } from 'react' + +// Next/Head's job is to inject children into the document `` and +// deduplicate them by `key` prop. React 19 ships native "document +// metadata" hoisting: any ``, `<meta>`, `<link>`, `<style>`, or +// `<script>` rendered anywhere in the tree is hoisted to `<head>` +// automatically and works for both client render and SSR. Studio uses +// `<Head>` exclusively for those elements (`<title>`, `<meta>`, +// `<link>` — see `pages/maintenance.tsx`, `pages/claim-project.tsx`, +// etc.), so the shim can be a passthrough that lets React do the +// hoisting. +// +// Trade-offs vs the previous `createPortal(children, document.head)` +// approach: +// - SSR: the portal returned `null` on the server, so head content +// from `<Head>` was never in the prerendered HTML. Native hoisting +// emits the metadata in the prerendered output. +// - Deduplication: React 19 dedupes `<title>` (last one wins, same +// as `document.title`) and merges `<meta>`/`<link>` by their +// attributes. Next dedupes by an explicit `key` prop. The +// observable output is equivalent for the consumer set we have. +// - Non-metadata children: React 19 only hoists the metadata tags +// listed above. If a consumer ever renders an arbitrary element +// inside `<Head>`, it'll render in place rather than in `<head>`. +// We have no such consumers today; if one shows up, swap to a +// portal+SSR-collector setup. + +// eslint-disable-next-line no-restricted-exports +export default function Head({ children }: { children?: ReactNode }) { + return <>{children}</> +} diff --git a/apps/studio/compat/next/image.tsx b/apps/studio/compat/next/image.tsx new file mode 100644 index 00000000000..c0a931a7041 --- /dev/null +++ b/apps/studio/compat/next/image.tsx @@ -0,0 +1,159 @@ +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 diff --git a/apps/studio/compat/next/legacy/image.tsx b/apps/studio/compat/next/legacy/image.tsx new file mode 100644 index 00000000000..da45e196ca3 --- /dev/null +++ b/apps/studio/compat/next/legacy/image.tsx @@ -0,0 +1,163 @@ +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 diff --git a/apps/studio/compat/next/link.tsx b/apps/studio/compat/next/link.tsx new file mode 100644 index 00000000000..c662e623466 --- /dev/null +++ b/apps/studio/compat/next/link.tsx @@ -0,0 +1,252 @@ +import { Link as TanStackLink } from '@tanstack/react-router' +import { + cloneElement, + forwardRef, + isValidElement, + type AnchorHTMLAttributes, + type ForwardedRef, + type MouseEvent, + type ReactElement, + type ReactNode, + type Ref, +} from 'react' + +// Next's Link accepts either a string `href` or a `UrlObject` +// ({pathname, query, hash}). Workspace source does both — flatten +// `UrlObject` into `pathname?search#hash` first so the TanStack `to` +// prop (which only takes a string) works for either input. + +type QueryValue = string | number | boolean | string[] | undefined | null +type UrlObject = { + pathname?: string + query?: Record<string, QueryValue> | string + hash?: string + search?: string +} + +interface LinkProps extends Omit<AnchorHTMLAttributes<HTMLAnchorElement>, 'href'> { + href: string | UrlObject + children?: ReactNode + replace?: boolean + scroll?: boolean + shallow?: boolean + passHref?: boolean + prefetch?: boolean | null | 'auto' + locale?: string | false + legacyBehavior?: boolean + // Next-specific props with no TanStack equivalent; accepted and dropped. + as?: string | UrlObject +} + +function serializeQuery(query: UrlObject['query']): string { + if (!query) return '' + if (typeof query === 'string') return query.startsWith('?') ? query : `?${query}` + const params = new URLSearchParams() + for (const [key, raw] of Object.entries(query)) { + if (raw == null) continue + if (Array.isArray(raw)) { + for (const item of raw) if (item != null) params.append(key, String(item)) + } else { + params.set(key, String(raw)) + } + } + const s = params.toString() + return s ? `?${s}` : '' +} + +function resolveHref(href: string | UrlObject): string { + if (typeof href === 'string') return href + const pathname = href.pathname ?? '' + const search = href.search ?? serializeQuery(href.query) + const hash = href.hash ? (href.hash.startsWith('#') ? href.hash : `#${href.hash}`) : '' + return `${pathname}${search}${hash}` +} + +// Inlined at build time via Vite's `define`. Must agree with Vite `base` +// and `tanstackStart({ router: { basepath } })`. Empty string when no +// basePath is configured. Used to strip a duplicate prefix in +// `splitInternalUrl` below — see the comment there. +const NEXT_PUBLIC_BASE_PATH = process.env.NEXT_PUBLIC_BASE_PATH ?? '' + +// TanStack Link's `to` prop is a route-pattern path; query params and hash +// must be passed separately via `search` / `hash`. Studio code (and Next's +// own contract) routinely passes one of three href shapes: +// 1. a relative path like `/project/abc/editor/123?schema=public` +// 2. a same-origin absolute URL produced by `new URL(...).toString()`, +// e.g. `http://localhost:8082/project/abc/editor/123?schema=public` +// (this is what `buildTableEditorUrl` does) +// 3. a genuinely external URL like `https://supabase.com/docs`. +// +// If we forward any of these straight through to TanStack as `to`, TanStack +// either fails to match a known route pattern (#1 with query) or treats +// the whole thing as external (#2) and falls back to native browser +// navigation — which the user sees as a full page reload. +// +// Split into three parts: pathname, search, hash. Same-origin absolute +// URLs are normalised to a relative path. Cross-origin URLs are left +// alone so TanStack's external-link path handles them. +// +// basePath quirk: TanStack's `to` is **basepath-relative** — given +// `basepath: '/dashboard'` and `to: '/foo'`, TanStack builds the href +// `/dashboard/foo`. Next's contract treats `href` as the **full path +// from app root including basePath**, and studio code routinely +// pre-prefixes BASE_PATH (e.g. `buildTableEditorUrl` calls +// `new URL(`${BASE_PATH}/project/.../editor/...`, location.origin)`). +// Forwarding the BASE_PATH-prefixed pathname as `to` makes TanStack +// double-prefix it (`/dashboard/dashboard/project/...`). Strip the +// basePath when we see it, so what we hand TanStack is always +// basepath-relative. +function splitInternalUrl(url: string): { + to: string + search?: Record<string, string> + hash?: string +} { + // Try to detect cross-origin absolute URLs cheaply before paying for a + // full parse. Protocol-relative URLs (`//host/...`) are always external. + if (url.startsWith('//')) { + return { to: url } + } + + // Use the document origin as the parse base so relative inputs resolve. + // SSR has no `location`; fall back to a placeholder host that won't ever + // collide with a real one. + const base = + typeof window !== 'undefined' && window.location ? window.location.origin : 'http://_/' + + let parsed: URL + try { + parsed = new URL(url, base) + } catch { + return { to: url } + } + + // Cross-origin → leave for TanStack to handle as external. + if ( + typeof window !== 'undefined' && + window.location && + parsed.origin !== window.location.origin + ) { + return { to: url } + } + + let pathname = parsed.pathname + // Strip a leading basePath segment so we hand TanStack a basepath- + // relative path. Match `/dashboard` exactly OR `/dashboard/...`; don't + // strip a coincidental prefix like `/dashboard-other`. + if ( + NEXT_PUBLIC_BASE_PATH && + (pathname === NEXT_PUBLIC_BASE_PATH || pathname.startsWith(`${NEXT_PUBLIC_BASE_PATH}/`)) + ) { + pathname = pathname.slice(NEXT_PUBLIC_BASE_PATH.length) || '/' + } + + const search = Object.fromEntries(parsed.searchParams) + const hash = parsed.hash || undefined + return { + to: pathname, + search: Object.keys(search).length > 0 ? search : undefined, + hash, + } +} + +// Next: prefetch=true|"auto" → eagerly preload; false → never; default +// in production = true. TanStack's preload values are "intent" (on hover/ +// focus), "viewport" (when entering viewport), "render" (immediately), +// or false. "intent" is the closest behavioural match to Next's default +// hover-prefetch behaviour. +function mapPrefetch(prefetch: LinkProps['prefetch']): 'intent' | false | undefined { + if (prefetch === false) return false + if (prefetch === true || prefetch === 'auto') return 'intent' + return undefined +} + +const Link = forwardRef(function Link( + { + href, + as: _as, + replace, + scroll: _scroll, + shallow: _shallow, + passHref, + prefetch, + locale: _locale, + legacyBehavior, + children, + onClick, + ...rest + }: LinkProps, + ref: ForwardedRef<HTMLAnchorElement> +) { + const resolved = resolveHref(href) + const { to, search, hash } = splitInternalUrl(resolved) + const preload = mapPrefetch(prefetch) + + // Next's `legacyBehavior` (and `passHref` with a custom anchor child) + // expects the consumer's child element to *be* the anchor — the parent + // <Link> shouldn't render its own. We approximate by cloning the + // single child element and stamping `href` plus a click handler that + // fires TanStack navigation. Modifier-clicks / middle-clicks fall + // through to the browser the same way Next does. + if (legacyBehavior && isValidElement(children)) { + const child = children as ReactElement<{ + href?: string + onClick?: (e: MouseEvent<HTMLAnchorElement>) => void + ref?: Ref<HTMLAnchorElement> + }> + return ( + <TanStackLink + // eslint-disable-next-line @typescript-eslint/no-explicit-any + to={to as any} + // eslint-disable-next-line @typescript-eslint/no-explicit-any + search={search as any} + hash={hash} + replace={replace} + preload={preload} + // TanStack Link supports a function child for custom rendering. + // Cast through unknown to bridge the typing — runtime contract is + // the same. + > + {(() => { + // Inside TanStackLink's child slot we still get an <a> by default; + // when legacyBehavior is true the consumer wants to control the + // anchor themselves. Render the cloned child with merged props. + return cloneElement(child, { + href: resolved, + ref, + onClick: (e: MouseEvent<HTMLAnchorElement>) => { + child.props.onClick?.(e) + onClick?.(e) + }, + }) + })()} + </TanStackLink> + ) + } + + // `passHref` without `legacyBehavior` is a no-op in modern Next when + // the child is anything other than an `<a>` — the wrapping <Link> + // renders the anchor and the `href` attribute lands on it. We get the + // same outcome by passing children through TanStackLink, which also + // renders an anchor. + void passHref + + return ( + <TanStackLink + // eslint-disable-next-line @typescript-eslint/no-explicit-any + to={to as any} + // eslint-disable-next-line @typescript-eslint/no-explicit-any + search={search as any} + hash={hash} + replace={replace} + preload={preload} + ref={ref} + onClick={onClick} + {...rest} + > + {children} + </TanStackLink> + ) +}) + +// eslint-disable-next-line no-restricted-exports +export default Link diff --git a/apps/studio/compat/next/navigation.ts b/apps/studio/compat/next/navigation.ts new file mode 100644 index 00000000000..576693a1d9b --- /dev/null +++ b/apps/studio/compat/next/navigation.ts @@ -0,0 +1,140 @@ +import { + useLocation, + useMatches, + useParams as useTanStackParams, + useRouter as useTanStackRouter, +} from '@tanstack/react-router' +import { useMemo } from 'react' + +// `next/navigation` is the App Router hook surface (Next 13+). Studio is +// still pages-based, so most of these are entry-points that arrive via +// stray imports or shared packages — we keep the surface comprehensive +// to avoid call sites silently getting `undefined` for something Next +// would have provided. + +type NavigateOptions = { + scroll?: boolean +} + +type PrefetchOptions = { + kind?: 'auto' | 'full' | 'temporary' +} + +export function usePathname(): string | null { + return useLocation().pathname +} + +export function useRouter() { + const router = useTanStackRouter() + + return useMemo( + () => ({ + push: (href: string, _options?: NavigateOptions) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + router.navigate({ to: href as any }) + }, + replace: (href: string, _options?: NavigateOptions) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + router.navigate({ to: href as any, replace: true }) + }, + // App Router's refresh() refetches the current route's data without + // re-mounting the React tree. Closest TanStack equivalent is + // invalidating the active matches. + refresh: () => { + router.invalidate() + }, + back: () => { + if (typeof window !== 'undefined') window.history.back() + }, + forward: () => { + if (typeof window !== 'undefined') window.history.forward() + }, + prefetch: (href: string, _options?: PrefetchOptions) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + router.preloadRoute({ to: href as any }).catch(() => { + // Match Next's fire-and-forget contract. + }) + }, + }), + [router] + ) +} + +export function useSearchParams(): URLSearchParams { + const location = useLocation() + // App Router returns a ReadonlyURLSearchParams; URLSearchParams is + // structurally compatible for read access. + return useMemo(() => new URLSearchParams(location.searchStr ?? ''), [location.searchStr]) +} + +export function useParams< + T extends Record<string, string | string[]> = Record<string, string>, +>(): T { + // App Router's useParams() returns a flat object of dynamic segments. + // TanStack's strict-false useParams returns the same shape merged + // across matches. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return useTanStackParams({ strict: false } as any) as T +} + +// `useSelectedLayoutSegment(parallelRoute?)` returns the active leaf +// segment one level below the layout that calls it. We approximate by +// returning the trailing URL segment of the active route, which is what +// most studio callers want when they drift into this hook. +export function useSelectedLayoutSegment(_parallelRouteKey?: string): string | null { + const matches = useMatches() + const leafRouteId = matches[matches.length - 1]?.routeId + if (!leafRouteId) return null + const segments = leafRouteId.split('/').filter(Boolean) + const last = segments[segments.length - 1] + return last ?? null +} + +export function useSelectedLayoutSegments(_parallelRouteKey?: string): string[] { + const matches = useMatches() + const leafRouteId = matches[matches.length - 1]?.routeId + if (!leafRouteId) return [] + return leafRouteId.split('/').filter(Boolean) +} + +// Next's RedirectType enum. App Router uses these as string values. +export const RedirectType = { + push: 'push', + replace: 'replace', +} as const +export type RedirectType = (typeof RedirectType)[keyof typeof RedirectType] + +// Next signals these by throwing internal symbols that Next's renderer +// catches. We have no equivalent renderer in TanStack, so we use the +// closest behavioural match: `redirect` does a client-side navigation +// and then throws so the calling component stops rendering on the same +// tick (mirroring Next's "function never returns" contract). Callers +// that catch and ignore will get unexpected behaviour — same as in Next. +const NEXT_NOT_FOUND = Symbol.for('next.not-found') +const NEXT_REDIRECT = Symbol.for('next.redirect') + +export function notFound(): never { + const err = new Error('NEXT_NOT_FOUND') as Error & { digest?: symbol } + err.digest = NEXT_NOT_FOUND + throw err +} + +export function redirect(url: string, type: RedirectType = RedirectType.replace): never { + if (typeof window !== 'undefined') { + if (type === RedirectType.push) { + window.location.assign(url) + } else { + window.location.replace(url) + } + } + const err = new Error(`NEXT_REDIRECT;${type};${url}`) as Error & { digest?: symbol } + err.digest = NEXT_REDIRECT + throw err +} + +export function permanentRedirect(url: string, type: RedirectType = RedirectType.replace): never { + // Next distinguishes permanent vs temporary at the framework level + // (different HTTP status when triggered server-side). Client-side + // there's no observable difference; we delegate to redirect(). + return redirect(url, type) +} diff --git a/apps/studio/compat/next/router.test.ts b/apps/studio/compat/next/router.test.ts new file mode 100644 index 00000000000..f5b3aa67323 --- /dev/null +++ b/apps/studio/compat/next/router.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from 'vitest' + +import { resolveUrl } from './router' + +describe('resolveUrl (next/router compat shim)', () => { + it('returns string URLs untouched', () => { + expect(resolveUrl('/project/abc/editor/1?schema=public')).toBe( + '/project/abc/editor/1?schema=public' + ) + // A string that happens to contain brackets is NOT interpolated. + expect(resolveUrl('/project/[ref]/editor')).toBe('/project/[ref]/editor') + }) + + it('interpolates dynamic params from query into a bracketed pathname and drops them from the query string', () => { + // The regression we fixed: useUrlState pushes `router.pathname` (the bracketed + // pattern) + query. Next fills the params; the shim must too, or TanStack + // navigates to a literal `/project/[ref]/...` and bounces to "project not found". + expect( + resolveUrl({ + pathname: '/project/[ref]/database/tables/[id]', + query: { ref: 'default', id: '123', schema: 'public', sort: 'name:asc' }, + }) + ).toBe('/project/default/database/tables/123?schema=public&sort=name%3Aasc') + }) + + it('omits the query string entirely when every param is consumed', () => { + expect(resolveUrl({ pathname: '/project/[ref]/settings', query: { ref: 'abc' } })).toBe( + '/project/abc/settings' + ) + }) + + it('leaves a bracket-free pathname and its query alone', () => { + expect(resolveUrl({ pathname: '/project/default/sql', query: { a: '1', b: '2' } })).toBe( + '/project/default/sql?a=1&b=2' + ) + }) + + it('interpolates required and optional catch-all segments', () => { + expect( + resolveUrl({ + pathname: '/project/[ref]/logs/[[...slug]]', + query: { ref: 'r', slug: ['a', 'b'] }, + }) + ).toBe('/project/r/logs/a/b') + expect(resolveUrl({ pathname: '/p/[...rest]', query: { rest: ['x', 'y'] } })).toBe('/p/x/y') + }) + + it('does not interpolate when query is a raw string (cannot fill named params)', () => { + expect(resolveUrl({ pathname: '/project/[ref]/x', query: 'a=1' })).toBe('/project/[ref]/x?a=1') + }) + + it('preserves hash and honours an explicit search override', () => { + expect(resolveUrl({ pathname: '/project/[ref]', query: { ref: 'x' }, hash: 'section' })).toBe( + '/project/x#section' + ) + expect( + resolveUrl({ pathname: '/project/[ref]', query: { ref: 'x', drop: 'me' }, search: '?a=1' }) + ).toBe('/project/x?a=1') + }) +}) diff --git a/apps/studio/compat/next/router.ts b/apps/studio/compat/next/router.ts new file mode 100644 index 00000000000..479b4234e84 --- /dev/null +++ b/apps/studio/compat/next/router.ts @@ -0,0 +1,406 @@ +import { + useLocation, + useMatches, + useParams, + useSearch, + useRouter as useTanStackRouter, +} from '@tanstack/react-router' +import { useMemo } from 'react' + +import { getRouterEventsProxy } from './_router-events' + +// Next's pages-router exposes `router.pathname` as the route *pattern* +// (e.g. `/project/[ref]/sql/[id]`), not the resolved URL. TanStack's +// route id uses `$param` — convert so legacy code that does +// `router.pathname.endsWith('/sql/[id]')` keeps working. +// +// Also strip the trailing slash TanStack appends to index-route ids +// (`/project/$ref/`). Next's pages-router never includes a trailing +// slash, so consumers like `router.pathname.split('/')[3]` (used in +// the project sidebar's active-route check) silently see an empty +// string for index pages instead of `undefined`, and the home icon +// stops highlighting. The root path stays `/` either way. +function toNextPathPattern(routeId: string) { + // Strip TanStack's layout-route segments — they're prefixed with `_` + // (`_app`, `_auth`, etc.) and don't appear in the URL or in Next's + // `router.pathname`. Without this, downstream code that derives a path + // segment from `pathname.split('/')[N]` indexes into the wrong slot — + // e.g. Sidebar uses index 3 to pick the active route, expecting + // `/org/[slug]/general` but receiving `/_app/org/[slug]/general` and + // ending up with `[slug]` instead of `general`. + const withoutLayoutSegments = routeId.replace(/\/_[a-zA-Z0-9_]+(?=\/|$)/g, '') + const withBracketParams = withoutLayoutSegments.replace(/\$([a-zA-Z0-9_]+)/g, '[$1]') + if (withBracketParams === '' || withBracketParams === '/') return '/' + return withBracketParams.replace(/\/$/, '') +} + +// Normalise TanStack's `router.basepath` to Next's `router.basePath` +// shape: '' for "no basePath" or '/path' for "configured" (leading +// slash, no trailing slash). +function toNextBasePath(tanstackBasepath: string | undefined): string { + if (!tanstackBasepath || tanstackBasepath === '/') return '' + const withLeading = tanstackBasepath.startsWith('/') ? tanstackBasepath : `/${tanstackBasepath}` + return withLeading.endsWith('/') ? withLeading.slice(0, -1) : withLeading +} + +type QueryValue = string | number | boolean | string[] | undefined | null +type UrlObject = { + pathname?: string + query?: Record<string, QueryValue> | string + hash?: string + search?: string +} + +function serializeQuery(query: UrlObject['query']): string { + if (!query) return '' + if (typeof query === 'string') return query.startsWith('?') ? query : `?${query}` + const params = new URLSearchParams() + for (const [key, raw] of Object.entries(query)) { + if (raw == null) continue + if (Array.isArray(raw)) { + for (const item of raw) if (item != null) params.append(key, String(item)) + } else { + params.set(key, String(raw)) + } + } + const s = params.toString() + return s ? `?${s}` : '' +} + +// Next's pages-router fills dynamic segments in a UrlObject's `pathname` from +// `query`, then drops the consumed keys from the query string — e.g. +// `push({ pathname: '/project/[ref]/editor/[id]', query: { ref, id, foo } })` +// resolves to `/project/<ref>/editor/<id>?foo=...`. `router.pathname` here is +// the bracketed route *pattern* (see toNextPathPattern) and callers like +// useUrlState push it back verbatim, so without this a sort/filter update on a +// dynamic route would navigate TanStack to a LITERAL `/project/[ref]/...`, +// which matches no project (ref === '[ref]') and bounces to a "project not +// found" redirect. Mirrors Next's behaviour so those pushes stay on-page. +function interpolatePathname( + pathname: string, + query: Record<string, QueryValue> +): { pathname: string; query: Record<string, QueryValue> } { + if (!pathname.includes('[')) return { pathname, query } + const consumed = new Set<string>() + const encodeValue = (v: QueryValue) => + v == null + ? '' + : Array.isArray(v) + ? v.map((item) => encodeURIComponent(String(item))).join('/') + : encodeURIComponent(String(v)) + const interpolated = pathname + // optional + required catch-all: `[[...name]]` / `[...name]` + .replace(/\[\[?\.\.\.([^\]]+)\]?\]/g, (_match, name: string) => { + consumed.add(name) + return encodeValue(query[name]) + }) + // single dynamic segment: `[name]` + .replace(/\[([^\]]+)\]/g, (_match, name: string) => { + consumed.add(name) + return encodeValue(query[name]) + }) + if (consumed.size === 0) return { pathname: interpolated, query } + const rest: Record<string, QueryValue> = {} + for (const [key, value] of Object.entries(query)) { + if (!consumed.has(key)) rest[key] = value + } + return { pathname: interpolated, query: rest } +} + +// Exported for unit tests (see router.test.ts) — not part of the Next surface. +export function resolveUrl(url: string | UrlObject): string { + if (typeof url === 'string') return url + let pathname = url.pathname ?? '' + let query = url.query + // Interpolate named params into the path when query is a record — a raw query + // string can't fill `[param]` placeholders, so leave it untouched. + if (query && typeof query === 'object') { + const interpolated = interpolatePathname(pathname, query) + pathname = interpolated.pathname + query = interpolated.query + } + const search = url.search ?? serializeQuery(query) + const hash = url.hash ? (url.hash.startsWith('#') ? url.hash : `#${url.hash}`) : '' + return `${pathname}${search}${hash}` +} + +// Studio code occasionally constructs `router.push` targets via +// `new URL().toString()` (e.g. `buildTableEditorUrl`), producing fully +// qualified `http://localhost:8082/dashboard/project/.../editor/123?...` +// strings — origin + basePath + path. Next's router tolerated both by +// treating same-origin absolute URLs as relative paths AND understanding +// basePath was already in the input. +// +// TanStack Router needs `to` to be basepath-relative — given +// `basepath: '/dashboard'` and `to: '/foo'`, it produces `/dashboard/foo`. +// So we strip the origin AND the basePath when present; otherwise +// `router.push('/dashboard/...')` would double-prefix to +// `/dashboard/dashboard/...`. Mirrors the equivalent logic in the +// next/link shim's `splitInternalUrl`. Cross-origin URLs pass through +// untouched so TanStack hands them to the browser as external. +const NEXT_PUBLIC_BASE_PATH = process.env.NEXT_PUBLIC_BASE_PATH ?? '' + +// Strip a leading basePath segment from a path-shape URL (no origin). +// Mirrors what Next's pages-router does for `asPath`. Used by both +// `useRouter().asPath` and the push/replace path-normalisation pipeline. +function stripBasePath(pathish: string): string { + if (!NEXT_PUBLIC_BASE_PATH) return pathish + if (pathish === NEXT_PUBLIC_BASE_PATH) return '/' + if (pathish.startsWith(`${NEXT_PUBLIC_BASE_PATH}/`)) { + return pathish.slice(NEXT_PUBLIC_BASE_PATH.length) + } + return pathish +} + +function toRelativeSameOrigin(url: string): string { + let pathname: string + let search = '' + let hash = '' + if (url.startsWith('http://') || url.startsWith('https://')) { + if (typeof window === 'undefined' || !window.location) return url + try { + const parsed = new URL(url) + if (parsed.origin !== window.location.origin) return url + pathname = parsed.pathname + search = parsed.search + hash = parsed.hash + } catch { + return url + } + } else { + // Relative input — split on the first `?` / `#` so we can strip a + // basePath segment from the pathname only. + const queryIdx = url.indexOf('?') + const hashIdx = url.indexOf('#') + const splitIdx = + [queryIdx, hashIdx].filter((i) => i >= 0).sort((a, b) => a - b)[0] ?? url.length + pathname = url.slice(0, splitIdx) + const rest = url.slice(splitIdx) + const qEnd = rest.indexOf('#') + if (rest.startsWith('?')) { + search = qEnd >= 0 ? rest.slice(0, qEnd) : rest + hash = qEnd >= 0 ? rest.slice(qEnd) : '' + } else if (rest.startsWith('#')) { + hash = rest + } + } + return `${stripBasePath(pathname)}${search}${hash}` +} + +// Next's pages-router passes a TransitionOptions bag as the 3rd arg to +// push/replace. We accept the shape but ignore every field — TanStack has +// no direct equivalent for any of them (shallow, locale, scroll, +// unstable_skipClientCache). Notably `shallow` is a no-op here, NOT a +// push-vs-replace signal: callers pass `push(url, as, { shallow: true })` +// expecting a normal history push (e.g. useUrlState, MonacoEditor). Whether +// a navigation replaces is decided solely by which method is called +// (push vs replace) via the internal `_replace` flag below. +type TransitionOptions = { + shallow?: boolean + locale?: string | false + scroll?: boolean + unstable_skipClientCache?: boolean +} + +type PrefetchOptions = { + priority?: boolean + locale?: string | false + unstable_skipClientCache?: boolean +} + +export function useRouter() { + const router = useTanStackRouter() + const location = useLocation() + const matches = useMatches() + const params = useParams({ strict: false }) + const search = useSearch({ strict: false }) + + return useMemo(() => { + const leafRouteId = matches[matches.length - 1]?.routeId ?? location.pathname + const pathPattern = toNextPathPattern(leafRouteId) + + // Both push and replace accept Next's (url, as?, options?) signature. + // `as` is the legacy alias path (mostly obsolete in modern Next; ignored + // here — the resolved `url` is what we navigate to). Returns + // Promise<boolean> matching Next; TanStack's navigate doesn't surface + // a success boolean so we always resolve to true. + const navigate = async ( + url: string | UrlObject, + _as?: string | UrlObject, + // `_replace` is an internal flag set by the `replace()` method below. + // It's intentionally not part of Next's public TransitionOptions. + options?: TransitionOptions & { _replace?: boolean } + ): Promise<boolean> => { + const to = toRelativeSameOrigin(resolveUrl(url)) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + await router.navigate({ to: to as any, replace: options?._replace }) + return true + } + + return { + // ---- state ---- + pathname: pathPattern, + // Next's pages-router exposes `route` and `pathname` as the same value + // — the route pattern with bracketed dynamic segments. Some studio + // code (e.g. AppLayout/BranchLink, AppLayout/ProjectDropdown) reads + // `router.route` specifically; without this it's `undefined` and + // downstream `.split('/')` calls crash. + route: pathPattern, + // Route params take precedence over search params of the same name, + // matching Next's pages-router req.query merge order. + query: { ...search, ...params }, + // Next's pages-router `asPath` is path + query + hash *without* the + // origin and *without* the configured `basePath` + // (https://nextjs.org/docs/pages/api-reference/functions/use-router). + // Studio code relies on the no-basePath shape — e.g. + // OrganizationSettingsLayout compares `currentPath === '/org/<slug>/ + // general'` for the side-nav active state, with section hrefs that + // never include `/dashboard`. Returning a basepath-prefixed value + // breaks every such strict-equality check. + asPath: stripBasePath(location.href), + // Mirror Next's pages-router contract for `basePath`: + // - no basePath configured → '' (empty string) + // - configured → '/dashboard' (leading slash, no trailing) + // + // TanStack stores the raw `basepath` option without normalising: + // `undefined` becomes '/' (its internal default), 'dashboard' stays + // 'dashboard', '/dashboard/' stays '/dashboard/'. Studio code then + // does `${router.basePath}/img/...` and trips on every non-Next + // shape ('/' → '//img/...' protocol-relative; 'dashboard' → + // 'dashboard/img/...' relative-to-current-path; '/dashboard/' → + // '/dashboard//img/...' double slash). + basePath: toNextBasePath(router.basepath), + // TanStack resolves params/search synchronously on render, so the + // pages-router "is the dynamic param ready yet?" flag is always + // true here. (In Next this can be false during the very first + // render of a dynamic page.) + isReady: true, + // No equivalent under TanStack — surface as static `false` so call + // sites that read these don't crash. Next-only features. + isFallback: false, + isPreview: false, + isLocaleDomain: false, + // i18n routing isn't wired through TanStack here. Return undefined + // for the active locale and an empty list for the rest — matches + // a Next app that has no i18n config. + locale: undefined as string | undefined, + locales: undefined as string[] | undefined, + defaultLocale: undefined as string | undefined, + domainLocales: undefined as Array<{ domain: string; defaultLocale: string }> | undefined, + + // ---- navigation ---- + push: (url: string | UrlObject, as?: string | UrlObject, options?: TransitionOptions) => + navigate(url, as, options), + replace: (url: string | UrlObject, as?: string | UrlObject, options?: TransitionOptions) => + navigate(url, as, { ...options, _replace: true }), + reload: () => { + if (typeof window !== 'undefined') window.location.reload() + }, + back: () => { + if (typeof window !== 'undefined') window.history.back() + }, + forward: () => { + if (typeof window !== 'undefined') window.history.forward() + }, + prefetch: async ( + url: string, + _asPath?: string, + _options?: PrefetchOptions + ): Promise<void> => { + try { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + await router.preloadRoute({ to: url as any }) + } catch { + // Next's prefetch is fire-and-forget; swallow resolution errors + // (e.g. unknown route) so callers don't have to guard. + } + }, + // Next-only escape hatch for popstate handling. Not wired up; accept + // and discard the callback so call sites compile and run without + // throwing. Callers that *rely* on this (none currently in studio) + // would need a real implementation. + beforePopState: (_cb: (state: unknown) => boolean) => {}, + + // ---- events ---- + events: getRouterEventsProxy(router), + } + }, [router, location.href, location.pathname, matches, params, search]) +} + +// Normalise an optional-catch-all route's params across both frameworks. +// +// Next's `[[...name]]` surfaces the trailing path as `query.name: string[]`, +// while TanStack's splat (`$`) surfaces it as `query._splat: string`. The +// shim can't rename `_splat` to the Next param name on its own — that name +// only exists in the Next page filename and never reaches the router — so +// the caller passes it. Returns the trailing path as a string[] plus the +// remaining query params (with the catch-all keys stripped) for building +// query strings. Shared by every migrated catch-all page so the logic lives +// in one place. +export function parseCatchAllRoute( + query: Record<string, string | string[] | undefined>, + paramName: string +): { + segments: string[] | undefined + queryParams: Record<string, string | string[] | undefined> +} { + const { [paramName]: raw, _splat, ...queryParams } = query + const segments = Array.isArray(raw) + ? raw + : typeof _splat === 'string' && _splat + ? _splat.split('/') + : undefined + return { segments, queryParams } +} + +// Module-scope singleton — Next exposes the same proxy via +// `import router from 'next/router'`. We have one consumer +// (Support/DiscordCTACard) that reads `router.basePath` at render time +// outside of a hook context, so we surface the env-derived basePath +// directly. Push/replace/etc. fall through to `window.location` to keep +// future module-scope navigations safe; nothing in studio uses them +// today. +const singletonBasePath = toNextBasePath( + // Read both the TanStack and Next env names — TanStack also reads + // VITE_BASE_URL but the studio config writes NEXT_PUBLIC_BASE_PATH. + process.env.NEXT_PUBLIC_BASE_PATH +) + +const singletonRouter = { + basePath: singletonBasePath, + pathname: '', + route: '', + query: {} as Record<string, string | string[] | undefined>, + asPath: '', + isReady: true, + isFallback: false, + isPreview: false, + isLocaleDomain: false, + push: async (url: string | UrlObject): Promise<boolean> => { + if (typeof window !== 'undefined') window.location.assign(resolveUrl(url)) + return true + }, + replace: async (url: string | UrlObject): Promise<boolean> => { + if (typeof window !== 'undefined') window.location.replace(resolveUrl(url)) + return true + }, + reload: () => { + if (typeof window !== 'undefined') window.location.reload() + }, + back: () => { + if (typeof window !== 'undefined') window.history.back() + }, + forward: () => { + if (typeof window !== 'undefined') window.history.forward() + }, + prefetch: async () => {}, + beforePopState: (_cb: (state: unknown) => boolean) => {}, + events: { + on: () => {}, + off: () => {}, + emit: () => {}, + }, +} + +// eslint-disable-next-line no-restricted-exports +export default singletonRouter diff --git a/apps/studio/compat/next/script.tsx b/apps/studio/compat/next/script.tsx new file mode 100644 index 00000000000..150211de310 --- /dev/null +++ b/apps/studio/compat/next/script.tsx @@ -0,0 +1,107 @@ +import { useEffect, useRef, type ComponentPropsWithoutRef, type ReactNode } from 'react' + +// Next/Script handles ordering, deduplication, and load callbacks for +// third-party scripts. Under Vite we have no orchestrator — render a +// `<script>` and approximate the callback contract. Studio doesn't +// currently mount any <Script>, but the surface is shimmed +// comprehensively so the migration doesn't catch anyone out later. + +type Strategy = 'beforeInteractive' | 'afterInteractive' | 'lazyOnload' | 'worker' + +interface ScriptProps extends Omit< + ComponentPropsWithoutRef<'script'>, + 'children' | 'onLoad' | 'onError' +> { + // Accepted-and-dropped in this shim — the browser handles network + // priority via the regular `<script>` element; we don't reorder. + strategy?: Strategy + // Children as the script body (inline scripts) are also accepted via + // dangerouslySetInnerHTML; Next allows either. We honour both. + children?: ReactNode + onLoad?: (e: Event) => void + onReady?: () => void + onError?: (e: Event | string) => void +} + +// eslint-disable-next-line no-restricted-exports +export default function Script({ + strategy: _strategy, + children, + dangerouslySetInnerHTML, + onLoad, + onError, + onReady, + src, + id, + ...rest +}: ScriptProps) { + const ref = useRef<HTMLScriptElement | null>(null) + const readyFiredRef = useRef(false) + + // onReady fires once the script has loaded (or immediately on mount + // if it's already been loaded by a previous instance with the same + // id). Approximate by firing once after mount when the element is + // present and either has no src (inline) or has finished loading. + useEffect(() => { + const node = ref.current + if (!node || !onReady || readyFiredRef.current) return + if (!src) { + // Inline script — body executed synchronously on mount. + readyFiredRef.current = true + onReady() + return + } + if (node.dataset.loaded === 'true') { + readyFiredRef.current = true + onReady() + } + }, [onReady, src]) + + const handleLoad = (e: Event | React.SyntheticEvent<HTMLScriptElement>) => { + const node = ref.current + if (node) node.dataset.loaded = 'true' + onLoad?.(e as Event) + if (onReady && !readyFiredRef.current) { + readyFiredRef.current = true + onReady() + } + } + + const handleError = (e: Event | React.SyntheticEvent<HTMLScriptElement>) => { + onError?.(e as Event) + } + + // For inline scripts, `children` is preferred over + // dangerouslySetInnerHTML when both are present (matches Next). + if (children !== undefined) { + return ( + <script + {...rest} + id={id} + ref={ref} + onLoad={handleLoad} + onError={handleError} + dangerouslySetInnerHTML={{ + __html: typeof children === 'string' ? children : '', + }} + /> + ) + } + + return ( + // This shim mirrors next/script for the TanStack build (where vite aliases + // `next/script` to this file via nextCompat). Call sites that opt for an + // explicit `async`/`defer` already pass it through via {...rest}; we don't + // force one here because Next's <Script> doesn't either at this layer. + // eslint-disable-next-line @next/next/no-sync-scripts + <script + {...rest} + id={id} + src={src} + ref={ref} + onLoad={handleLoad} + onError={handleError} + dangerouslySetInnerHTML={dangerouslySetInnerHTML} + /> + ) +} diff --git a/apps/studio/compat/next/server.ts b/apps/studio/compat/next/server.ts new file mode 100644 index 00000000000..a966c9b37d4 --- /dev/null +++ b/apps/studio/compat/next/server.ts @@ -0,0 +1,77 @@ +// Vite-side compat for `next/server`. Studio's middleware (proxy.ts) and +// a couple of App Router routes import from here. Most of this surface +// isn't actually exercised at runtime under TanStack/Vite — middleware +// doesn't run, and the App Router routes only call NextResponse.json — +// but the imports need to resolve to real values so the bundle compiles +// and consumers don't crash if they're invoked. + +type NextInit = ResponseInit & { + // Next attaches a `request` bag on `next()` to allow middleware to + // mutate request headers before forwarding. Accepted-and-ignored here. + request?: { headers?: HeadersInit } +} + +const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]) + +function buildResponse(body: BodyInit | null, init?: NextInit): Response { + // Strip the Next-only `request` field before handing off to the + // standard Response constructor — it'd be retained as a non-standard + // option and trip strict implementations. + if (init && 'request' in init) { + const { request: _request, ...rest } = init + return new Response(body, rest) + } + return new Response(body, init) +} + +export const NextResponse = { + json: (data: unknown, init?: NextInit) => { + // Response.json sets Content-Type for us; merge any caller headers. + return Response.json(data, init && 'request' in init ? { ...init, request: undefined } : init) + }, + + // Middleware uses `NextResponse.next()` to signal "continue without + // rewriting". Under TanStack we have no middleware runtime, so the + // returned Response is effectively a placeholder that mirrors the + // shape Next produces (empty 200). + next: (init?: NextInit): Response => buildResponse(null, init), + + redirect: (url: string | URL, init?: number | NextInit): Response => { + const status = typeof init === 'number' ? init : (init?.status ?? 307) + if (!REDIRECT_STATUSES.has(status)) { + throw new RangeError( + `[next/server compat] NextResponse.redirect: invalid status ${status}; expected one of ${[...REDIRECT_STATUSES].join(', ')}` + ) + } + const headers = new Headers(typeof init === 'object' ? init?.headers : undefined) + headers.set('Location', String(url)) + return new Response(null, { status, headers }) + }, + + // Used in middleware to rewrite an incoming request to a different + // path without changing the visible URL. Encoded by setting the + // `x-middleware-rewrite` header (matches Next's runtime contract). + rewrite: (destination: string | URL, init?: NextInit): Response => { + const headers = new Headers(init?.headers) + headers.set('x-middleware-rewrite', String(destination)) + return buildResponse(null, { ...init, headers }) + }, + + error: (): Response => new Response(null, { status: 500 }), +} + +// NextRequest extends Request with `nextUrl`, `cookies`, `geo`, and `ip`. +// None of our workspace source reads those fields at runtime — most +// imports are type-only. Aliasing the runtime value to the global +// Request constructor keeps value imports working (e.g. `import { +// NextRequest } from 'next/server'` followed by `(req: NextRequest) => …` +// type annotations under verbatimModuleSyntax / isolatedModules). +export const NextRequest = Request +export type NextRequest = Request + +// Stub the type-only exports Next ships so consumers importing them +// don't need to special-case the shim. +export type NextMiddleware = (request: Request) => Response | Promise<Response> | undefined | void +export type MiddlewareConfig = { + matcher?: string | string[] +}