mirror of
https://github.com/supabase/supabase.git
synced 2026-10-09 03:15:06 +03:00
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:
1 parent
aa42906e0b
commit
eb8660925e
5 files changed
+152
-3
No files matched your search
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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('/')
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
@@ -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'
|
||||
|
||||
Reference in new issue
Block a user