fix(studio): stop ?-only navigations injecting a trailing slash

TanStack resolves a `?`-only relative `to` by appending it to the current
path, so any navigation that only changes the query string landed on
`<path>/?<query>` — e.g. picking a preset on the advisors page produced
`/advisors/security/?preset=...` and changing an auth provider produced
`/auth/providers/?provider=...`. Two producers fixed:

- The next/router compat shim's push/replace: a resolved `?`/`#`-only
  target is now prefixed with the current (basepath-stripped) pathname.
- nuqs writes (useQueryState/useQueryStates across ~123 files): the stock
  `nuqs/adapters/tanstack-router` adapter navigates with
  `to: renderQueryString(search) || '.'`. Replaced with a custom adapter
  (via nuqs's unstable_createAdapterProvider) that navigates to
  `pathname + renderQueryString(search)`, preserving replace/push, scroll,
  and hash behavior of the stock adapter.

Claude-Session: https://claude.ai/code/session_01MLH8WVg8zBJnmZTYTV3ooo
This commit is contained in:
Alaister Young committed 2026-07-07 02:32:53 +08:00
1 parent aa42906e0b
commit eb8660925e
5 files changed
+152 -3

No files matched your search

+31 -1
View File
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest'
import { resolveUrl } from './router'
import { resolveSearchOrHashOnlyTarget, resolveUrl } from './router'
describe('resolveUrl (next/router compat shim)', () => {
it('returns string URLs untouched', () => {
@@ -58,3 +58,33 @@ describe('resolveUrl (next/router compat shim)', () => {
).toBe('/project/x?a=1')
})
})
describe('resolveSearchOrHashOnlyTarget (next/router compat shim)', () => {
it('prefixes the current pathname on a ?-only target', () => {
// The regression we fixed: `push({ query })` with no pathname produced a
// relative `?...` that TanStack resolved by appending — landing on
// `/advisors/security/?preset=...` (trailing slash injected).
expect(resolveSearchOrHashOnlyTarget('?preset=x', '/advisors/security')).toBe(
'/advisors/security?preset=x'
)
})
it('prefixes the current pathname on a #-only target', () => {
expect(resolveSearchOrHashOnlyTarget('#invoices', '/org/slug/billing')).toBe(
'/org/slug/billing#invoices'
)
})
it('strips a trailing slash from the current pathname, but keeps the root "/"', () => {
expect(resolveSearchOrHashOnlyTarget('?a=1', '/auth/providers/')).toBe('/auth/providers?a=1')
expect(resolveSearchOrHashOnlyTarget('?a=1', '/')).toBe('/?a=1')
expect(resolveSearchOrHashOnlyTarget('?a=1', '')).toBe('/?a=1')
})
it('leaves targets with a pathname untouched', () => {
expect(resolveSearchOrHashOnlyTarget('/project/abc?x=1', '/elsewhere')).toBe(
'/project/abc?x=1'
)
expect(resolveSearchOrHashOnlyTarget('/project/abc', '/elsewhere')).toBe('/project/abc')
})
})
+20 -1
View File
@@ -107,6 +107,20 @@ function interpolatePathname(
return { pathname: interpolated, query: rest }
}
// TanStack resolves a `?`- or `#`-only relative `to` by *appending* it to the
// current path, injecting a trailing slash: navigating to `?preset=x` from
// `/advisors/security` lands on `/advisors/security/?preset=x`. Next resolved
// these against the current pathname. Prefix it explicitly (trailing slash
// stripped; root stays `/`) so `push({ query })` with no pathname stays on
// the exact current path.
// Exported for unit tests (see router.test.ts) — not part of the Next surface.
export function resolveSearchOrHashOnlyTarget(to: string, currentPathname: string): string {
if (!to.startsWith('?') && !to.startsWith('#')) return to
let base = currentPathname || '/'
if (base.length > 1 && base.endsWith('/')) base = base.slice(0, -1)
return `${base}${to}`
}
// Exported for unit tests (see router.test.ts) — not part of the Next surface.
export function resolveUrl(url: string | UrlObject): string {
if (typeof url === 'string') return url
@@ -231,7 +245,12 @@ export function useRouter() {
// It's intentionally not part of Next's public TransitionOptions.
options?: TransitionOptions & { _replace?: boolean }
): Promise<boolean> => {
const to = toRelativeSameOrigin(resolveUrl(url))
// `location.pathname` is already basepath-stripped by TanStack, so the
// prefixed target stays basepath-relative like every other `to`.
const to = resolveSearchOrHashOnlyTarget(
toRelativeSameOrigin(resolveUrl(url)),
location.pathname
)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
await router.navigate({ to: to as any, replace: options?._replace })
return true
@@ -0,0 +1,25 @@
import { describe, expect, it } from 'vitest'
import { buildSearchUpdateTarget } from './nuqs-tanstack-adapter'
describe('buildSearchUpdateTarget (custom nuqs adapter)', () => {
it('joins the current pathname and a rendered query string', () => {
// The regression we fixed: the stock tanstack-router adapter navigated to
// a relative `?provider=...`, which TanStack resolved by appending —
// landing on `/auth/providers/?provider=...` (trailing slash injected).
expect(buildSearchUpdateTarget('/auth/providers', '?provider=apple')).toBe(
'/auth/providers?provider=apple'
)
})
it('returns just the pathname when every param is cleared', () => {
expect(buildSearchUpdateTarget('/auth/providers', '')).toBe('/auth/providers')
})
it('strips a trailing slash from the pathname, but keeps the root "/"', () => {
expect(buildSearchUpdateTarget('/auth/providers/', '?a=1')).toBe('/auth/providers?a=1')
expect(buildSearchUpdateTarget('/', '?a=1')).toBe('/?a=1')
expect(buildSearchUpdateTarget('', '?a=1')).toBe('/?a=1')
expect(buildSearchUpdateTarget('/', '')).toBe('/')
})
})
+73
View File
@@ -0,0 +1,73 @@
import { useLocation, useNavigate } from '@tanstack/react-router'
import { renderQueryString, unstable_createAdapterProvider } from 'nuqs/adapters/custom'
import { startTransition, useCallback, useMemo } from 'react'
// Custom nuqs adapter for TanStack Router.
//
// The stock `nuqs/adapters/tanstack-router` adapter navigates with
// `navigate({ to: renderQueryString(search) || '.', from })`. TanStack
// resolves a `?`-only relative `to` by *appending* it to the current path,
// injecting a trailing slash — every nuqs write turned
// `/auth/providers?provider=x` into `/auth/providers/?provider=x`. We build
// an absolute target from the current pathname instead. Everything else
// (searchParams derivation, replace/push, scroll, hash preservation)
// mirrors the stock adapter's contract.
type AdapterOptions = { history: 'push' | 'replace'; scroll: boolean; shallow: boolean }
// Compose the current pathname and a nuqs-rendered query string (`?...` or
// '' when every param is cleared) into an absolute navigation target.
// `pathname` comes from TanStack's parsed location, which is already
// basepath-stripped; guard against a trailing slash anyway (root stays `/`).
// Exported for unit tests — not part of the adapter surface.
export function buildSearchUpdateTarget(pathname: string, queryString: string): string {
let base = pathname || '/'
if (base.length > 1 && base.endsWith('/')) base = base.slice(0, -1)
return `${base}${queryString}`
}
function useNuqsTanStackRouterAdapter(watchKeys: string[]) {
const search = useLocation({
select: (state) =>
Object.fromEntries(Object.entries(state.search).filter(([key]) => watchKeys.includes(key))),
})
const pathname = useLocation({ select: (state) => state.pathname })
const navigate = useNavigate()
const searchParams = useMemo(
() =>
new URLSearchParams(
Object.entries(search).flatMap(([key, value]): Array<[string, string]> => {
if (Array.isArray(value)) return value.map((v) => [key, String(v)])
if (typeof value === 'object' && value !== null) return [[key, JSON.stringify(value)]]
return [[key, String(value)]]
})
),
// eslint-disable-next-line react-hooks/exhaustive-deps
[search, watchKeys.join(',')]
)
const updateUrl = useCallback(
(search: URLSearchParams, options: AdapterOptions) => {
startTransition(() => {
navigate({
// eslint-disable-next-line @typescript-eslint/no-explicit-any
to: buildSearchUpdateTarget(pathname, renderQueryString(search)) as any,
replace: options.history === 'replace',
resetScroll: options.scroll,
// Keep the current hash — nuqs updates must not clear `#section`.
hash: (prevHash) => prevHash ?? '',
})
})
},
[navigate, pathname]
)
return {
searchParams,
updateUrl,
rateLimitFactor: 1,
}
}
export const NuqsAdapter = unstable_createAdapterProvider(useNuqsTanStackRouterAdapter)
+3 -1
View File
@@ -47,7 +47,6 @@ import relativeTime from 'dayjs/plugin/relativeTime'
import timezone from 'dayjs/plugin/timezone'
import utc from 'dayjs/plugin/utc'
import { DevToolbar, DevToolbarProvider, DevToolbarTrigger, type ExtraTab } from 'dev-tools'
import { NuqsAdapter } from 'nuqs/adapters/tanstack-router'
import {
lazy,
Suspense,
@@ -73,6 +72,9 @@ import { useCustomContent } from '@/hooks/custom-content/useCustomContent'
import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization'
import { AuthProvider } from '@/lib/auth'
import { API_URL, BASE_PATH, IS_PLATFORM, useDefaultProvider } from '@/lib/constants'
// Custom adapter instead of `nuqs/adapters/tanstack-router` — the stock one
// injects a trailing slash before the query on every nuqs write (see module).
import { NuqsAdapter } from '@/lib/nuqs-tanstack-adapter'
import { ProfileProvider } from '@/lib/profile'
import { Telemetry } from '@/lib/telemetry'
import { Toaster } from '@/lib/toaster'