Files
supabase/apps/studio/router.tsx
Alaister Young 58621818d0 feat(studio): switch TanStack skew protection to ?dpl= query params (#48008)
Switches the TanStack build's Vercel skew protection from the `__vdpl`
session cookie to `?dpl=<deployment-id>` query params baked into asset
URLs at build time. Assets stay pinned to the deployment that built
them, while document navigations and API fetches always reach the latest
deployment (with the cookie, a session stayed fully pinned — including
reloads — until the tab closed).

**Removed:**
- `pinDeploymentForSession` (the `__vdpl` cookie) from `router.tsx`,
plus the cookie clearing in the refresh toast and the
`vite:preloadError` backstop
- `credentials: 'omit'` on the deployment-commit check — its only
purpose was escaping the cookie pin, and API fetches are now inherently
unpinned

**Added:**
- `skewProtectionDpl` plugin + `experimental.renderBuiltUrl` in
`vite.config.ts`, active only when `VERCEL_SKEW_PROTECTION_ENABLED=1`.
Full coverage needs three mechanisms (Vite has no single hook for this —
see
[vitejs/vite#13834](https://github.com/vitejs/vite/discussions/13834#discussioncomment-7469745)):
1. `renderBuiltUrl` — CSS `url()`s, images, workers, and
`__vite__mapDeps` preload lists
2. a `generateBundle` (`order: 'post'`) rewrite of chunk-to-chunk
`import`/`from` specifiers, which Rolldown emits as bare relative paths
that `renderBuiltUrl` never sees — with sourcemaps recombined per chunk
(`magic-string` + `@jridgewell/remapping` devDeps) so Sentry columns
stay exact
3. a post-`buildApp` patch of the prerendered `_shell.html`
(script/preload tags + embedded router manifest come from TanStack, not
Vite's asset pipeline); without it the entry graph double-downloads
because preload and import URLs differ

## To test

- Built with fake `VERCEL_SKEW_PROTECTION_ENABLED=1
VERCEL_DEPLOYMENT_ID=dpl_TESTPIN123abc`: every chunk import specifier
(static + dynamic), `__vite__mapDeps` entry, CSS font URL, and
`_shell.html` asset URL carries `?dpl=`; zero unpinned `/assets/`
references remain
- Sourcemap accuracy verified by tracing a minified position through the
recombined map: resolves to the exact original file/line/column
(`use-check-latest-deploy.tsx:62:8`)
- Built without the env vars: output contains no `dpl=` anywhere
(self-hosted/e2e builds unaffected)
- `smoke:tanstack` passes on both builds; `tsc --noEmit` and eslint
clean
- On the preview: load the dashboard, check Network tab — chunk/CSS
requests should carry `?dpl=` matching the deployment; hard reload
should hit the latest deployment (no pin on document requests)

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **Improvements**
* Improved deployment consistency by pinning generated asset and module
URLs to the current deployment (using `?dpl=`).
* Simplified refresh and preload-error recovery to reduce reload-loop
risk.
* Kept API request behavior aligned with the updated deployment
routing/pinning approach.
  * Preserved correct routing across deployment configurations.
* **Developer Experience**
* Added build-time tooling to rewrite pinned URLs for client assets
while maintaining source map integrity.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Alaister Young <10985857+alaister@users.noreply.github.com>
2026-07-16 23:46:40 +08:00

93 lines
3.6 KiB
TypeScript

import type { QueryClient } from '@tanstack/react-query'
import { createRouter } from '@tanstack/react-router'
import { setupRouterSsrQueryIntegration } from '@tanstack/react-router-ssr-query'
import { routeTree } from './routeTree.gen'
import { initSentryTanStackClient } from './sentry.tanstack'
import { getQueryClient } from '@/data/query-client'
import { parseSearch, stringifySearch } from '@/lib/router-search-params'
export interface RouterContext {
queryClient: QueryClient
}
// Skew protection: every asset URL in the built bundle carries a
// `?dpl=<deployment-id>` pin (see skewProtectionDpl in vite.config.ts), so a
// long-lived dashboard session keeps loading lazily-imported chunks from the
// deployment it was built by instead of 404ing after a redeploy. Backstop: if
// a lazily-loaded chunk still 404s — most likely the pinned deployment aged
// out of Skew Protection's Maximum Age, so Vercel refuses the `?dpl=` request
// — Vite emits `vite:preloadError`. Reload so we land on the latest
// deployment, whose asset URLs pin to itself. A short time-window guard
// prevents a reload loop if the latest deployment is itself broken.
function registerChunkErrorBackstop() {
if (typeof window === 'undefined') return
window.addEventListener('vite:preloadError', (event) => {
const KEY = 'studio:chunk-error-reload-at'
let last = 0
try {
last = Number(sessionStorage.getItem(KEY) || 0)
} catch {
// sessionStorage unavailable — fall through and attempt a reload anyway.
}
// Reloaded very recently → likely a loop; let Vite surface the error.
if (Date.now() - last < 10_000) return
event.preventDefault()
try {
sessionStorage.setItem(KEY, String(Date.now()))
} catch {
// ignore — worst case we lose loop protection for this reload.
}
window.location.reload()
})
}
function getContext(): RouterContext {
return {
queryClient: getQueryClient(),
}
}
export function getRouter() {
registerChunkErrorBackstop()
const context = getContext()
const router = createRouter({
routeTree,
context,
scrollRestoration: true,
defaultPreload: 'intent',
// Next-style search params (plain strings, repeated keys → arrays)
// instead of TanStack's JSON defaults, which coerce "2"→2/"true"→true
// and JSON-quote strings on write. The whole app — including the
// next/router compat shim and nuqs — expects the Next semantics.
parseSearch,
stringifySearch,
// Inlined via Vite's `define` at build time; stays undefined (= app at `/`)
// unless NEXT_PUBLIC_BASE_PATH is set. Must agree with Vite `base`
basepath: process.env.NEXT_PUBLIC_BASE_PATH || undefined,
})
// Sentry: nothing loads Next's convention files (instrumentation-client.ts)
// under TanStack Start, so init happens here — the earliest point with
// access to the router instance, which the tracing integration needs.
// No-op on the server and when no DSN is configured (see module).
initSentryTanStackClient(router)
// @tanstack/react-router-ssr-query@1.166.12 pulls in @tanstack/query-core@5.100
// as a peer, but our app pins react-query to 5.83. The QueryClient class is
// structurally identical between the two, but TS treats them as nominally
// distinct types because each version has its own `#private` field.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
setupRouterSsrQueryIntegration({ router, queryClient: context.queryClient as any })
return router
}
declare module '@tanstack/react-router' {
interface Register {
router: ReturnType<typeof getRouter>
}
}