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>
This commit is contained in:
authored and GitHub committed 2026-06-25 16:52:34 +08:00
1 parent 1059b726ce
commit 6946ec2b2d
13 files changed
+1969

No files matched your search

+138
View File
@@ -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<NextEventName, Mapping | undefined> = {
// `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<object, EventsProxy>()
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<NextEventName, Map<Handler, () => 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
}
+370
View File
@@ -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<unknown>
interface RouteCtx {
request: Request
params?: Record<string, string | undefined>
}
export function toWebHandler(handler: NextHandler) {
return async ({ request, params = {} }: RouteCtx): Promise<Response> => {
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<string, string | undefined>
): Promise<NextApiRequest> {
const url = new URL(request.url)
const method = request.method.toUpperCase()
const headers: Record<string, string | string[]> = {}
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<string, string> = {}
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<string, string | string[]> = {}
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<string, Set<(...args: unknown[]) => 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<Uint8Array> | 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<Uint8Array>({
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<string, unknown>
// `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<string, string> = {}
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<string, number | string | readonly string[]>,
maybeHeaders?: Record<string, number | string | readonly string[]>
) => unknown
;(res as unknown as { writeHead: WriteHead }).writeHead = (
code: number,
headersOrMessage?: string | Record<string, number | string | readonly string[]>,
maybeHeaders?: Record<string, number | string | readonly string[]>
) => {
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<string, unknown>).on = noopEE
;(res as unknown as Record<string, unknown>).once = noopEE
;(res as unknown as Record<string, unknown>).off = noopEE
;(res as unknown as Record<string, unknown>).removeListener = noopEE
;(res as unknown as Record<string, unknown>).removeAllListeners = noopEE
;(res as unknown as Record<string, unknown>).emit = () => false
;(res as unknown as Record<string, unknown>).addListener = noopEE
;(res as unknown as Record<string, unknown>).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 }
}
+8
View File
@@ -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'
+58
View File
@@ -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<P> = () => Promise<ComponentType<P>> | Promise<{ default: ComponentType<P> }>
type DynamicComponent<P> = ComponentType<P> & {
// 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<void>
}
function isDefaultExport<P>(
value: ComponentType<P> | { default: ComponentType<P> }
): value is { default: ComponentType<P> } {
return typeof value === 'object' && value !== null && 'default' in value
}
// eslint-disable-next-line no-restricted-exports
export default function dynamic<P extends object = {}>(
loader: Loader<P>,
options: DynamicOptions = {}
): DynamicComponent<P> {
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<P> }> | null = null
const load = () => {
if (cached) return cached
cached = loader().then((mod) => (isDefaultExport<P>(mod) ? mod : { default: mod }))
return cached
}
const Lazy = lazy(load)
function DynamicComponent(props: P) {
if (ssr === false && typeof window === 'undefined') return null
return (
<Suspense fallback={loading ? loading() : null}>
<Lazy {...props} />
</Suspense>
)
}
;(DynamicComponent as DynamicComponent<P>).preload = () => load().then(() => undefined)
return DynamicComponent as DynamicComponent<P>
}
+31
View File
@@ -0,0 +1,31 @@
import type { ReactNode } from 'react'
// Next/Head's job is to inject children into the document `<head>` and
// deduplicate them by `key` prop. React 19 ships native "document
// metadata" hoisting: any `<title>`, `<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}</>
}
+159
View File
@@ -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
+163
View File
@@ -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
+252
View File
@@ -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
+140
View File
@@ -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)
}
+60
View File
@@ -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')
})
})
+406
View File
@@ -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
+107
View File
@@ -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}
/>
)
}
+77
View File
@@ -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[]
}