mirror of
https://github.com/supabase/supabase.git
synced 2026-10-08 19:05:06 +03:00
fix(studio): preserve query params and hash across redirects
Next's redirects() carries the incoming query string and hash through to the redirect destination; the TanStack beforeLoad redirects dropped them — deep links like /project/x/sql/quickstarts?template=... lost the template on the rewrite to /sql/examples, and /?next=new-project&projectName=foo lost projectName. matchRedirect (redirects.shared.ts) now merges the incoming query and hash onto the destination via a new pure preserveQueryAndHash helper: params already set on the destination win, keys the rule matched on (`has`) are consumed, and a destination hash (e.g. billing#invoices) wins over the incoming one. The `/` index route redirect does the same, consuming `next` only when it matched. The shared module stays runtime-agnostic; the config consumers (next.config.ts / vercel.ts) only import the tables and are unaffected. Claude-Session: https://claude.ai/code/session_01MLH8WVg8zBJnmZTYTV3ooo
This commit is contained in:
1 parent
3db49418e0
commit
2d71129bd9
5 files changed
+193
-11
No files matched your search
@@ -82,9 +82,7 @@ describe('resolveSearchOrHashOnlyTarget (next/router compat shim)', () => {
|
||||
})
|
||||
|
||||
it('leaves targets with a pathname untouched', () => {
|
||||
expect(resolveSearchOrHashOnlyTarget('/project/abc?x=1', '/elsewhere')).toBe(
|
||||
'/project/abc?x=1'
|
||||
)
|
||||
expect(resolveSearchOrHashOnlyTarget('/project/abc?x=1', '/elsewhere')).toBe('/project/abc?x=1')
|
||||
expect(resolveSearchOrHashOnlyTarget('/project/abc', '/elsewhere')).toBe('/project/abc')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,119 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { matchRedirect, preserveQueryAndHash } from './redirects.shared'
|
||||
|
||||
describe('preserveQueryAndHash', () => {
|
||||
it('carries incoming query params onto the destination', () => {
|
||||
expect(preserveQueryAndHash('/org', { foo: '1', bar: 'x' })).toBe('/org?foo=1&bar=x')
|
||||
})
|
||||
|
||||
it('returns the destination untouched when there is nothing to carry', () => {
|
||||
expect(preserveQueryAndHash('/org', {})).toBe('/org')
|
||||
expect(preserveQueryAndHash('/org', new URLSearchParams())).toBe('/org')
|
||||
})
|
||||
|
||||
it('drops params consumed by the matched rule', () => {
|
||||
expect(
|
||||
preserveQueryAndHash(
|
||||
'/new/new-project',
|
||||
{ next: 'new-project', a: '1' },
|
||||
{
|
||||
consumedKeys: ['next'],
|
||||
}
|
||||
)
|
||||
).toBe('/new/new-project?a=1')
|
||||
})
|
||||
|
||||
it("lets the destination's own params win on conflict", () => {
|
||||
expect(
|
||||
preserveQueryAndHash('/org/_/billing?panel=subscriptionPlan', { panel: 'other', x: '1' })
|
||||
).toBe('/org/_/billing?panel=subscriptionPlan&x=1')
|
||||
})
|
||||
|
||||
it('preserves repeated keys and array values', () => {
|
||||
expect(preserveQueryAndHash('/dest', new URLSearchParams('f=a&f=b'))).toBe('/dest?f=a&f=b')
|
||||
expect(preserveQueryAndHash('/dest', { f: ['a', 'b'] })).toBe('/dest?f=a&f=b')
|
||||
})
|
||||
|
||||
it('carries the incoming hash', () => {
|
||||
expect(preserveQueryAndHash('/dest', { a: '1' }, { hash: 'section' })).toBe('/dest?a=1#section')
|
||||
expect(preserveQueryAndHash('/dest', {}, { hash: 'section' })).toBe('/dest#section')
|
||||
})
|
||||
|
||||
it("lets the destination's own hash win over the incoming one", () => {
|
||||
expect(preserveQueryAndHash('/org/slug/billing#invoices', { a: '1' }, { hash: 'other' })).toBe(
|
||||
'/org/slug/billing?a=1#invoices'
|
||||
)
|
||||
})
|
||||
|
||||
it('skips undefined values in a record search', () => {
|
||||
expect(preserveQueryAndHash('/dest', { a: undefined, b: '1' })).toBe('/dest?b=1')
|
||||
})
|
||||
})
|
||||
|
||||
describe('matchRedirect query/hash preservation', () => {
|
||||
it('carries the incoming query and hash through a plain rule', () => {
|
||||
expect(
|
||||
matchRedirect({
|
||||
pathname: '/project/abc/sql/quickstarts',
|
||||
search: { template: 'countries', flag: 'true' },
|
||||
isPlatform: true,
|
||||
hash: 'top',
|
||||
})
|
||||
).toEqual({
|
||||
destination: '/project/abc/sql/examples?template=countries&flag=true#top',
|
||||
permanent: true,
|
||||
})
|
||||
})
|
||||
|
||||
it('consumes `has` query keys but keeps the rest', () => {
|
||||
expect(
|
||||
matchRedirect({
|
||||
pathname: '/',
|
||||
search: { next: 'new-project', projectName: 'foo' },
|
||||
isPlatform: true,
|
||||
})
|
||||
).toEqual({ destination: '/new/new-project?projectName=foo', permanent: false })
|
||||
})
|
||||
|
||||
it("keeps the destination's own params when the incoming query repeats them", () => {
|
||||
expect(
|
||||
matchRedirect({
|
||||
pathname: '/project/abc/settings/billing/subscription',
|
||||
search: { panel: 'pitr', source: 'email' },
|
||||
isPlatform: true,
|
||||
})
|
||||
).toEqual({
|
||||
destination: '/project/abc/settings/addons?panel=pitr&source=email',
|
||||
permanent: true,
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps a destination hash (e.g. billing#invoices) over the incoming hash', () => {
|
||||
expect(
|
||||
matchRedirect({
|
||||
pathname: '/org/my-org/invoices',
|
||||
search: {},
|
||||
isPlatform: true,
|
||||
hash: 'ignored',
|
||||
})
|
||||
).toEqual({ destination: '/org/my-org/billing#invoices', permanent: true })
|
||||
})
|
||||
|
||||
it('leaves plain redirects without query or hash untouched', () => {
|
||||
expect(matchRedirect({ pathname: '/', search: {}, isPlatform: true })).toEqual({
|
||||
destination: '/org',
|
||||
permanent: false,
|
||||
})
|
||||
expect(matchRedirect({ pathname: '/', search: {}, isPlatform: false })).toEqual({
|
||||
destination: '/project/default',
|
||||
permanent: false,
|
||||
})
|
||||
})
|
||||
|
||||
it('still returns null for non-matching paths', () => {
|
||||
expect(
|
||||
matchRedirect({ pathname: '/project/abc/editor', search: { a: '1' }, isPlatform: true })
|
||||
).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -393,23 +393,70 @@ function hasQueryMatches(
|
||||
return has.every((h) => h.type === 'query' && get(h.key) === h.value)
|
||||
}
|
||||
|
||||
// Merge the incoming request's query params (and hash) onto a redirect
|
||||
// destination, mirroring Next.js `redirects()` semantics: the query string is
|
||||
// carried through to the destination; params the destination already sets
|
||||
// win; params consumed by the matched rule (its `has` query keys) are
|
||||
// dropped; a hash on the destination wins over the incoming one. `hash` is
|
||||
// the bare fragment without the leading `#`. Runtime-agnostic (also usable
|
||||
// from next.config / Vercel edge). Exported for unit tests.
|
||||
export function preserveQueryAndHash(
|
||||
destination: string,
|
||||
search: URLSearchParams | Record<string, string | string[] | undefined>,
|
||||
options: { consumedKeys?: string[]; hash?: string } = {}
|
||||
): string {
|
||||
const { consumedKeys = [], hash = '' } = options
|
||||
|
||||
const hashIdx = destination.indexOf('#')
|
||||
const destHash = hashIdx >= 0 ? destination.slice(hashIdx + 1) : ''
|
||||
const beforeHash = hashIdx >= 0 ? destination.slice(0, hashIdx) : destination
|
||||
const queryIdx = beforeHash.indexOf('?')
|
||||
const destPath = queryIdx >= 0 ? beforeHash.slice(0, queryIdx) : beforeHash
|
||||
const params = new URLSearchParams(queryIdx >= 0 ? beforeHash.slice(queryIdx + 1) : '')
|
||||
|
||||
const incoming =
|
||||
search instanceof URLSearchParams
|
||||
? search
|
||||
: new URLSearchParams(
|
||||
Object.entries(search).flatMap(([key, value]): Array<[string, string]> => {
|
||||
if (value === undefined || value === null) return []
|
||||
if (Array.isArray(value)) return value.map((v) => [key, v])
|
||||
return [[key, value]]
|
||||
})
|
||||
)
|
||||
for (const key of new Set(incoming.keys())) {
|
||||
if (consumedKeys.includes(key)) continue
|
||||
if (params.has(key)) continue // destination's own value wins
|
||||
for (const value of incoming.getAll(key)) params.append(key, value)
|
||||
}
|
||||
|
||||
const queryStr = params.toString()
|
||||
const finalHash = destHash || hash
|
||||
return `${destPath}${queryStr ? `?${queryStr}` : ''}${finalHash ? `#${finalHash}` : ''}`
|
||||
}
|
||||
|
||||
export function matchRedirect(input: {
|
||||
pathname: string
|
||||
search: URLSearchParams | Record<string, string | string[] | undefined>
|
||||
isPlatform: boolean
|
||||
maintenanceMode?: boolean
|
||||
// Bare fragment without the leading `#` (TanStack's `location.hash` shape).
|
||||
hash?: string
|
||||
}): RedirectMatch | null {
|
||||
const { pathname, search, isPlatform, maintenanceMode = false } = input
|
||||
const { pathname, search, isPlatform, maintenanceMode = false, hash } = input
|
||||
|
||||
// Maintenance mode handled inline — the maintenance-on rule uses a
|
||||
// negative-lookahead regex source that the segment matcher above can't
|
||||
// parse. Cheap to special-case here.
|
||||
if (maintenanceMode) {
|
||||
if (!pathname.startsWith('/maintenance') && !pathname.startsWith('/img')) {
|
||||
return { destination: '/maintenance', permanent: false }
|
||||
return {
|
||||
destination: preserveQueryAndHash('/maintenance', search, { hash }),
|
||||
permanent: false,
|
||||
}
|
||||
}
|
||||
} else if (pathname === '/maintenance') {
|
||||
return { destination: '/', permanent: false }
|
||||
return { destination: preserveQueryAndHash('/', search, { hash }), permanent: false }
|
||||
}
|
||||
|
||||
const ordered = [
|
||||
@@ -421,7 +468,12 @@ export function matchRedirect(input: {
|
||||
if (!params) continue
|
||||
if (!hasQueryMatches(rule.has, search)) continue
|
||||
return {
|
||||
destination: substituteDestination(rule.destination, params),
|
||||
// Next.js carries the incoming query and hash through to the
|
||||
// destination; keys the rule matched on (`has`) are consumed.
|
||||
destination: preserveQueryAndHash(substituteDestination(rule.destination, params), search, {
|
||||
consumedKeys: rule.has?.map((h) => h.key) ?? [],
|
||||
hash,
|
||||
}),
|
||||
permanent: rule.permanent,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -285,6 +285,7 @@ export const Route = createRootRouteWithContext<RouterContext>()({
|
||||
pathname: location.pathname,
|
||||
search: location.search as Record<string, string | string[] | undefined>,
|
||||
isPlatform: IS_PLATFORM,
|
||||
hash: location.hash,
|
||||
})
|
||||
if (!match) return
|
||||
const href = BASE_PATH ? `${BASE_PATH}${match.destination}` : match.destination
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { createFileRoute, redirect } from '@tanstack/react-router'
|
||||
|
||||
import { IS_PLATFORM } from '@/lib/constants'
|
||||
import { stringifySearch } from '@/lib/router-search-params'
|
||||
|
||||
// `/` is never rendered — it always redirects. Mirrors the Next.js
|
||||
// `redirects()` rules in next.config.ts: platform sends users to `/org`
|
||||
@@ -10,15 +11,26 @@ export const Route = createFileRoute('/')({
|
||||
validateSearch: (search: Record<string, unknown>) => ({
|
||||
next: typeof search.next === 'string' ? search.next : undefined,
|
||||
}),
|
||||
beforeLoad: ({ search }) => {
|
||||
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 suffix = (consumeNext: boolean) => {
|
||||
const carried = { ...location.search } as Record<string, unknown>
|
||||
if (consumeNext) delete carried.next
|
||||
return `${stringifySearch(carried)}${location.hash ? `#${location.hash}` : ''}`
|
||||
}
|
||||
// `href` instead of `to` because these targets aren't in the TanStack
|
||||
// routeTree yet — they're still on the Next.js pages side during the
|
||||
// migration. Swap to `to` once `/org`, `/new/new-project`, and
|
||||
// `/project/default` are migrated.
|
||||
if (IS_PLATFORM) {
|
||||
if (search.next === 'new-project') throw redirect({ href: '/new/new-project' })
|
||||
throw redirect({ href: '/org' })
|
||||
if (search.next === 'new-project') {
|
||||
throw redirect({ href: `/new/new-project${suffix(true)}` })
|
||||
}
|
||||
throw redirect({ href: `/org${suffix(false)}` })
|
||||
}
|
||||
throw redirect({ href: '/project/default' })
|
||||
throw redirect({ href: `/project/default${suffix(false)}` })
|
||||
},
|
||||
})
|
||||
Reference in new issue
Block a user