Files
supabase/apps/studio/routes/index.tsx
Alaister YoungandAlaister Young 0833c586ac fix(studio): use redirect({ to }) for internal TanStack redirects (#48469)
Hover-preloading any link that points at a redirecting path (e.g. the
org invite "Decline" link to `/projects`) hung the tab under the
TanStack runtime: `redirect({ href })` is treated as an opaque external
target, and the router's preload retry ignores `href` when rebuilding
the location, so it re-runs the same `beforeLoad`, throws the same
redirect, and recurses forever (TanStack/router#7141 — internal targets
must use `to`).

**Changed:**

- `routes/__root.tsx` — the redirect-table `beforeLoad` splits the
destination with `splitInternalUrl()` and throws `redirect({ to, search,
hash, statusCode })` instead of `redirect({ href })`. `to` is
basepath-relative, so the manual `BASE_PATH` prefix goes away too.
- `routes/index.tsx` — same `href` → `to`/`search`/`hash` switch for the
`/` redirects; the "targets aren't in the routeTree yet" comment was
stale (all three destinations resolve to real routes now).
- `OrganizationInvite.tsx` — "Decline" links straight to
`/organizations`, skipping the `/projects` redirect hop entirely.

## To test

- On the TanStack runtime, hover (don't click) a link to a redirecting
path — e.g. the auth overview's "Go to observability" link
(`/project/:ref/reports/auth`) or the 404 page's `/projects` link. The
page must stay responsive (this hung before).
- `/projects` → `/organizations` (307), `/project/:ref/database` →
`/database/tables` (308), `/` → `/org`.
- Query/hash semantics still hold: `/?next=new-project&projectName=x` →
`/new/new-project?projectName=x`;
`/project/:ref/database/wrappers?foo=bar` →
`/integrations?category=wrapper&foo=bar`; `/org/:slug/invoices#other` →
`/org/:slug/billing#invoices`.
- Chained redirects stay bounded: `/project/:ref/database/linter` →
`/advisors/security` in two hops.

All of the above verified locally via Playwright against the TanStack
dev server; `redirects.shared` / `internal-url` / compat-router unit
tests pass.

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

- **Bug Fixes**
- Fixed the invitation “Decline” action to route users to the
Organizations page instead of the Projects page.
- Improved Studio redirect/navigation handling by correctly preserving
URL search parameters and hash fragments and routing to the intended
destination.
- **Tests**
- Updated Organization Invite test expectations to reflect the corrected
“Decline” link destination.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Alaister Young <10985857+alaister@users.noreply.github.com>
2026-07-30 12:42:47 +08:00

45 lines
1.9 KiB
TypeScript

import { createFileRoute, redirect, type AnyRouter } from '@tanstack/react-router'
import { IS_PLATFORM } from '@/lib/constants'
// `/` is never rendered — it always redirects. Mirrors the Next.js
// `redirects()` rules in next.config.ts: platform sends users to `/org`
// (or `/new/new-project` when deep-linked with `?next=new-project`),
// self-hosted sends them straight to `/project/default`.
export const Route = createFileRoute('/')({
validateSearch: (search: Record<string, unknown>) => ({
next: typeof search.next === 'string' ? search.next : undefined,
}),
beforeLoad: ({ search, location }) => {
// Next's redirects() carries the incoming query and hash through to the
// destination — deep links like `/?next=new-project&projectName=x` must
// keep `projectName`. Only the consumed `next` param is dropped (and only
// when it matched); everything else passes through.
const carried = (shouldConsumeNext: boolean) => {
const carriedSearch: Record<string, unknown> = { ...location.search }
if (shouldConsumeNext) delete carriedSearch.next
return carriedSearch
}
// `to`, never `href`: preloading a Link whose beforeLoad throws
// `redirect({ href })` recurses forever
// (https://github.com/TanStack/router/issues/7141). The `<AnyRouter,
// string>` type arguments opt out of the registered route tree's strict
// typing so the carried free-form search record is accepted.
if (IS_PLATFORM) {
if (search.next === 'new-project') {
throw redirect<AnyRouter, string>({
to: '/new/new-project',
search: carried(true),
hash: location.hash,
})
}
throw redirect<AnyRouter, string>({ to: '/org', search: carried(false), hash: location.hash })
}
throw redirect<AnyRouter, string>({
to: '/project/default',
search: carried(false),
hash: location.hash,
})
},
})