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:
Alaister Young committed 2026-07-07 02:02:17 +08:00
1 parent 3db49418e0
commit 2d71129bd9
5 files changed
+193 -11

No files matched your search

+1 -3
View File
@@ -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')
})
})
+119
View File
@@ -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()
})
})
+56 -4
View File
@@ -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,
}
}
+1
View File
@@ -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
+16 -4
View File
@@ -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)}` })
},
})