From 18431efb258b0a8be98dfa7abdab4abb3e0899fd Mon Sep 17 00:00:00 2001 From: Alaister Young Date: Wed, 8 Jul 2026 14:52:59 +0800 Subject: [PATCH] =?UTF-8?q?fix(studio):=20TanStack=20post-merge=20fixes=20?= =?UTF-8?q?=E2=80=94=20Monaco=20loader,=20fonts,=20CSP=20(from=20#46424)?= =?UTF-8?q?=20(#47657)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Post-merge fixes for the TanStack Start migration (#46424) — things that broke on the TanStack build as master evolved under the migration branches. Kept on their own branch off master rather than piling onto the E2E-matrix PR (#47119); all land on master and cascade up to S6 + the big PR. Common theme: a master PR changed something the Next pipeline handles via `next/font` / `pages/_app.tsx` / `next.config.ts`, but the hand-rolled TanStack equivalent (`routes/__root.tsx`, `styles/fonts.css`, `vercel.ts`) wasn't updated to match — invisible on the Next deploy, broken only on TanStack. --- ## 1. Monaco loader path (#47182) #47182 re-nested the served Monaco assets from a flat `public/monaco-editor/` layout into `public/monaco-editor/vs/` and updated `pages/_app.tsx`, but `routes/__root.tsx` still pointed `loader.config` at the old path, so `loader.js` 404'd and **no Monaco editor mounted anywhere in the TanStack build**. Now mirrors the Next config (`${origin}${BASE_PATH}/monaco-editor/vs`, window-guarded for SSR). Was failing the whole `tanstack` E2E shard on #47119. ## 2. Inter + Manrope fonts (#47306) #47306 renamed Tailwind's sans var `--font-custom` → `--font-sans` and added `--font-heading` (Manrope), set via `next/font` on Next. `fonts.css` still only set the now-ignored `--font-custom`, so the body fell back to the theme's system chain (`Circular, custom-font, Helvetica…`) at weight 450 — that's the "Inter weights look wrong". Manrope was missing entirely. - Wire `--font-sans` (Inter) + `--font-heading` (Manrope) to match `next/font`. - **Vendor all three families** (Inter, Manrope, Source Code Pro) via `@font-face` so nothing depends on the Google Fonts CDN — matches `next/font` self-hosting, and (see below) `font-src` doesn't allow `fonts.gstatic.com` anyway. Verified in-browser: computed `body` → `Inter`, headings → `Manrope`, all loading from local `/assets/*.woff2`. ## 3. Security headers / CSP (next.config.ts `headers()`) The Next build sets X-Frame-Options / X-Content-Type-Options / HSTS / **Content-Security-Policy** / Referrer-Policy via `next.config.ts`. The TanStack build never carried these over — `vercel.ts` only set cache-control, so **the deployed TanStack dashboard shipped with no CSP at all**. The TanStack deploy serves a static shell (no server to attach headers), so they go in the Vercel config: - `security-headers.ts` — shared source of truth, reuses `getCSP()`, env-gated exactly like next.config. - `vercel.ts` — apply to every response (all base-path prefixes): full `getCSP()` + HSTS on platform. - `scripts/serve.js` — the non-platform set (`frame-ancestors 'none'`) for the self-hosted server. **Tested the policy in a real browser** (temporarily enforced it on the TanStack build via /test-supabase-local): everything passed except one real gap — `font-src` was missing `data:`, so GraphiQL's bundled Monaco codicon font and Stripe's payment-element fonts (both data: URIs) were blocked (37 violations on a cold load). Added `data:` to `font-src` in `csp.ts` → violations drop to zero, SQL editor Monaco renders clean. That gap affects the Next build too. --- ## 4. `node:path` import crashing `/project/[ref]/merge` Found by a full-site click-through of the TanStack build (all product areas, ongoing — see below). `useEdgeFunctionsDiff.ts` + `EdgeFunctionsDiffPanel.tsx` did `import { basename } from 'path'` in client code. Webpack (Next) polyfills `path` in the browser; Vite externalizes it, so the whole `/merge` route crashed with "Module \"path\" has been externalized for browser compatibility". Replaced the two `basename` call sites with a string helper. Verified in-browser: `/merge` renders. ## 5. URL shape — Next-style search-param semantics + shim fixes The dashboard produced malformed URLs vs the Next build (strange query params, trailing slashes, `##` hashes). Root cause + audit verified empirically against `@tanstack/react-router@1.170.10`; all fixed with unit tests and browser-verified: - **`createRouter` used TanStack's default JSON search codec** — `?flag=true` became `?flag=%22true%22` via links, repeated `?filter=…&filter=…` collapsed into a JSON array (breaking multi-filter/sort table-editor URLs and the account-page round-trip, which double-encoded), and search values arrived as numbers/booleans where the app expects strings. New `lib/router-search-params.ts` (Next-style: strings in, strings out, repeated keys → string[]) wired into the router. - **Link shim** (`compat/next/link.tsx`): `URL.hash` includes the leading `#` while TanStack's `hash` prop adds its own → every `href="…#section"` navigated to `##section` (hash-scroll broke); `Object.fromEntries(searchParams)` dropped repeated query params. Both fixed. - **Trailing slash injected before the query** on every `?`-only relative navigation (`/auth/providers/?provider=…`): fixed in the compat router (prefix current pathname) and via a custom nuqs adapter (`lib/nuqs-tanstack-adapter.tsx`) replacing the stock tanstack-router adapter, whose `navigate({ to: '?…' })` writes hit the same TanStack behavior (123 files use nuqs). - **Pathname-less `router.push({ query })` leaked path params** — Next re-consumes `ref`/`id` from `query` into the path pattern; the shim didn't, yielding `/editor/17597?schema=public&ref=&id=17597&filter=…` from table-editor filter/sort, linter panels, and advisor shortcuts. The shim now defaults the pathname to the current route pattern and backfills omitted params. - **Redirects dropped query + hash** (Next's `redirects()` preserves them): `__root.tsx` `matchRedirect` and `routes/index.tsx` now carry incoming params/hash through (consumed rule params excluded, destination's own params win). `/?next=new-project&projectName=zzz` → `/new/new-project?projectName=zzz`; `/sql/quickstarts?template=x#frag` → `/sql/examples?template=x#frag`. Browser-verified post-fix: advisors `?preset=WARN`, providers `?provider=Google`, `?schema=auth` — all clean (no `/?`, no leaks); repeated `filter` params survive hydration; `=true` unquoted; single `#`. ## 6. TanStack `navigate` corrupting query values (Logs Explorer SQL newline loss) TanStack router-core treats a query string embedded in `navigate({ to })` as part of the *path*: `decodePath` percent-decodes it and `sanitizePathSegment` strips control characters, silently deleting every `%0A`. Logs Explorer's SQL (`s` param) lost its newlines on Run/reload — `order by timestamp desc` / `limit 5` glued into `desclimit 5`, which then failed the LIMIT lint. Pre-existing on the TanStack build (the stock nuqs adapter had the same shape); Next unaffected. Fixed by never embedding query strings in `to`: the nuqs adapter and the compat `router.push`/`replace`/`prefetch` (plus the `next/navigation` shim) now pass search as an object through the app codec (`splitInternalUrl` hoisted to `lib/internal-url.ts`). Guard test drives a real `createRouter` with multi-line SQL through both producers. Browser-verified: newlines survive the full Run → reload → re-Run cycle. ## 7. Integration overview markdown never loaded (all integrations) `MarkdownContent` used a template-literal dynamic import (``import(`@/static-data/integrations/${id}/overview.md`)``) — webpack builds a context module for that, Vite can't analyze it, so every integration detail page threw `Failed to resolve module specifier` and rendered no overview text. Fixed with an explicit lazy registry of literal imports (`static-data/integrations/overviews.ts`, drift-guarded by a test) plus an `mdRawLoader()` Vite plugin mirroring next.config's turbopack raw-loader rule. Both runtimes keep working; md stays out of the main bundle. ## 8. GraphiQL editor never mounted (`exports is not defined`) Our `umdAmdShortCircuit()` Vite plugin (which disarms Monaco's global AMD loader for deps like papaparse) rewrote `typeof define === 'function' && define.amd` to `false` inside `monaco-editor`'s bundled copy of marked — whose UMD relies on its own *local* `define` shim — so the whole optimized monaco chunk failed to evaluate and GraphiQL's editor pane stayed blank. The check now only short-circuits when `define` is the global AMD loader. Browser-verified: all four GraphiQL Monaco panes mount, queries execute. (Known follow-up: GraphiQL's Monaco workers fall back to the main thread under Vite — functional, worker wiring is Next-specific `setup-workers/webpack`.) ## 9. `@sentry/nextjs` bundling Next internals — built TanStack bundle crashed (caught by E2E) The E2E suite against the **built** TanStack bundle (not the dev server) found lazy chunks like `table-editor-*.js` dead on arrival: `@sentry/nextjs` (imported by ~25 client files) drags in `next/dist/shared/lib/constants`, whose module scope evaluates `process?.features?.typescript` — optional chaining doesn't guard an undeclared `process` in the browser, so the whole chunk failed at load with `ReferenceError: process is not defined`. Dev shims `process`, which is why weeks of dev-server testing never saw it. Fixed by aliasing `@sentry/nextjs` → `compat/sentry-nextjs.ts` (re-exports `@sentry/react`, same deduped 10.59.0, plus explicit stand-ins for the three Next-only APIs) in the Vite build only. Verified: fresh build has zero Next-internals markers in any chunk; table editor loads clean; full E2E suite run against the built bundle. Note for the stack: `alaister/tanstack-start` / the E2E-matrix branch already carried a different fix for the same crash (a `next/constants` shim) that never made it to master — the cherry-pick onto those branches keeps **both** (the shim covers any other transitive importer; the alias keeps Next internals out of the client bundle entirely). **Follow-up found while fixing:** Sentry is never *initialized* in the TanStack runtime — `instrumentation-client.ts` / `sentry.server.config.ts` are Next-convention files nothing imports under TanStack, so `captureException` calls are silent no-ops. Needs an `@sentry/react` init (+ `tanstackRouterBrowserTracingIntegration`) wired into the TanStack client entry as its own PR. ## 10. GraphiQL Monaco workers + edge-function Deno typings (Vite-only gaps) - **GraphiQL's Monaco workers ran on the main thread** under Vite ("Could not create web worker(s)…" — `setup-workers/webpack`'s `new URL(...)` form isn't rewritten by Vite). A `graphiqlViteWorkers()` plugin resolves the import to graphiql's own `setup-workers/vite` variant for client builds (SSR untouched, Next untouched); the setup-workers chain is `optimizeDeps.exclude`d because the Rolldown optimizer can't load `?worker` ids. - **Edge-function editors silently lost their Deno typings** — `AIEditor` loaded `public/deno/*.d.ts` via `/* @vite-ignore */` imports that always failed at runtime under Vite. The `.md` raw loader is generalized into `rawTextLoader` (exact-path allowlist for the two typings files, served as virtual string modules so the dep scanner never parses `.d.ts` syntax), and the imports are now static-analyzable literals that both bundlers handle (turbopack's raw-loader rules match them on the Next side). ## Split out for reviewability App-level fixes that reproduce on the Next build too (DOM-nesting hydration errors, the ghost deleted-snippet nav, the recurring pg-meta `migrations` 400) moved to their own PR: #47667. Sentry initialization for the TanStack runtime (captures were silent no-ops) is #47666, stacked on this PR. ## Full-site test campaign Drove every dashboard product area on the local TanStack build (Playwright, human-style) hunting migration regressions: redirects/404/catch-alls, org, account, project home/branches/merge, table editor CRUD, SQL editor (Monaco/run/save/templates/AI), all database pages, all auth pages, storage CRUD, edge functions + realtime, logs/observability, advisors, settings, integrations hub incl. nested routes, global UI (palette/connect/switchers/theme/fonts), and a cross-cutting sweep (document titles, back/forward chain, hard-refresh hydration on deep URLs, trailing-slash active state). Every failure found is fixed above and re-verified in-browser; remaining console quirks were cross-checked against the deployed Next build and are pre-existing (tracked separately). ## To test Most fixes are already browser-verified + covered by unit tests and the self-hosted E2E suite; the last two landed after the final browser pass and still need an in-browser check: 1. **GraphiQL Monaco workers** — restart the dev server (clear `apps/studio/node_modules/.vite` once first — the optimizer cache may hold a stale prebundle of the worker chain). Open `/project//integrations/graphiql/graphiql` with the console open: the `Could not create web worker(s). Falling back to loading web worker code in main thread` warning must be gone, and DevTools → Sources → Threads shows the three workers (json, editor, graphql). Autocomplete in the query editor stays responsive. 2. **Edge-function Deno typings** — `/project//functions/new`: no "Failed to load … typings" console error, and typing `Deno.` in the editor offers typed completions (e.g. `Deno.env`). Spot-checks for the rest (all previously verified): - `/project//merge` renders (no "Module path" crash). - Multi-line SQL in Logs Explorer survives Run → reload (no `desclimit` gluing, no LIMIT-lint false failure); `s` param keeps `%0A`. - `/auth/providers` → open a provider → `?provider=…` with no trailing slash before `?`; table-editor filter/sort URLs carry no leaked `ref`/`id` params; `/?next=new-project&projectName=x` lands on `/new/new-project?projectName=x`. - Integration detail pages (cron/queues/vault/data_api) show their overview prose; GraphiQL query editor mounts. - Built bundle (`MODE=test vite build` + `start:tanstack`): table editor loads with no `process is not defined`. - `curl -sI` any page on a platform deploy: `X-Content-Type-Options: nosniff` (was the invalid `no-sniff`). ## Summary by CodeRabbit * **New Features** * Centralized integration overview markdown loading with registry-based lookup. * Improved Monaco loading/asset path handling for smoother editor startup. * **Bug Fixes** * Next-style navigation/search handling now preserves pathname, hash, repeated query keys, and special characters (including newlines). * Redirects now reliably carry over query and hash with correct precedence. * **Security/Configuration** * Updated CSP font sourcing and unified security headers delivery across environments; conditional HSTS behavior. * Refreshed font CSS variables and font-face definitions to match the theme. --- ### Review feedback: non-prod favicon (Joshen) The TanStack `__root.tsx` hardcoded the prod favicon; local + hosted staging now use the white staging favicon (`/favicon/staging`), matching what `pages/_app.tsx` passes to `MetaFaviconsPagesRouter` for non-prod. Rather than pull the pages-router component into the TanStack head, it reuses the same synchronous `NEXT_PUBLIC_ENVIRONMENT` signal the file already uses for `IS_DEV_TOOLBAR_ENABLED` (the `head()` route option isn't a React component, so it can't run `_app`'s async CLI check — but the env signal covers the reported local/staging case). --------- Co-authored-by: Alaister Young <10985857+alaister@users.noreply.github.com> Co-authored-by: Joshen Lim --- apps/studio/TANSTACK_MIGRATION.md | 74 +++++++ apps/studio/compat/next/link.tsx | 94 +-------- apps/studio/compat/next/navigation.ts | 37 +++- apps/studio/compat/next/router.test.ts | 78 +++++++- apps/studio/compat/next/router.ts | 87 +++++++- apps/studio/compat/sentry-nextjs.ts | 62 ++++++ .../EdgeFunctionsDiffPanel.tsx | 13 +- .../MarkdownContent.tsx | 8 +- .../Integration/MarkdownContent.tsx | 12 +- .../Infrastructure/InfrastructureActivity.tsx | 7 +- apps/studio/components/ui/AIEditor/index.tsx | 38 ++-- apps/studio/csp.ts | 4 +- .../manrope-latin-ext-wght-normal.woff2 | Bin 0 -> 15120 bytes .../manrope/manrope-latin-wght-normal.woff2 | Bin 0 -> 24836 bytes .../hooks/branches/useEdgeFunctionsDiff.ts | 7 +- apps/studio/lib/configure-monaco-loader.ts | 19 ++ apps/studio/lib/internal-url.test.ts | 126 ++++++++++++ apps/studio/lib/internal-url.ts | 104 ++++++++++ apps/studio/lib/nuqs-tanstack-adapter.test.ts | 47 +++++ apps/studio/lib/nuqs-tanstack-adapter.tsx | 91 +++++++++ apps/studio/lib/router-search-params.test.ts | 123 ++++++++++++ apps/studio/lib/router-search-params.ts | 54 +++++ apps/studio/lib/router-url-shape.test.tsx | 186 +++++++++++++++++ apps/studio/next.config.ts | 2 +- apps/studio/pages/_app.tsx | 11 +- apps/studio/redirects.shared.test.ts | 119 +++++++++++ apps/studio/redirects.shared.ts | 60 +++++- apps/studio/router.tsx | 7 + apps/studio/routes/__root.tsx | 44 ++-- apps/studio/routes/index.tsx | 20 +- apps/studio/scripts/serve.js | 13 ++ apps/studio/security-headers.ts | 35 ++++ .../integrations/overviews.test.ts | 27 +++ .../static-data/integrations/overviews.ts | 63 ++++++ apps/studio/styles/fonts.css | 66 +++++- apps/studio/types/markdown.d.ts | 8 + apps/studio/vercel.ts | 6 + apps/studio/vite.config.ts | 189 ++++++++++++++---- .../monaco-graphiql-coexistence.spec.ts | 13 +- 39 files changed, 1728 insertions(+), 226 deletions(-) create mode 100644 apps/studio/compat/sentry-nextjs.ts create mode 100644 apps/studio/fonts/manrope/manrope-latin-ext-wght-normal.woff2 create mode 100644 apps/studio/fonts/manrope/manrope-latin-wght-normal.woff2 create mode 100644 apps/studio/lib/configure-monaco-loader.ts create mode 100644 apps/studio/lib/internal-url.test.ts create mode 100644 apps/studio/lib/internal-url.ts create mode 100644 apps/studio/lib/nuqs-tanstack-adapter.test.ts create mode 100644 apps/studio/lib/nuqs-tanstack-adapter.tsx create mode 100644 apps/studio/lib/router-search-params.test.ts create mode 100644 apps/studio/lib/router-search-params.ts create mode 100644 apps/studio/lib/router-url-shape.test.tsx create mode 100644 apps/studio/redirects.shared.test.ts create mode 100644 apps/studio/security-headers.ts create mode 100644 apps/studio/static-data/integrations/overviews.test.ts create mode 100644 apps/studio/static-data/integrations/overviews.ts create mode 100644 apps/studio/types/markdown.d.ts diff --git a/apps/studio/TANSTACK_MIGRATION.md b/apps/studio/TANSTACK_MIGRATION.md index 95590998590..728290838b2 100644 --- a/apps/studio/TANSTACK_MIGRATION.md +++ b/apps/studio/TANSTACK_MIGRATION.md @@ -494,6 +494,80 @@ Keep this plugin even after migration — it's not a Next-related shim, it's general protection against this entire class of bug. Just clear the allowlist when the underlying cycle is gone. +### `@sentry/nextjs` → `@sentry/react` alias + +`resolve.alias` in `vite.config.ts` rewrites the bare `@sentry/nextjs` +import to `compat/sentry-nextjs.ts`, which re-exports `@sentry/react` +(the same-version package `@sentry/nextjs` wraps on the client) plus +explicit stand-ins for the Next-only APIs (`captureRouterTransitionStart`, +`captureRequestError`, `withSentryConfig`). + +Why: `@sentry/nextjs`'s client entry imports +`next/dist/shared/lib/constants`, whose module scope evaluates +`...(process?.features?.typescript ? ['next.config.mts'] : [])`. +Optional chaining does **not** guard an undeclared `process` identifier, +so every built client chunk containing it (table editor was the canary) +threw `ReferenceError: process is not defined` at module load. Dev was +unaffected (dev pipeline shims `process`), so it only surfaced in the +production/test build. + +The alias also made the previous `@sentry/nextjs` SSR workarounds +(`ssr.noExternal` entry + `ssr.optimizeDeps.include`) obsolete — the id +is rewritten before SSR resolution, and `@sentry/react` ships real ESM. +App source keeps importing `@sentry/nextjs` so the Next build +(`build:next`) is untouched; drop the alias + shim together with the +Next build when the migration is done (switch imports to +`@sentry/react` directly). + +### GraphiQL Monaco workers: `setup-workers/webpack` → `setup-workers/vite` + +App source (`GraphiQLTab.tsx`) imports `graphiql/setup-workers/webpack`, +which registers `MonacoEnvironment.getWorker` using +`new Worker(new URL('monaco-editor/...', import.meta.url))` — the URL form +webpack/turbopack rewrites at build time. Vite doesn't rewrite bare module +specifiers inside `new URL(..., import.meta.url)`, so under the TanStack +build the worker URLs 404'd and Monaco fell back to running the `json`, +`editorWorkerService` and `graphql` workers on the main thread ("Could not +create web worker(s). Falling back to loading web worker code in main +thread" in the console). + +The `graphiqlViteWorkers` plugin in `vite.config.ts` resolves that import +to graphiql's own `setup-workers/vite` variant (same three workers via +Vite `?worker` imports) in client builds; SSR resolution is untouched. The +import specifier stays `.../webpack` in app source so the Next build keeps +working. The whole setup-workers chain is also in `optimizeDeps.exclude` — +the Rolldown dep optimizer can't load `?worker` ids +(`UNLOADABLE_DEPENDENCY`), so the modules go through the normal transform +pipeline where Vite's built-in worker plugin handles them. Drop the plugin +and the exclude, and switch the import to `graphiql/setup-workers/vite`, +when the Next build goes away. + +### Raw-text imports: `*.md` + `public/deno/*.d.ts` (`rawTextLoader`) + +Next's raw-loader rules (next.config.ts `turbopack.rules`) serve `*.md` +files and the Deno typings `public/deno/edge-runtime.d.ts` / +`public/deno/lib.deno.d.ts` as JS modules whose default export is the +file's text. The `rawTextLoader` plugin in `vite.config.ts` mirrors that +for the Vite pipeline: + +- `*.md` — plain `transform` (used by + `static-data/integrations/*/overview.md` via the literal-import registry + in `static-data/integrations/overviews.ts`). +- The two Deno `.d.ts` files (used by `components/ui/AIEditor` as Monaco + extra libs for edge-function editors) — an exact-specifier allowlist + resolved to `\0`-virtual ids and served from a `load` hook. They can't go + through `transform`: Rolldown's native dep scanner skips JS plugin hooks + and hard-fails parsing TS _declaration_ syntax (`get stdin(): ...;`) as + runtime TS, which killed dependency pre-bundling wholesale. The previous + `/* @vite-ignore */` hack kept the scanner away but also meant the + imports failed at runtime, silently dropping Deno type hints in the + TanStack build. Do NOT widen the allowlist to `*.d.ts` — hijacking + declaration-file resolution globally would corrupt packages that ship + `.d.ts` next to their JS. The `as string` casts on the import specifiers + in `AIEditor/index.tsx` keep tsc from resolving the `.d.ts` files as + declaration files (TS2846) while erasing to plain literals both bundlers + statically analyze. + ### Other build-side migration changes - `pnpm-workspace.yaml` catalog now includes `@tanstack/react-router`, diff --git a/apps/studio/compat/next/link.tsx b/apps/studio/compat/next/link.tsx index c662e623466..cbb68e892ce 100644 --- a/apps/studio/compat/next/link.tsx +++ b/apps/studio/compat/next/link.tsx @@ -11,6 +11,8 @@ import { type Ref, } from 'react' +import { splitInternalUrl } from '@/lib/internal-url' + // Next's Link accepts either a string `href` or a `UrlObject` // ({pathname, query, hash}). Workspace source does both — flatten // `UrlObject` into `pathname?search#hash` first so the TanStack `to` @@ -62,92 +64,14 @@ function resolveHref(href: string | UrlObject): string { return `${pathname}${search}${hash}` } -// Inlined at build time via Vite's `define`. Must agree with Vite `base` -// and `tanstackStart({ router: { basepath } })`. Empty string when no -// basePath is configured. Used to strip a duplicate prefix in -// `splitInternalUrl` below — see the comment there. -const NEXT_PUBLIC_BASE_PATH = process.env.NEXT_PUBLIC_BASE_PATH ?? '' - // TanStack Link's `to` prop is a route-pattern path; query params and hash -// must be passed separately via `search` / `hash`. Studio code (and Next's -// own contract) routinely passes one of three href shapes: -// 1. a relative path like `/project/abc/editor/123?schema=public` -// 2. a same-origin absolute URL produced by `new URL(...).toString()`, -// e.g. `http://localhost:8082/project/abc/editor/123?schema=public` -// (this is what `buildTableEditorUrl` does) -// 3. a genuinely external URL like `https://supabase.com/docs`. -// -// If we forward any of these straight through to TanStack as `to`, TanStack -// either fails to match a known route pattern (#1 with query) or treats -// the whole thing as external (#2) and falls back to native browser -// navigation — which the user sees as a full page reload. -// -// Split into three parts: pathname, search, hash. Same-origin absolute -// URLs are normalised to a relative path. Cross-origin URLs are left -// alone so TanStack's external-link path handles them. -// -// basePath quirk: TanStack's `to` is **basepath-relative** — given -// `basepath: '/dashboard'` and `to: '/foo'`, TanStack builds the href -// `/dashboard/foo`. Next's contract treats `href` as the **full path -// from app root including basePath**, and studio code routinely -// pre-prefixes BASE_PATH (e.g. `buildTableEditorUrl` calls -// `new URL(`${BASE_PATH}/project/.../editor/...`, location.origin)`). -// Forwarding the BASE_PATH-prefixed pathname as `to` makes TanStack -// double-prefix it (`/dashboard/dashboard/project/...`). Strip the -// basePath when we see it, so what we hand TanStack is always -// basepath-relative. -function splitInternalUrl(url: string): { - to: string - search?: Record - hash?: string -} { - // Try to detect cross-origin absolute URLs cheaply before paying for a - // full parse. Protocol-relative URLs (`//host/...`) are always external. - if (url.startsWith('//')) { - return { to: url } - } - - // Use the document origin as the parse base so relative inputs resolve. - // SSR has no `location`; fall back to a placeholder host that won't ever - // collide with a real one. - const base = - typeof window !== 'undefined' && window.location ? window.location.origin : 'http://_/' - - let parsed: URL - try { - parsed = new URL(url, base) - } catch { - return { to: url } - } - - // Cross-origin → leave for TanStack to handle as external. - if ( - typeof window !== 'undefined' && - window.location && - parsed.origin !== window.location.origin - ) { - return { to: url } - } - - let pathname = parsed.pathname - // Strip a leading basePath segment so we hand TanStack a basepath- - // relative path. Match `/dashboard` exactly OR `/dashboard/...`; don't - // strip a coincidental prefix like `/dashboard-other`. - if ( - NEXT_PUBLIC_BASE_PATH && - (pathname === NEXT_PUBLIC_BASE_PATH || pathname.startsWith(`${NEXT_PUBLIC_BASE_PATH}/`)) - ) { - pathname = pathname.slice(NEXT_PUBLIC_BASE_PATH.length) || '/' - } - - const search = Object.fromEntries(parsed.searchParams) - const hash = parsed.hash || undefined - return { - to: pathname, - search: Object.keys(search).length > 0 ? search : undefined, - hash, - } -} +// must be passed separately via `search` / `hash`. If we forward an href +// straight through to TanStack as `to`, TanStack either mangles the query +// (path interpolation percent-decodes it and strips control characters) or +// treats a same-origin absolute URL as external and falls back to native +// browser navigation — which the user sees as a full page reload. Splitting +// (plus origin/basePath normalisation) lives in `splitInternalUrl` +// (@/lib/internal-url), shared with the next/router shim. // Next: prefetch=true|"auto" → eagerly preload; false → never; default // in production = true. TanStack's preload values are "intent" (on hover/ diff --git a/apps/studio/compat/next/navigation.ts b/apps/studio/compat/next/navigation.ts index 576693a1d9b..6b5da9328aa 100644 --- a/apps/studio/compat/next/navigation.ts +++ b/apps/studio/compat/next/navigation.ts @@ -3,9 +3,12 @@ import { useMatches, useParams as useTanStackParams, useRouter as useTanStackRouter, + type AnyRouter, } from '@tanstack/react-router' import { useMemo } from 'react' +import { splitInternalUrl } from '@/lib/internal-url' + // `next/navigation` is the App Router hook surface (Next 13+). Studio is // still pages-based, so most of these are entry-points that arrive via // stray imports or shared packages — we keep the surface comprehensive @@ -29,13 +32,27 @@ export function useRouter() { return useMemo( () => ({ + // Split query/hash out of the href — embedding them in TanStack's + // `to` runs the whole string through path interpolation, which + // percent-decodes it and strips control characters from query values + // (see @/lib/internal-url). `search: {}` / `hash: ''` clear stale + // state, matching Next's full-href navigation semantics. + // + // The `` type arguments opt out of the registered + // route tree's strict typing: Next-style hrefs are free-form strings + // that can't satisfy the route-path union at compile time. push: (href: string, _options?: NavigateOptions) => { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - router.navigate({ to: href as any }) + const { to, search, hash } = splitInternalUrl(href) + router.navigate({ to, search: search ?? {}, hash: hash ?? '' }) }, replace: (href: string, _options?: NavigateOptions) => { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - router.navigate({ to: href as any, replace: true }) + const { to, search, hash } = splitInternalUrl(href) + router.navigate({ + to, + search: search ?? {}, + hash: hash ?? '', + replace: true, + }) }, // App Router's refresh() refetches the current route's data without // re-mounting the React tree. Closest TanStack equivalent is @@ -50,10 +67,14 @@ export function useRouter() { if (typeof window !== 'undefined') window.history.forward() }, prefetch: (href: string, _options?: PrefetchOptions) => { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - router.preloadRoute({ to: href as any }).catch(() => { - // Match Next's fire-and-forget contract. - }) + const { to, search, hash } = splitInternalUrl(href) + // `` (TFrom, TTo) loosens `to` to a plain string for + // the same free-form-href reason as `navigate` above. + router + .preloadRoute({ to, search: search ?? {}, hash: hash ?? '' }) + .catch(() => { + // Match Next's fire-and-forget contract. + }) }, }), [router] diff --git a/apps/studio/compat/next/router.test.ts b/apps/studio/compat/next/router.test.ts index f5b3aa67323..16c0ad8a56f 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, withDefaultPathname } from './router' describe('resolveUrl (next/router compat shim)', () => { it('returns string URLs untouched', () => { @@ -58,3 +58,79 @@ 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') + }) +}) + +describe('withDefaultPathname (next/router compat shim)', () => { + it('fills a missing pathname from the current route pattern so path params are consumed', () => { + // The regression we fixed: `push({ query: { ...router.query, filter } })` + // with no pathname leaked `ref`/`id` into the query string + // (`/editor/17597/?ref=...&id=17597&filter=...`) because nothing + // interpolated them back into the path. + const target = withDefaultPathname( + { query: { ref: 'abc', id: '17597', filter: 'note:eq:hi' } }, + '/project/[ref]/editor/[id]', + { ref: 'abc', id: '17597' } + ) + expect(resolveUrl(target)).toBe('/project/abc/editor/17597?filter=note%3Aeq%3Ahi') + }) + + it('backfills params the caller omitted from the current route params', () => { + const target = withDefaultPathname( + { query: { preset: 'WARN' } }, + '/project/[ref]/advisors/security', + { ref: 'abc' } + ) + expect(resolveUrl(target)).toBe('/project/abc/advisors/security?preset=WARN') + }) + + it('caller-provided query values win over backfilled params', () => { + const target = withDefaultPathname({ query: { ref: 'other', page: '2' } }, '/project/[ref]', { + ref: 'abc', + }) + expect(resolveUrl(target)).toBe('/project/other?page=2') + }) + + it('never backfills the TanStack _splat param', () => { + const target = withDefaultPathname({ query: { a: '1' } }, '/org/_/[[...routeSlug]]', { + _splat: 'x/y', + }) + expect(resolveUrl(target)).toBe('/org/_/?a=1') + }) + + it('leaves string URLs, explicit pathnames, and raw-string queries untouched', () => { + expect(withDefaultPathname('/x?a=1', '/p/[ref]', { ref: 'r' })).toBe('/x?a=1') + expect( + withDefaultPathname({ pathname: '/y', query: { a: '1' } }, '/p/[ref]', { ref: 'r' }) + ).toEqual({ pathname: '/y', query: { a: '1' } }) + expect(withDefaultPathname({ query: 'a=1' }, '/p/[ref]', { ref: 'r' })).toEqual({ + query: 'a=1', + }) + }) +}) diff --git a/apps/studio/compat/next/router.ts b/apps/studio/compat/next/router.ts index 479b4234e84..d2ae293151c 100644 --- a/apps/studio/compat/next/router.ts +++ b/apps/studio/compat/next/router.ts @@ -4,10 +4,12 @@ import { useParams, useSearch, useRouter as useTanStackRouter, + type AnyRouter, } from '@tanstack/react-router' import { useMemo } from 'react' import { getRouterEventsProxy } from './_router-events' +import { splitInternalUrl } from '@/lib/internal-url' // Next's pages-router exposes `router.pathname` as the route *pattern* // (e.g. `/project/[ref]/sql/[id]`), not the resolved URL. TanStack's @@ -107,6 +109,45 @@ 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}` +} + +// Next resolves a pathname-less UrlObject against the *current route +// pattern*, re-consuming dynamic params from `query` into the path — e.g. +// `push({ query: { ...router.query, preset } })` on `/project/[ref]/advisors` +// stays on `/project//advisors?preset=…`. Because the shim's +// `router.query` merges path params in (Next shape), skipping this would leak +// `ref`/`id` into the query string. Params the caller didn't include are +// backfilled from the current route's params (minus TanStack's `_splat`, +// which no bracket segment can consume) so partial `{ query }` pushes stay +// on-page instead of producing empty path segments. +// Exported for unit tests (see router.test.ts) — not part of the Next surface. +export function withDefaultPathname( + url: string | UrlObject, + currentPathPattern: string, + currentParams: Record +): string | UrlObject { + if (typeof url === 'string' || url.pathname != null) return url + if (!url.query || typeof url.query !== 'object') return url + const { _splat, ...paramsWithoutSplat } = currentParams + return { + ...url, + pathname: currentPathPattern, + query: { ...paramsWithoutSplat, ...url.query }, + } +} + // 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 @@ -135,8 +176,8 @@ export function resolveUrl(url: string | UrlObject): string { // `basepath: '/dashboard'` and `to: '/foo'`, it produces `/dashboard/foo`. // So we strip the origin AND the basePath when present; otherwise // `router.push('/dashboard/...')` would double-prefix to -// `/dashboard/dashboard/...`. Mirrors the equivalent logic in the -// next/link shim's `splitInternalUrl`. Cross-origin URLs pass through +// `/dashboard/dashboard/...`. Mirrors the equivalent logic in +// `splitInternalUrl` (@/lib/internal-url). Cross-origin URLs pass through // untouched so TanStack hands them to the browser as external. const NEXT_PUBLIC_BASE_PATH = process.env.NEXT_PUBLIC_BASE_PATH ?? '' @@ -231,9 +272,33 @@ export function useRouter() { // It's intentionally not part of Next's public TransitionOptions. options?: TransitionOptions & { _replace?: boolean } ): Promise => { - const to = toRelativeSameOrigin(resolveUrl(url)) - // eslint-disable-next-line @typescript-eslint/no-explicit-any - await router.navigate({ to: to as any, replace: options?._replace }) + // `location.pathname` is already basepath-stripped by TanStack, so the + // prefixed target stays basepath-relative like every other `to`. + const target = resolveSearchOrHashOnlyTarget( + // `useParams({ strict: false })` types as possibly-undefined; treat + // "no params" as an empty record for backfilling. + toRelativeSameOrigin(resolveUrl(withDefaultPathname(url, pathPattern, params ?? {}))), + location.pathname + ) + // Never embed `?query`/`#hash` inside TanStack's `to` — router-core + // runs the whole string through path interpolation, which percent- + // decodes it and strips control characters (dropping `%0A` newlines + // from values like the Logs Explorer's `s` SQL param). Split into + // { to, search, hash } instead; the target is already relative and + // basepath-stripped, so splitInternalUrl's own origin/basePath + // normalisation is a no-op here. `search: {}` clears the query, + // matching Next's push-without-query semantics; same for `hash: ''`. + // + // The `` type arguments opt out of the registered + // route tree's strict typing: Next-style hrefs are free-form strings + // that can't satisfy the route-path union at compile time. + const { to, search, hash } = splitInternalUrl(target) + await router.navigate({ + to, + search: search ?? {}, + hash: hash ?? '', + replace: options?._replace, + }) return true } @@ -308,8 +373,16 @@ export function useRouter() { _options?: PrefetchOptions ): Promise => { try { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - await router.preloadRoute({ to: url as any }) + // Split like navigate() above so a query string in the href + // preloads the real target instead of a path-mangled one. + // `` (TFrom, TTo) loosens `to` to a plain string + // for the same free-form-href reason as `navigate` above. + const { to, search, hash } = splitInternalUrl(toRelativeSameOrigin(url)) + await router.preloadRoute({ + to, + search: search ?? {}, + hash: hash ?? '', + }) } catch { // Next's prefetch is fire-and-forget; swallow resolution errors // (e.g. unknown route) so callers don't have to guard. diff --git a/apps/studio/compat/sentry-nextjs.ts b/apps/studio/compat/sentry-nextjs.ts new file mode 100644 index 00000000000..8666c90b2ac --- /dev/null +++ b/apps/studio/compat/sentry-nextjs.ts @@ -0,0 +1,62 @@ +/** + * TanStack-build replacement for `@sentry/nextjs`. + * + * `@sentry/nextjs`'s client entry imports `next/dist/shared/lib/constants`, + * which evaluates `...(process?.features?.typescript ? ['next.config.mts'] : [])` + * at module scope. Optional chaining does NOT guard an undeclared `process` + * identifier in the browser, so any built chunk containing it throws + * `ReferenceError: process is not defined` at load time (observed on the + * table editor route). The Vite dev server shims `process`, so the crash + * only surfaces in the production/test build. + * + * vite.config.ts aliases `@sentry/nextjs` to this module for the TanStack + * build only — app source keeps importing `@sentry/nextjs`, and the Next + * build (`build:next`) is untouched. + * + * `@sentry/nextjs` re-exports `@sentry/react` (same version, 10.x) and adds + * Next-specific helpers on top. Everything Studio uses at runtime — + * `captureException`, `captureMessage`, `startSpan`, `withScope`, `setTag`, + * `setUser`, `init`, and the `Event` / `Breadcrumb` / `StackFrame` types — + * exists in `@sentry/react`, so re-export the lot. + */ +import { captureException } from '@sentry/react' + +// eslint-disable-next-line barrel-files/avoid-re-export-all -- compat shim: its whole job is to mirror @sentry/react's surface +export * from '@sentry/react' + +// --------------------------------------------------------------------------- +// Next-specific APIs with no `@sentry/react` equivalent. None of them are in +// the TanStack/Vite module graph today (they're only referenced from +// Next-convention files: instrumentation-client.ts, instrumentation.ts and +// next.config.ts), but provide explicit stand-ins so the alias can never +// produce an `undefined is not a function` crash if one leaks in later. +// --------------------------------------------------------------------------- + +/** + * `@sentry/nextjs`'s hook for Next's `onRouterTransitionStart` instrumentation + * event. TanStack Router navigation spans would instead come from + * `Sentry.tanstackRouterBrowserTracingIntegration` — no-op here. + */ +export function captureRouterTransitionStart(_href: string, _navigationType: string): void {} + +/** + * `@sentry/nextjs`'s hook for Next's `onRequestError` server instrumentation. + * Report the error through the regular capture pipeline instead. + */ +export function captureRequestError( + error: unknown, + _request: unknown, + _errorContext: unknown +): void { + // `captureException` from `@sentry/react` is isomorphic (no-op without an + // initialized client), so this is safe on both server and client. + captureException(error) +} + +/** + * Build-time-only wrapper for next.config — meaningless under Vite. Return + * the config unchanged so any accidental usage is inert. + */ +export function withSentryConfig(config: T, ..._options: unknown[]): T { + return config +} diff --git a/apps/studio/components/interfaces/BranchManagement/EdgeFunctionsDiffPanel.tsx b/apps/studio/components/interfaces/BranchManagement/EdgeFunctionsDiffPanel.tsx index 98b00272f4c..fdaa39ad42a 100644 --- a/apps/studio/components/interfaces/BranchManagement/EdgeFunctionsDiffPanel.tsx +++ b/apps/studio/components/interfaces/BranchManagement/EdgeFunctionsDiffPanel.tsx @@ -1,4 +1,3 @@ -import { basename } from 'path' import { IS_PLATFORM } from 'common' import { Circle, Code, Minus, Plus, Wind } from 'lucide-react' import Link from 'next/link' @@ -7,10 +6,11 @@ import { Card, CardContent, CardHeader, CardTitle, cn, Skeleton } from 'ui' import { DiffEditor } from '@/components/ui/DiffEditor' import type { EdgeFunctionBodyData } from '@/data/edge-functions/edge-function-body-query' -import type { - EdgeFunctionsDiffResult, - FileInfo, - FileStatus, +import { + fileKey, + type EdgeFunctionsDiffResult, + type FileInfo, + type FileStatus, } from '@/hooks/branches/useEdgeFunctionsDiff' import { EMPTY_ARR } from '@/lib/void' @@ -31,9 +31,6 @@ interface FunctionDiffProps { fileInfos: FileInfo[] } -// Helper to canonicalize file identifiers to prevent mismatch due to differing root paths -const fileKey = (fullPath: string) => basename(fullPath) - // Helper to get the status color for file indicators const getStatusColor = (status: FileStatus): string => { switch (status) { diff --git a/apps/studio/components/interfaces/Integrations/Integration/IntegrationOverviewTabV2/MarkdownContent.tsx b/apps/studio/components/interfaces/Integrations/Integration/IntegrationOverviewTabV2/MarkdownContent.tsx index e5fcb83e8e5..15b26ec07df 100644 --- a/apps/studio/components/interfaces/Integrations/Integration/IntegrationOverviewTabV2/MarkdownContent.tsx +++ b/apps/studio/components/interfaces/Integrations/Integration/IntegrationOverviewTabV2/MarkdownContent.tsx @@ -1,6 +1,8 @@ import { useEffect, useState } from 'react' import { Markdown } from 'ui-patterns/Markdown' +import { loadIntegrationOverview } from '@/static-data/integrations/overviews' + interface MarkdownContentProps { content: string | null | undefined integrationId?: string @@ -20,9 +22,9 @@ export const MarkdownContent = ({ if (!integrationId || remoteContent) return let cancelled = false - import(`@/static-data/integrations/${integrationId}/overview.md`) - .then((module) => { - if (!cancelled) setLocalContent(String(module.default)) + loadIntegrationOverview(integrationId) + .then((markdown) => { + if (!cancelled && markdown !== null) setLocalContent(markdown) }) .catch((error) => console.error('Error loading markdown:', error)) diff --git a/apps/studio/components/interfaces/Integrations/Integration/MarkdownContent.tsx b/apps/studio/components/interfaces/Integrations/Integration/MarkdownContent.tsx index 45a9c5d1f22..b156d96ad94 100644 --- a/apps/studio/components/interfaces/Integrations/Integration/MarkdownContent.tsx +++ b/apps/studio/components/interfaces/Integrations/Integration/MarkdownContent.tsx @@ -3,6 +3,7 @@ import { useEffect, useState } from 'react' import { cn } from 'ui' import { Markdown } from '@/components/interfaces/Markdown' +import { loadIntegrationOverview } from '@/static-data/integrations/overviews' const CHAR_LIMIT = 500 // Adjust this number as needed @@ -17,9 +18,16 @@ export const MarkdownContent = ({ const [isExpanded, setIsExpanded] = useState(initiallyExpanded ?? false) useEffect(() => { - import(`@/static-data/integrations/${integrationId}/overview.md`) - .then((module) => setContent(String(module.default))) + let cancelled = false + loadIntegrationOverview(integrationId) + .then((markdown) => { + if (!cancelled && markdown !== null) setContent(markdown) + }) .catch((error) => console.error('Error loading markdown:', error)) + + return () => { + cancelled = true + } }, [integrationId]) const displayContent = isExpanded ? content : content.slice(0, CHAR_LIMIT) diff --git a/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureActivity.tsx b/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureActivity.tsx index cde130f2aab..fb40475c709 100644 --- a/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureActivity.tsx +++ b/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureActivity.tsx @@ -438,7 +438,12 @@ export const InfrastructureActivity = () => { title="Looking for actual disk activity?" description="The chart above shows your remaining burst budget, not real disk throughput. For detailed read/write IOPS and throughput charts, head to the Database Observability page." > -