From a50ebee88c1fc343415a7ddc4413c672936013df Mon Sep 17 00:00:00 2001 From: Alaister Young <10985857+alaister@users.noreply.github.com> Date: Thu, 1 Oct 2026 16:46:00 -0700 Subject: [PATCH] fix(studio): prevent redirect loops and restore TanStack E2E tests --- apps/studio/compat/next/router.hook.test.tsx | 62 +++++++++++++++++++ apps/studio/compat/next/router.ts | 10 +-- e2e/studio/env.config.ts | 1 - e2e/studio/features/api-access-toggle.spec.ts | 17 ----- e2e/studio/features/database.spec.ts | 16 ----- 5 files changed, 68 insertions(+), 38 deletions(-) create mode 100644 apps/studio/compat/next/router.hook.test.tsx diff --git a/apps/studio/compat/next/router.hook.test.tsx b/apps/studio/compat/next/router.hook.test.tsx new file mode 100644 index 00000000000..536b6500bdb --- /dev/null +++ b/apps/studio/compat/next/router.hook.test.tsx @@ -0,0 +1,62 @@ +import { + createMemoryHistory, + createRootRoute, + createRoute, + createRouter, + Outlet, + RouterProvider, +} from '@tanstack/react-router' +import { act, render, screen } from '@testing-library/react' +import { useEffect } from 'react' +import { describe, expect, it } from 'vitest' + +import { useRouter } from './router' + +describe('next/router redirect effects', () => { + it('keeps the router object stable when only match loading state changes', async () => { + const seen: ReturnType[] = [] + const rootRoute = createRootRoute({ component: Outlet }) + const dataRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/project/$ref/database/triggers/data', + component: function DataPage() { + const router = useRouter() + useEffect(() => { + seen.push(router) + }, [router]) + return
Triggers loaded
+ }, + }) + const router = createRouter({ + routeTree: rootRoute.addChildren([dataRoute]), + history: createMemoryHistory({ + initialEntries: ['/project/default/database/triggers/data?schema=public'], + }), + }) + await router.load() + const view = render() + try { + await screen.findByText('Triggers loaded') + const initial = seen.at(-1) + await act(async () => { + await router.invalidate() + }) + expect(seen.at(-1)).toBe(initial) + expect(initial?.pathname).toBe('/project/[ref]/database/triggers/data') + expect(initial?.query).toEqual({ ref: 'default', schema: 'public' }) + + await act(async () => { + await router.navigate({ + to: '/project/$ref/database/triggers/data', + params: { ref: 'other' }, + search: { schema: 'realtime' }, + }) + }) + expect(seen.at(-1)).not.toBe(initial) + expect(seen.at(-1)?.asPath).toBe('/project/other/database/triggers/data?schema=realtime') + expect(seen.at(-1)?.query).toEqual({ ref: 'other', schema: 'realtime' }) + } finally { + view.unmount() + } + }) +}) diff --git a/apps/studio/compat/next/router.ts b/apps/studio/compat/next/router.ts index d2ae293151c..5949d316f8a 100644 --- a/apps/studio/compat/next/router.ts +++ b/apps/studio/compat/next/router.ts @@ -252,13 +252,15 @@ type PrefetchOptions = { export function useRouter() { const router = useTanStackRouter() const location = useLocation() - const matches = useMatches() + // Match objects change while a navigation loads. Only the leaf route ID + // affects Next's pathname; subscribing to all matches changes this router + // object and can restart effects that are already redirecting the page. + const leafRouteId = useMatches({ select: (matches) => matches[matches.length - 1]?.routeId }) const params = useParams({ strict: false }) const search = useSearch({ strict: false }) return useMemo(() => { - const leafRouteId = matches[matches.length - 1]?.routeId ?? location.pathname - const pathPattern = toNextPathPattern(leafRouteId) + const pathPattern = toNextPathPattern(leafRouteId ?? location.pathname) // Both push and replace accept Next's (url, as?, options?) signature. // `as` is the legacy alias path (mostly obsolete in modern Next; ignored @@ -397,7 +399,7 @@ export function useRouter() { // ---- events ---- events: getRouterEventsProxy(router), } - }, [router, location.href, location.pathname, matches, params, search]) + }, [router, location.href, location.pathname, leafRouteId, params, search]) } // Normalise an optional-catch-all route's params across both frameworks. diff --git a/e2e/studio/env.config.ts b/e2e/studio/env.config.ts index 7295aaa73db..10b13e97ab4 100644 --- a/e2e/studio/env.config.ts +++ b/e2e/studio/env.config.ts @@ -22,7 +22,6 @@ export const env = { API_URL: process.env.API_URL || 'http://127.0.0.1:54321', IS_PLATFORM: toBoolean(process.env.IS_PLATFORM || 'false'), - STUDIO_FRAMEWORK: process.env.STUDIO_FRAMEWORK || 'next', EMAIL: process.env.EMAIL, PASSWORD: process.env.PASSWORD, PROJECT_REF: process.env.PROJECT_REF || undefined, diff --git a/e2e/studio/features/api-access-toggle.spec.ts b/e2e/studio/features/api-access-toggle.spec.ts index 1d40dad4182..6a8e047d074 100644 --- a/e2e/studio/features/api-access-toggle.spec.ts +++ b/e2e/studio/features/api-access-toggle.spec.ts @@ -1,6 +1,5 @@ import { expect, Page } from '@playwright/test' -import { env } from '../env.config.js' import { dropTable, query } from '../utils/db/index.js' import { dismissToastsIfAny } from '../utils/dismiss-toast.js' import { openTableContextMenu } from '../utils/table-helpers.js' @@ -200,14 +199,6 @@ test.describe('API Access Toggle', () => { }) test('shows Manage access link when editing an existing table', async ({ page, ref }) => { - // TODO(tanstack): the entity-types pg-meta query that waitForTableToLoad - // waits on consistently times out (>30s) under the TanStack Start server - // but not under Next — see PR #51141. Re-enable once the root cause is found. - test.skip( - env.STUDIO_FRAMEWORK === 'tanstack', - 'pg-meta entity-types query hangs under the TanStack Start server (#51141)' - ) - const tableName = `${TABLE_NAME_PREFIX}_edit` await using _ = await withSetupCleanup( async () => { @@ -266,14 +257,6 @@ test.describe('API Access Toggle', () => { page, ref, }) => { - // TODO(tanstack): the entity-types pg-meta query that waitForTableToLoad - // waits on consistently times out (>30s) under the TanStack Start server - // but not under Next — see PR #51141. Re-enable once the root cause is found. - test.skip( - env.STUDIO_FRAMEWORK === 'tanstack', - 'pg-meta entity-types query hangs under the TanStack Start server (#51141)' - ) - const tableName = `${TABLE_NAME_PREFIX}_preserve_grants` await using _ = await withSetupCleanup( async () => { diff --git a/e2e/studio/features/database.spec.ts b/e2e/studio/features/database.spec.ts index eca44cfe1cd..0dff28610c5 100644 --- a/e2e/studio/features/database.spec.ts +++ b/e2e/studio/features/database.spec.ts @@ -540,14 +540,6 @@ test.describe('Database', () => { test.describe('Triggers', () => { test('actions works as expected', async ({ page, ref }) => { - // TODO(tanstack): triggers/entity-types pg-meta queries consistently - // time out (>30s) under the TanStack Start server but not under Next — - // see PR #51141. Re-enable once the root cause is found. - test.skip( - env.STUDIO_FRAMEWORK === 'tanstack', - 'pg-meta triggers query hangs under the TanStack Start server (#51141)' - ) - const triggersLoadWait = createApiResponseWaiter(page, 'pg-meta', ref, 'query?key=triggers') await page.goto(toUrl(`/project/${env.PROJECT_REF}/database/triggers?schema=public`)) @@ -572,14 +564,6 @@ test.describe('Database', () => { }) test('CRUD operations works as expected', async ({ page, ref }) => { - // TODO(tanstack): triggers/entity-types pg-meta queries consistently - // time out (>30s) under the TanStack Start server but not under Next — - // see PR #51141. Re-enable once the root cause is found. - test.skip( - env.STUDIO_FRAMEWORK === 'tanstack', - 'pg-meta triggers query hangs under the TanStack Start server (#51141)' - ) - const databaseTableName = 'pw_database_trigger_table' const databaseColumnName = 'pw_database_column_trigger' const databaseTriggerName = 'pw_database_trigger'