diff --git a/apps/studio/compat/next/router.test.ts b/apps/studio/compat/next/router.test.ts index 9038ac4aa50..a1527f8c0f7 100644 --- a/apps/studio/compat/next/router.test.ts +++ b/apps/studio/compat/next/router.test.ts @@ -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') }) }) diff --git a/apps/studio/redirects.shared.test.ts b/apps/studio/redirects.shared.test.ts new file mode 100644 index 00000000000..6136ae80955 --- /dev/null +++ b/apps/studio/redirects.shared.test.ts @@ -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() + }) +}) diff --git a/apps/studio/redirects.shared.ts b/apps/studio/redirects.shared.ts index 5576a0af821..9a22b174a1b 100644 --- a/apps/studio/redirects.shared.ts +++ b/apps/studio/redirects.shared.ts @@ -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, + 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 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, } } diff --git a/apps/studio/routes/__root.tsx b/apps/studio/routes/__root.tsx index fd9334f9f17..1b4cb26762c 100644 --- a/apps/studio/routes/__root.tsx +++ b/apps/studio/routes/__root.tsx @@ -285,6 +285,7 @@ export const Route = createRootRouteWithContext()({ pathname: location.pathname, search: location.search as Record, isPlatform: IS_PLATFORM, + hash: location.hash, }) if (!match) return const href = BASE_PATH ? `${BASE_PATH}${match.destination}` : match.destination diff --git a/apps/studio/routes/index.tsx b/apps/studio/routes/index.tsx index 9eec8c7acce..56ac764338e 100644 --- a/apps/studio/routes/index.tsx +++ b/apps/studio/routes/index.tsx @@ -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) => ({ 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 + 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)}` }) }, })