mirror of
https://github.com/supabase/supabase.git
synced 2026-10-09 19:35:06 +03:00
**Stack 2/6** of the TanStack Start migration (#46424). Stacked on **#47107** (S1) — review that first; this PR's diff is just the compat shims. > [!NOTE] > Purely additive. Next never imports these files — under TanStack they're wired in via Vite aliases (`next/*` → `@/compat/next/*`). No routes consume them yet (that begins in stack 3). ## What's in this PR `apps/studio/compat/next/*` — drop-in shims so the existing pages-router code runs unchanged under TanStack Start: - `link`, `router`, `navigation`, `head`, `image`, `legacy/image`, `script`, `dynamic`, `server`, `_router-events` — React/runtime shims over `@tanstack/react-router`. - `api.ts` — `toWebHandler`, which adapts a pages-router API handler `(req, res)` into a TanStack server-route Web `fetch` handler. ## Verification On top of S1: `studio` typecheck ✓, lint (0 errors) ✓. Next build is unaffected (nothing imports these under tsc). <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Added broad Next.js compatibility support for routing, links, dynamic imports, images, scripts, head metadata, navigation hooks, server responses, and API handlers. * Improved handling of redirects, pathname/search params, base paths, and event callbacks for smoother app behavior. * **Tests** * Added coverage for URL resolution and dynamic route interpolation to verify Next-style routing behavior. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: Alaister Young <10985857+alaister@users.noreply.github.com> Co-authored-by: Ivan Vasilov <vasilov.ivan@gmail.com>
This commit is contained in:
13 files changed
+1969
No files matched your search
@@ -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
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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'
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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}</>
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
@@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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[]
|
||||
}
|
||||
Reference in new issue
Block a user