Files
supabase/apps/studio/compat/next/_router-events.ts
6946ec2b2d 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>
2026-06-25 16:52:34 +08:00

139 lines
4.5 KiB
TypeScript

// 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
}