diff --git a/apps/studio/compat/next/router.test.ts b/apps/studio/compat/next/router.test.ts index f5b3aa67323..9038ac4aa50 100644 --- a/apps/studio/compat/next/router.test.ts +++ b/apps/studio/compat/next/router.test.ts @@ -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') + }) +}) diff --git a/apps/studio/compat/next/router.ts b/apps/studio/compat/next/router.ts index 479b4234e84..bf6a83309c2 100644 --- a/apps/studio/compat/next/router.ts +++ b/apps/studio/compat/next/router.ts @@ -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 => { - 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 diff --git a/apps/studio/lib/nuqs-tanstack-adapter.test.ts b/apps/studio/lib/nuqs-tanstack-adapter.test.ts new file mode 100644 index 00000000000..40c7bd4fa2e --- /dev/null +++ b/apps/studio/lib/nuqs-tanstack-adapter.test.ts @@ -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('/') + }) +}) diff --git a/apps/studio/lib/nuqs-tanstack-adapter.tsx b/apps/studio/lib/nuqs-tanstack-adapter.tsx new file mode 100644 index 00000000000..046251c9b7e --- /dev/null +++ b/apps/studio/lib/nuqs-tanstack-adapter.tsx @@ -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) diff --git a/apps/studio/routes/__root.tsx b/apps/studio/routes/__root.tsx index 13385f879a9..79ef6ab4601 100644 --- a/apps/studio/routes/__root.tsx +++ b/apps/studio/routes/__root.tsx @@ -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'