fix(studio): TanStack post-merge fixes — Monaco loader, fonts, CSP (from #46424) (#47657)

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=<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/<ref>/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/<ref>/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/<ref>/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`).


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## 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.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->


---

### 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 <joshenlimek@gmail.com>
This commit is contained in:
authored and GitHub committed 2026-07-08 14:52:59 +08:00
1 parent fa20667ec1
commit 18431efb25
39 files changed
+1728 -226

No files matched your search

+74
View File
@@ -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`,
+9 -85
View File
@@ -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<string, string>
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/
+29 -8
View File
@@ -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 `<AnyRouter, string>` 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<AnyRouter, string>({ 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<AnyRouter, string>({
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)
// `<string, string>` (TFrom, TTo) loosens `to` to a plain string for
// the same free-form-href reason as `navigate` above.
router
.preloadRoute<string, string>({ to, search: search ?? {}, hash: hash ?? '' })
.catch(() => {
// Match Next's fire-and-forget contract.
})
},
}),
[router]
+77 -1
View File
@@ -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',
})
})
})
+80 -7
View File
@@ -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/<ref>/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, QueryValue>
): 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<boolean> => {
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 `<AnyRouter, string>` 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<AnyRouter, string>({
to,
search: search ?? {},
hash: hash ?? '',
replace: options?._replace,
})
return true
}
@@ -308,8 +373,16 @@ export function useRouter() {
_options?: PrefetchOptions
): Promise<void> => {
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.
// `<string, string>` (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<string, string>({
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.
+62
View File
@@ -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<T>(config: T, ..._options: unknown[]): T {
return config
}
@@ -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) {
@@ -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))
@@ -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)
@@ -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."
>
<Button asChild variant="default" icon={<ChartLine size={14} />}>
<Button
asChild
variant="default"
className="mt-2"
icon={<ChartLine size={14} />}
>
<Link href={`/project/${projectRef}/observability/database`}>
View detailed IOPS and throughput
</Link>
+20 -18
View File
@@ -214,24 +214,26 @@ export const AIEditor = ({
})
if (language === 'javascript' || language === 'typescript') {
// The Deno libs are loaded as a raw text via raw-loader in next.config.ts. They're passed as raw text to the
// Monaco editor. The @vite-ignore stops Vite/Rolldown's dep scanner from following the dynamic import and
// trying to parse the .d.ts declarations as runtime modules — that crashes the scan in vite 8.0.13+ and
// cascades into SSR pre-bundling breakage.
import(/* @vite-ignore */ '@/public/deno/edge-runtime.d.ts' as string)
.then((module) => {
monaco.languages.typescript.typescriptDefaults.addExtraLib(module.default)
})
.catch((error) => {
console.error('Failed to load Deno edge-runtime typings:', error)
})
import(/* @vite-ignore */ '@/public/deno/lib.deno.d.ts' as string)
.then((module) => {
monaco.languages.typescript.typescriptDefaults.addExtraLib(module.default)
})
.catch((error) => {
console.error('Failed to load Deno lib typings:', error)
})
// The Deno libs load as raw text — via the raw-loader rules in
// next.config.ts (Next/turbopack) and the `rawTextLoader` plugin in
// vite.config.ts (TanStack/Vite) — and are registered as Monaco extra
// libs. The specifiers must stay string literals so both bundlers can
// statically analyze and code-split them; the `as string` cast keeps
// tsc from resolving the `.d.ts` files as declaration files (TS2846 /
// "not a module") while erasing to a plain literal for the bundlers.
const denoTypeLibs: Record<string, Promise<{ default: string }>> = {
'edge-runtime': import('@/public/deno/edge-runtime.d.ts' as string),
'lib.deno': import('@/public/deno/lib.deno.d.ts' as string),
}
for (const [lib, loading] of Object.entries(denoTypeLibs)) {
loading
.then((module) => {
monaco.languages.typescript.typescriptDefaults.addExtraLib(module.default)
})
.catch((error) => {
console.error(`Failed to load Deno ${lib} typings:`, error)
})
}
}
refreshCloseAction()
+3 -1
View File
@@ -188,7 +188,9 @@ export function getCSP() {
VERCEL_LIVE_URL,
].join(' ')
const fontSrcDirective = [`font-src 'self'`, ...FONT_SRC_URLS, VERCEL_LIVE_URL].join(' ')
// `data:` is required — GraphiQL's bundled Monaco ships the codicon icon font
// as a data: URI, and Stripe's payment element inlines its fonts the same way.
const fontSrcDirective = [`font-src 'self'`, `data:`, ...FONT_SRC_URLS, VERCEL_LIVE_URL].join(' ')
const workerSrcDirective = [`worker-src 'self'`, `blob:`, `data:`].join(' ')
@@ -1,4 +1,3 @@
import { basename } from 'path'
import { useQueries, useQueryClient } from '@tanstack/react-query'
import { useCallback, useMemo } from 'react'
@@ -46,8 +45,10 @@ export interface EdgeFunctionsDiffResult {
clearDiffsOptimistically: () => void
}
// Small helper around path.basename but avoids importing the full Node path lib for the browser bundle
const fileKey = (fullPath: string) => basename(fullPath)
// Equivalent of path.basename without importing Node's path lib — `path` isn't
// polyfilled in the browser bundle under Vite, so importing it crashes the route.
// Exported so EdgeFunctionsDiffPanel matches files with identical semantics.
export const fileKey = (fullPath: string) => fullPath.slice(fullPath.lastIndexOf('/') + 1)
export const useEdgeFunctionsDiff = ({
currentBranchRef,
@@ -0,0 +1,19 @@
import { loader } from '@monaco-editor/react'
import { BASE_PATH } from '@/lib/constants'
// [Ivan] Serve the Monaco assets locally from the public folder (see #47182, which
// dropped CDN loading in every environment and re-nested the assets under `vs/`). The
// worker bootstrap (vs/base/worker/workerMain.js) loads the language workers (e.g.
// tsWorker.js) via fetch() from inside the web worker. A root-relative path fails to
// resolve there in some browsers (Firefox throws "... is not a valid URL"), so we point
// `vs` at an absolute URL including the origin. Guarded on `window` since callers are
// also evaluated during SSR (where `window` is undefined and there's no editor to mount).
//
// Shared by both runtime entry points — `pages/_app.tsx` (Next) and
// `routes/__root.tsx` (TanStack) — so the asset path can't drift between them.
export function configureMonacoLoader() {
if (typeof window !== 'undefined') {
loader.config({ paths: { vs: `${window.location.origin}${BASE_PATH}/monaco-editor/vs` } })
}
}
+126
View File
@@ -0,0 +1,126 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { splitInternalUrl } from './internal-url'
describe('splitInternalUrl (compat shim URL splitting)', () => {
it('splits pathname, search, and hash', () => {
expect(splitInternalUrl('/project/abc/editor/123?schema=public#section')).toEqual({
to: '/project/abc/editor/123',
search: { schema: 'public' },
hash: 'section',
})
})
it('strips the leading "#" from the hash so TanStack does not produce "##section"', () => {
expect(splitInternalUrl('/org/slug/billing#invoices')).toEqual({
to: '/org/slug/billing',
search: undefined,
hash: 'invoices',
})
})
it('omits search and hash when absent', () => {
expect(splitInternalUrl('/projects')).toEqual({
to: '/projects',
search: undefined,
hash: undefined,
})
})
it('preserves repeated query keys as arrays', () => {
expect(splitInternalUrl('/advisors/security?filter=a:eq:1&filter=b:eq:2&page=2')).toEqual({
to: '/advisors/security',
search: { filter: ['a:eq:1', 'b:eq:2'], page: '2' },
hash: undefined,
})
})
it('keeps query values as plain strings (no JSON coercion)', () => {
expect(splitInternalUrl('/x?flag=true&page=2').search).toEqual({ flag: 'true', page: '2' })
})
it('decodes %0A (and other control chars) in query values losslessly', () => {
// The regression this split exists to prevent: query text embedded in a
// TanStack `to` goes through path interpolation, which strips control
// characters after percent-decoding. Splitting keeps the raw value.
const sql = 'select *\nfrom logs\norder by timestamp desc\nlimit 5'
const { to, search } = splitInternalUrl(
`/project/abc/logs/explorer?s=${encodeURIComponent(sql)}`
)
expect(to).toBe('/project/abc/logs/explorer')
expect(search).toEqual({ s: sql })
})
it('normalizes same-origin absolute URLs to a relative path', () => {
const url = new URL('/project/abc/editor/1?schema=public#top', window.location.origin)
expect(splitInternalUrl(url.toString())).toEqual({
to: '/project/abc/editor/1',
search: { schema: 'public' },
hash: 'top',
})
})
it('leaves cross-origin and protocol-relative URLs untouched', () => {
expect(splitInternalUrl('https://supabase.com/docs#install')).toEqual({
to: 'https://supabase.com/docs#install',
})
expect(splitInternalUrl('//supabase.com/docs')).toEqual({ to: '//supabase.com/docs' })
})
})
describe('splitInternalUrl under SSR (no window)', () => {
afterEach(() => {
vi.unstubAllGlobals()
})
it('leaves cross-origin absolute URLs untouched', () => {
vi.stubGlobal('window', undefined)
expect(splitInternalUrl('https://supabase.com/docs')).toEqual({
to: 'https://supabase.com/docs',
})
})
it('still splits relative paths', () => {
vi.stubGlobal('window', undefined)
expect(splitInternalUrl('/project/abc/editor/123?schema=public#section')).toEqual({
to: '/project/abc/editor/123',
search: { schema: 'public' },
hash: 'section',
})
})
})
describe('splitInternalUrl with a configured basePath', () => {
afterEach(() => {
vi.unstubAllEnvs()
vi.resetModules()
})
it('strips the basePath prefix so TanStack does not double-prefix', async () => {
vi.stubEnv('NEXT_PUBLIC_BASE_PATH', '/dashboard')
vi.resetModules()
const { splitInternalUrl: split } = await import('./internal-url')
expect(split('/dashboard/project/abc?schema=public#top')).toEqual({
to: '/project/abc',
search: { schema: 'public' },
hash: 'top',
})
expect(split('/dashboard')).toEqual({ to: '/', search: undefined, hash: undefined })
// A coincidental prefix is not stripped.
expect(split('/dashboard-other/x').to).toBe('/dashboard-other/x')
})
it('passes already basepath-relative inputs through unchanged', async () => {
// The router shim strips origin + basePath *before* splitting, so the
// input here is already relative. Double-stripping must be a no-op for
// every real studio route (none starts with the basePath segment).
vi.stubEnv('NEXT_PUBLIC_BASE_PATH', '/dashboard')
vi.resetModules()
const { splitInternalUrl: split } = await import('./internal-url')
expect(split('/project/abc/logs/explorer?s=select%201')).toEqual({
to: '/project/abc/logs/explorer',
search: { s: 'select 1' },
hash: undefined,
})
})
})
+104
View File
@@ -0,0 +1,104 @@
// Shared URL-splitting for the Next compat shims (next/link, next/router).
//
// TanStack's `to` is a route-pattern *path*; query params and hash must be
// passed separately via `search` / `hash`. Embedding `?query` (or `#hash`)
// inside `to` is not just a matching problem — router-core runs the whole
// `to` string through path interpolation (`interpolatePath` → `decodePath`
// → `decodeSegment` → `sanitizePathSegment`), which percent-DECODES it and
// then strips control characters (`/[\x00-\x1f\x7f]/`). Any `%0A` (newline)
// in a query value is silently deleted — e.g. the Logs Explorer's multi-line
// SQL in the `s` param loses its newlines, gluing `order by timestamp desc`
// and `limit 5` into one token. So every internal navigation target must be
// split into { to, search, hash } before it reaches TanStack.
//
// Studio code (and Next's own contract) routinely passes one of three
// URL 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`.
//
// 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. (Inputs that are *already* basepath-relative — like
// the router shim's pre-stripped targets — pass through unchanged, since
// no studio route pathname itself starts with the basePath segment.)
import { searchParamsToRecord, type SearchRecord } from './router-search-params'
// Inlined at build time via Vite's `define`. Must agree with Vite `base`
// and `tanstackStart({ router: { basepath } })`. Empty string when no
// basePath is configured.
const NEXT_PUBLIC_BASE_PATH = process.env.NEXT_PUBLIC_BASE_PATH ?? ''
export interface SplitInternalUrlResult {
to: string
search?: SearchRecord
hash?: string
}
export function splitInternalUrl(url: string): SplitInternalUrlResult {
// 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. Compare against
// the origin of the parse base (not `window` directly) so the check is
// intentionally window-independent: SSR and client agree, and an absolute
// external URL is never mistaken for an internal path on the server.
const baseOrigin = new URL(base).origin
if (parsed.origin !== baseOrigin) {
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) || '/'
}
// Repeated keys must survive as arrays (Object.fromEntries would keep only
// the last occurrence) — matches the router's Next-style parseSearch shape.
const search = searchParamsToRecord(parsed.searchParams)
// URL.hash includes the leading `#`; TanStack's `hash` prop expects the
// bare fragment and prepends its own `#` (passing it through would
// navigate to `##section` and break hash-scroll).
const hash = parsed.hash ? parsed.hash.slice(1) : undefined
return {
to: pathname,
search: Object.keys(search).length > 0 ? search : undefined,
hash,
}
}
@@ -0,0 +1,47 @@
import { describe, expect, it } from 'vitest'
import { buildSearchUpdateArgs } from './nuqs-tanstack-adapter'
describe('buildSearchUpdateArgs (custom nuqs adapter)', () => {
it('targets the current pathname with the search state as a record', () => {
// 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(buildSearchUpdateArgs('/auth/providers', new URLSearchParams('provider=apple'))).toEqual(
{ to: '/auth/providers', search: { provider: 'apple' } }
)
})
it('returns an empty search record when every param is cleared', () => {
// `search: {}` on TanStack navigate clears every param.
expect(buildSearchUpdateArgs('/auth/providers', new URLSearchParams())).toEqual({
to: '/auth/providers',
search: {},
})
})
it('keeps values verbatim, including newlines and other control chars', () => {
// The second regression we fixed: rendering the query string into the
// navigate `to` sent it through TanStack's path interpolation, which
// percent-decodes and strips control characters — Logs Explorer's
// multi-line SQL in `s` lost every newline (`desc\nlimit 5` became
// `desclimit 5`). Passing a search *record* keeps values untouched.
const sql = "select *\nfrom logs\nwhere source = 'edge'\norder by timestamp desc\nlimit 5"
const params = new URLSearchParams()
params.set('s', sql)
expect(buildSearchUpdateArgs('/project/abc/logs/explorer', params).search).toEqual({ s: sql })
})
it('preserves repeated keys as arrays', () => {
expect(
buildSearchUpdateArgs('/advisors/security', new URLSearchParams('f=a&f=b&page=2')).search
).toEqual({ f: ['a', 'b'], page: '2' })
})
it('strips a trailing slash from the pathname, but keeps the root "/"', () => {
const empty = new URLSearchParams()
expect(buildSearchUpdateArgs('/auth/providers/', empty).to).toBe('/auth/providers')
expect(buildSearchUpdateArgs('/', empty).to).toBe('/')
expect(buildSearchUpdateArgs('', empty).to).toBe('/')
})
})
+91
View File
@@ -0,0 +1,91 @@
import { useLocation, useNavigate, type AnyRouter } from '@tanstack/react-router'
import { unstable_createAdapterProvider } from 'nuqs/adapters/custom'
import { startTransition, useCallback, useMemo } from 'react'
import { searchParamsToRecord, type SearchRecord } from './router-search-params'
// Custom nuqs adapter for TanStack Router.
//
// The stock `nuqs/adapters/tanstack-router` adapter navigates with
// `navigate({ to: renderQueryString(search) || '.', from })`. That shape is
// doubly broken for us:
// - TanStack resolves a `?`-only relative `to` by *appending* it to the
// current path, injecting a trailing slash — every nuqs write turns
// `/auth/providers?provider=x` into `/auth/providers/?provider=x`.
// - Embedding the query string in `to` at all sends it through TanStack's
// path interpolation, which percent-decodes and then strips control
// characters — `%0A` newlines in values (e.g. Logs Explorer SQL in `s`)
// are silently deleted.
// So we navigate with `to` = the current pathname and the query as a
// TanStack `search` *object*, which the router serialises through the app's
// Next-style codec (lib/router-search-params) without touching the path
// pipeline. 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 nuqs-updated URLSearchParams into TanStack navigate args:
// `to` is the current pathname (from TanStack's parsed location, already
// basepath-stripped; guard against a trailing slash anyway — root stays `/`)
// and `search` is the FULL desired search state as a record ({} correctly
// clears every param). Exported for unit tests — not part of the adapter
// surface.
export function buildSearchUpdateArgs(
pathname: string,
search: URLSearchParams
): { to: string; search: SearchRecord } {
let to = pathname || '/'
if (to.length > 1 && to.endsWith('/')) to = to.slice(0, -1)
return { to, search: searchParamsToRecord(search) }
}
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) => {
const args = buildSearchUpdateArgs(pathname, search)
startTransition(() => {
// The `<AnyRouter, string>` type arguments opt out of the registered
// route tree's strict typing: the current pathname is a free-form
// runtime string that can't satisfy the route-path union at compile
// time.
navigate<AnyRouter, string>({
to: args.to,
search: args.search,
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)
@@ -0,0 +1,123 @@
import { describe, expect, it } from 'vitest'
import { parseSearch, searchParamsToRecord, stringifySearch } from './router-search-params'
describe('parseSearch (Next-style semantics)', () => {
it('returns an empty record for empty input', () => {
expect(parseSearch('')).toEqual({})
expect(parseSearch('?')).toEqual({})
})
it('accepts input with or without the leading "?"', () => {
expect(parseSearch('?a=1')).toEqual({ a: '1' })
expect(parseSearch('a=1')).toEqual({ a: '1' })
})
it('keeps every value a plain string — no JSON parsing or type coercion', () => {
expect(parseSearch('?page=2&flag=true&nul=null')).toEqual({
page: '2',
flag: 'true',
nul: 'null',
})
})
it('does not parse JSON-looking text into objects', () => {
expect(parseSearch(`?q=${encodeURIComponent('{"a":1}')}`)).toEqual({ q: '{"a":1}' })
})
it('collects repeated keys into a string array, in order', () => {
expect(parseSearch('?filter=a:eq:1&filter=b:eq:2&page=2')).toEqual({
filter: ['a:eq:1', 'b:eq:2'],
page: '2',
})
})
it('decodes percent-encoding exactly once', () => {
// `%3Aeq%3A1` must decode to `:eq:1`, not stay encoded or double-decode.
expect(parseSearch('?sort=name%3Aasc')).toEqual({ sort: 'name:asc' })
expect(parseSearch('?v=%2540')).toEqual({ v: '%40' })
})
it('decodes "+" as a space (URLSearchParams / Next behavior)', () => {
expect(parseSearch('?q=hello+world')).toEqual({ q: 'hello world' })
expect(parseSearch('?q=hello%20world')).toEqual({ q: 'hello world' })
})
it('preserves empty-string values and bare keys', () => {
expect(parseSearch('?a=&b')).toEqual({ a: '', b: '' })
})
it('decodes unicode values', () => {
expect(parseSearch(`?name=${encodeURIComponent('日本語 déjà')}`)).toEqual({
name: '日本語 déjà',
})
})
})
describe('stringifySearch', () => {
it('returns an empty string for an empty record', () => {
expect(stringifySearch({})).toBe('')
})
it('never quotes strings, even ones that are valid JSON', () => {
expect(stringifySearch({ flag: 'true', page: '2' })).toBe('?flag=true&page=2')
expect(stringifySearch({ q: '{"a":1}' })).toBe('?q=%7B%22a%22%3A1%7D')
})
it('emits repeated keys for arrays', () => {
expect(stringifySearch({ filter: ['a', 'b'], x: '1' })).toBe('?filter=a&filter=b&x=1')
})
it('serializes numbers and booleans from TanStack-native code with String()', () => {
expect(stringifySearch({ page: 2, flag: true })).toBe('?page=2&flag=true')
})
it('omits null and undefined values (including inside arrays)', () => {
expect(stringifySearch({ a: undefined, b: null, c: 'x' })).toBe('?c=x')
expect(stringifySearch({ a: ['x', null, undefined, 'y'] })).toBe('?a=x&a=y')
expect(stringifySearch({ a: undefined })).toBe('')
})
it('keeps empty-string values', () => {
expect(stringifySearch({ a: '' })).toBe('?a=')
})
})
describe('round-trip stability', () => {
const roundTrip = (searchStr: string) => stringifySearch(parseSearch(searchStr))
it('is byte-stable for typical Studio URLs', () => {
for (const input of [
'?page=2&flag=true',
'?filter=a%3Aeq%3A1&filter=b%3Aeq%3A2&page=2&flag=true',
'?provider=apple',
'?q=hello+world',
'?a=&b=1',
]) {
expect(roundTrip(input)).toBe(input)
// A second pass must not change anything either.
expect(roundTrip(roundTrip(input))).toBe(roundTrip(input))
}
})
it('reaches a stable encoding after one pass for unencoded input', () => {
// `:` normalizes to `%3A` (standard URLSearchParams encoding), then stays put.
const once = roundTrip('?filter=a:eq:1&filter=b:eq:2')
expect(once).toBe('?filter=a%3Aeq%3A1&filter=b%3Aeq%3A2')
expect(roundTrip(once)).toBe(once)
})
it('round-trips unicode', () => {
const input = `?name=${encodeURIComponent('日本語')}`
expect(roundTrip(input)).toBe(input)
})
})
describe('searchParamsToRecord', () => {
it('collapses URLSearchParams into the Next query shape', () => {
expect(searchParamsToRecord(new URLSearchParams('a=1&b=2&b=3'))).toEqual({
a: '1',
b: ['2', '3'],
})
})
})
+54
View File
@@ -0,0 +1,54 @@
// Next-style search-param semantics for TanStack Router.
//
// TanStack's default `parseSearch`/`stringifySearch` are JSON-first: parsing
// coerces `"2"` → 2, `"true"` → true and JSON text → objects; stringifying
// JSON-encodes arrays/objects and wraps any string that happens to be valid
// JSON in quotes (`?flag=%22true%22`), and can never emit repeated keys.
// Studio was written against Next's pages-router semantics — every search
// value is a plain string, repeated keys become string arrays — so we
// implement those semantics here and hand them to `createRouter`.
//
// Must stay safe to run during SSR/prerender: no `window`/`document` access.
export type SearchRecord = Record<string, string | string[]>
// Collapse a URLSearchParams into Next's `query` shape: single occurrence →
// string, repeated key → string[] (in order of appearance).
export function searchParamsToRecord(params: URLSearchParams): SearchRecord {
const result: SearchRecord = {}
for (const key of params.keys()) {
if (Object.prototype.hasOwnProperty.call(result, key)) continue
const all = params.getAll(key)
result[key] = all.length === 1 ? all[0] : all
}
return result
}
// `searchStr` is the raw search string, with or without the leading `?`.
// URLSearchParams handles percent-decoding and `+`-as-space exactly like
// Next's pages router did. No JSON parsing, no type coercion.
export function parseSearch(searchStr: string): SearchRecord {
if (!searchStr || searchStr === '?') return {}
return searchParamsToRecord(new URLSearchParams(searchStr))
}
// Values are usually strings/string[] (round-tripped from `parseSearch`),
// but TanStack-native call sites may pass numbers/booleans — serialize those
// with String(), never JSON (no added quotes). null/undefined are omitted.
// Returns '' or a `?`-prefixed string, matching TanStack's contract.
export function stringifySearch(search: Record<string, unknown>): string {
const params = new URLSearchParams()
for (const [key, value] of Object.entries(search)) {
if (value === undefined || value === null) continue
if (Array.isArray(value)) {
for (const item of value) {
if (item === undefined || item === null) continue
params.append(key, String(item))
}
} else {
params.append(key, String(value))
}
}
const str = params.toString()
return str ? `?${str}` : ''
}
+186
View File
@@ -0,0 +1,186 @@
import {
createMemoryHistory,
createRootRoute,
createRoute,
createRouter,
type AnyRouter,
} from '@tanstack/react-router'
import { describe, expect, it } from 'vitest'
import {
resolveSearchOrHashOnlyTarget,
resolveUrl,
withDefaultPathname,
} from '../compat/next/router'
import { splitInternalUrl } from './internal-url'
import { buildSearchUpdateArgs } from './nuqs-tanstack-adapter'
import { parseSearch, stringifySearch, type SearchRecord } from './router-search-params'
// The `<AnyRouter, string>` type arguments mirror the compat shims: the
// producers under test emit free-form runtime strings that can't satisfy
// the route-path union at compile time.
interface LooseNavigateArgs {
to: string
search?: SearchRecord
hash?: string | ((prevHash: string | undefined) => string)
replace?: boolean
}
// Integration guard for the family of URL-shape regressions from the
// TanStack migration: drives a real router (with the app's custom search
// codec) through the navigation shapes the compat shims and the nuqs
// adapter produce, and asserts the built hrefs are Next-shaped — no JSON
// coercion/quoting, no dropped repeated keys, no injected trailing slash,
// no doubled `#`, and no control characters stripped from query values.
describe('URL shape end-to-end against a real router', () => {
function makeRouter(initialEntry: string) {
const rootRoute = createRootRoute({})
const routes = [
'/project/$ref/advisors/security',
'/project/$ref/auth/providers',
'/project/$ref/logs/explorer',
'/org',
].map((p) => createRoute({ getParentRoute: () => rootRoute, path: p }))
rootRoute.addChildren(routes)
return createRouter({
routeTree: rootRoute,
history: createMemoryHistory({ initialEntries: [initialEntry] }),
parseSearch,
stringifySearch,
})
}
it('produces Next-shaped URLs for every fixed producer', async () => {
const router = makeRouter(
'/project/abc/advisors/security?preset=security&filter=a:eq:1&filter=b:eq:2'
)
await router.load()
const build = (opts: LooseNavigateArgs) => router.buildLocation<AnyRouter, string>(opts).href
// Custom codec: strings and repeated-key arrays, no JSON coercion.
expect(router.state.location.search).toEqual({
preset: 'security',
filter: ['a:eq:1', 'b:eq:2'],
})
// Compat router push({ query }) resolves to a ?-only target, split into
// { to, search }; must stay on the exact current pathname (no
// `/advisors/security/?...`).
const target = resolveSearchOrHashOnlyTarget('?preset=WARN', router.state.location.pathname)
const split = splitInternalUrl(target)
expect(build({ to: split.to, search: split.search ?? {}, hash: split.hash ?? '' })).toBe(
'/project/abc/advisors/security?preset=WARN'
)
// nuqs write over the whole page query — same guarantee. (The codec
// percent-encodes `:` as `%3A` — URLSearchParams semantics, matching the
// compat router's own serialisation — and it re-parses losslessly.)
const params = new URLSearchParams(router.state.location.searchStr)
params.set('preset', 'perf')
const nuqsHref = build({
...buildSearchUpdateArgs(router.state.location.pathname, params),
hash: (h: string | undefined) => h ?? '',
})
expect(nuqsHref).toBe(
'/project/abc/advisors/security?preset=perf&filter=a%3Aeq%3A1&filter=b%3Aeq%3A2'
)
expect(parseSearch(nuqsHref.split('?')[1])).toEqual({
preset: 'perf',
filter: ['a:eq:1', 'b:eq:2'],
})
// nuqs clear-all keeps the exact pathname.
expect(
build(buildSearchUpdateArgs(router.state.location.pathname, new URLSearchParams()))
).toBe('/project/abc/advisors/security')
// A bare fragment (the Link shim's hash shape) produces a single '#'.
expect(build({ to: '/org', hash: 'invoices' })).toBe('/org#invoices')
})
// The Logs Explorer regression: query values with newlines. Embedding the
// query string inside `to` sends it through TanStack's path interpolation
// (`interpolatePath` → `decodePath` → `sanitizePathSegment`), which
// percent-decodes and then strips control chars — every `%0A` was deleted,
// gluing `order by timestamp desc` and `limit 5` into `desclimit 5`.
const sql = [
'select',
' timestamp,',
' event_message',
'from logs',
"where source = 'edge_logs'",
'order by timestamp desc',
'limit 5',
].join('\n')
it('round-trips newlines in query values through the nuqs producer shape', async () => {
const router = makeRouter('/project/abc/logs/explorer')
await router.load()
// Shape produced by the nuqs adapter's updateUrl: to = current pathname,
// search = full record.
const params = new URLSearchParams()
params.set('s', sql)
params.set('its', 'PREVIOUS')
const args = buildSearchUpdateArgs(router.state.location.pathname, params)
await router.navigate<AnyRouter, string>({
to: args.to,
search: args.search,
hash: (h) => h ?? '',
replace: true,
})
expect(router.state.location.pathname).toBe('/project/abc/logs/explorer')
expect(router.state.location.search).toEqual({ s: sql, its: 'PREVIOUS' })
// The href itself keeps the %0A escapes and re-parses losslessly.
expect(router.state.location.searchStr).toContain('%0A')
expect(parseSearch(router.state.location.searchStr)).toEqual({ s: sql, its: 'PREVIOUS' })
expect((router.state.location.search as { s: string }).s).toContain('desc\nlimit 5')
})
it('round-trips newlines in query values through the compat router producer shape', async () => {
const router = makeRouter('/project/abc/logs/explorer')
await router.load()
// Shape produced by the compat useRouter().push pipeline:
// withDefaultPathname → resolveUrl → (toRelativeSameOrigin) →
// resolveSearchOrHashOnlyTarget → splitInternalUrl → navigate.
const target = resolveSearchOrHashOnlyTarget(
resolveUrl(
withDefaultPathname(
{ query: { ref: 'abc', s: sql, its: 'PREVIOUS' } },
'/project/[ref]/logs/explorer',
{ ref: 'abc' }
)
),
router.state.location.pathname
)
const { to, search, hash } = splitInternalUrl(target)
await router.navigate<AnyRouter, string>({
to,
search: search ?? {},
hash: hash ?? '',
})
expect(router.state.location.pathname).toBe('/project/abc/logs/explorer')
expect(router.state.location.search).toEqual({ s: sql, its: 'PREVIOUS' })
expect((router.state.location.search as { s: string }).s).toContain('desc\nlimit 5')
})
it('clears the previous query when the compat producer navigates without one', async () => {
const router = makeRouter('/project/abc/logs/explorer?s=stale')
await router.load()
// Next's push('/path') drops the current query entirely — `search: {}`
// (not undefined) is what encodes that under TanStack.
const { to, search, hash } = splitInternalUrl('/project/abc/auth/providers')
await router.navigate<AnyRouter, string>({
to,
search: search ?? {},
hash: hash ?? '',
})
expect(router.state.location.href).toBe('/project/abc/auth/providers')
expect(router.state.location.search).toEqual({})
})
})
+1 -1
View File
@@ -100,7 +100,7 @@ const nextConfig = {
},
{
key: 'X-Content-Type-Options',
value: 'no-sniff',
value: 'nosniff',
},
{
key: 'Strict-Transport-Security',
+2 -9
View File
@@ -13,7 +13,6 @@ import '@/styles/ui.css'
import 'react-data-grid/lib/styles.css'
import 'ui-patterns/ShimmeringLoader/index.css'
import { loader } from '@monaco-editor/react'
import * as Sentry from '@sentry/nextjs'
import { HydrationBoundary, QueryClientProvider } from '@tanstack/react-query'
import { ReactQueryDevtools } from '@tanstack/react-query-devtools'
@@ -56,6 +55,7 @@ import { inter, manrope, sourceCodePro } from '@/fonts'
import { useCustomContent } from '@/hooks/custom-content/useCustomContent'
import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization'
import { AuthProvider } from '@/lib/auth'
import { configureMonacoLoader } from '@/lib/configure-monaco-loader'
import { API_URL, BASE_PATH, IS_PLATFORM, useDefaultProvider } from '@/lib/constants'
import { TimezoneProvider, useTimezone } from '@/lib/datetime'
import { ProfileProvider } from '@/lib/profile'
@@ -118,14 +118,7 @@ const TimestampInfoTimezoneBridge = ({ children }: { children: React.ReactNode }
return <TimestampInfoProvider timezone={timezone}>{children}</TimestampInfoProvider>
}
// [Ivan] Serve the Monaco assets locally from the public folder. The worker bootstrap
// (vs/base/worker/workerMain.js) loads the language workers (e.g. tsWorker.js) via fetch()
// from inside the web worker. A root-relative path fails to resolve there in some browsers
// (Firefox throws "... is not a valid URL"), so we point `vs` at an absolute URL including
// the origin. Guarded on `window` since this module is also evaluated during SSR.
if (typeof window !== 'undefined') {
loader.config({ paths: { vs: `${window.location.origin}${BASE_PATH}/monaco-editor/vs` } })
}
configureMonacoLoader()
// [Joshen TODO] Once we settle on the new nav layout - we'll need a lot of clean up in terms of our layout components
// a lot of them are unnecessary and introduce way too many cluttered CSS especially with the height styles that make
+119
View File
@@ -0,0 +1,119 @@
import { describe, expect, it } from 'vitest'
import { matchRedirect, preserveQueryAndHash } from './redirects.shared'
describe('preserveQueryAndHash', () => {
it('carries incoming query params onto the destination', () => {
expect(preserveQueryAndHash('/org', { foo: '1', bar: 'x' })).toBe('/org?foo=1&bar=x')
})
it('returns the destination untouched when there is nothing to carry', () => {
expect(preserveQueryAndHash('/org', {})).toBe('/org')
expect(preserveQueryAndHash('/org', new URLSearchParams())).toBe('/org')
})
it('drops params consumed by the matched rule', () => {
expect(
preserveQueryAndHash(
'/new/new-project',
{ next: 'new-project', a: '1' },
{
consumedKeys: ['next'],
}
)
).toBe('/new/new-project?a=1')
})
it("lets the destination's own params win on conflict", () => {
expect(
preserveQueryAndHash('/org/_/billing?panel=subscriptionPlan', { panel: 'other', x: '1' })
).toBe('/org/_/billing?panel=subscriptionPlan&x=1')
})
it('preserves repeated keys and array values', () => {
expect(preserveQueryAndHash('/dest', new URLSearchParams('f=a&f=b'))).toBe('/dest?f=a&f=b')
expect(preserveQueryAndHash('/dest', { f: ['a', 'b'] })).toBe('/dest?f=a&f=b')
})
it('carries the incoming hash', () => {
expect(preserveQueryAndHash('/dest', { a: '1' }, { hash: 'section' })).toBe('/dest?a=1#section')
expect(preserveQueryAndHash('/dest', {}, { hash: 'section' })).toBe('/dest#section')
})
it("lets the destination's own hash win over the incoming one", () => {
expect(preserveQueryAndHash('/org/slug/billing#invoices', { a: '1' }, { hash: 'other' })).toBe(
'/org/slug/billing?a=1#invoices'
)
})
it('skips undefined values in a record search', () => {
expect(preserveQueryAndHash('/dest', { a: undefined, b: '1' })).toBe('/dest?b=1')
})
})
describe('matchRedirect query/hash preservation', () => {
it('carries the incoming query and hash through a plain rule', () => {
expect(
matchRedirect({
pathname: '/project/abc/sql/quickstarts',
search: { template: 'countries', flag: 'true' },
isPlatform: true,
hash: 'top',
})
).toEqual({
destination: '/project/abc/sql/examples?template=countries&flag=true#top',
permanent: true,
})
})
it('consumes `has` query keys but keeps the rest', () => {
expect(
matchRedirect({
pathname: '/',
search: { next: 'new-project', projectName: 'foo' },
isPlatform: true,
})
).toEqual({ destination: '/new/new-project?projectName=foo', permanent: false })
})
it("keeps the destination's own params when the incoming query repeats them", () => {
expect(
matchRedirect({
pathname: '/project/abc/settings/billing/subscription',
search: { panel: 'pitr', source: 'email' },
isPlatform: true,
})
).toEqual({
destination: '/project/abc/settings/addons?panel=pitr&source=email',
permanent: true,
})
})
it('keeps a destination hash (e.g. billing#invoices) over the incoming hash', () => {
expect(
matchRedirect({
pathname: '/org/my-org/invoices',
search: {},
isPlatform: true,
hash: 'ignored',
})
).toEqual({ destination: '/org/my-org/billing#invoices', permanent: true })
})
it('leaves plain redirects without query or hash untouched', () => {
expect(matchRedirect({ pathname: '/', search: {}, isPlatform: true })).toEqual({
destination: '/org',
permanent: false,
})
expect(matchRedirect({ pathname: '/', search: {}, isPlatform: false })).toEqual({
destination: '/project/default',
permanent: false,
})
})
it('still returns null for non-matching paths', () => {
expect(
matchRedirect({ pathname: '/project/abc/editor', search: { a: '1' }, isPlatform: true })
).toBeNull()
})
})
+56 -4
View File
@@ -393,23 +393,70 @@ function hasQueryMatches(
return has.every((h) => h.type === 'query' && get(h.key) === h.value)
}
// Merge the incoming request's query params (and hash) onto a redirect
// destination, mirroring Next.js `redirects()` semantics: the query string is
// carried through to the destination; params the destination already sets
// win; params consumed by the matched rule (its `has` query keys) are
// dropped; a hash on the destination wins over the incoming one. `hash` is
// the bare fragment without the leading `#`. Runtime-agnostic (also usable
// from next.config / Vercel edge). Exported for unit tests.
export function preserveQueryAndHash(
destination: string,
search: URLSearchParams | Record<string, string | string[] | undefined>,
options: { consumedKeys?: string[]; hash?: string } = {}
): string {
const { consumedKeys = [], hash = '' } = options
const hashIdx = destination.indexOf('#')
const destHash = hashIdx >= 0 ? destination.slice(hashIdx + 1) : ''
const beforeHash = hashIdx >= 0 ? destination.slice(0, hashIdx) : destination
const queryIdx = beforeHash.indexOf('?')
const destPath = queryIdx >= 0 ? beforeHash.slice(0, queryIdx) : beforeHash
const params = new URLSearchParams(queryIdx >= 0 ? beforeHash.slice(queryIdx + 1) : '')
const incoming =
search instanceof URLSearchParams
? search
: new URLSearchParams(
Object.entries(search).flatMap(([key, value]): Array<[string, string]> => {
if (value === undefined || value === null) return []
if (Array.isArray(value)) return value.map((v) => [key, v])
return [[key, value]]
})
)
for (const key of new Set(incoming.keys())) {
if (consumedKeys.includes(key)) continue
if (params.has(key)) continue // destination's own value wins
for (const value of incoming.getAll(key)) params.append(key, value)
}
const queryStr = params.toString()
const finalHash = destHash || hash
return `${destPath}${queryStr ? `?${queryStr}` : ''}${finalHash ? `#${finalHash}` : ''}`
}
export function matchRedirect(input: {
pathname: string
search: URLSearchParams | Record<string, string | string[] | undefined>
isPlatform: boolean
maintenanceMode?: boolean
// Bare fragment without the leading `#` (TanStack's `location.hash` shape).
hash?: string
}): RedirectMatch | null {
const { pathname, search, isPlatform, maintenanceMode = false } = input
const { pathname, search, isPlatform, maintenanceMode = false, hash } = input
// Maintenance mode handled inline — the maintenance-on rule uses a
// negative-lookahead regex source that the segment matcher above can't
// parse. Cheap to special-case here.
if (maintenanceMode) {
if (!pathname.startsWith('/maintenance') && !pathname.startsWith('/img')) {
return { destination: '/maintenance', permanent: false }
return {
destination: preserveQueryAndHash('/maintenance', search, { hash }),
permanent: false,
}
}
} else if (pathname === '/maintenance') {
return { destination: '/', permanent: false }
return { destination: preserveQueryAndHash('/', search, { hash }), permanent: false }
}
const ordered = [
@@ -421,7 +468,12 @@ export function matchRedirect(input: {
if (!params) continue
if (!hasQueryMatches(rule.has, search)) continue
return {
destination: substituteDestination(rule.destination, params),
// Next.js carries the incoming query and hash through to the
// destination; keys the rule matched on (`has`) are consumed.
destination: preserveQueryAndHash(substituteDestination(rule.destination, params), search, {
consumedKeys: rule.has?.map((h) => h.key) ?? [],
hash,
}),
permanent: rule.permanent,
}
}
+7
View File
@@ -5,6 +5,7 @@ import { setupRouterSsrQueryIntegration } from '@tanstack/react-router-ssr-query
import { routeTree } from './routeTree.gen'
import { getQueryClient } from '@/data/query-client'
import { BASE_PATH, IS_PLATFORM } from '@/lib/constants'
import { parseSearch, stringifySearch } from '@/lib/router-search-params'
export interface RouterContext {
queryClient: QueryClient
@@ -75,6 +76,12 @@ export function getRouter() {
context,
scrollRestoration: true,
defaultPreload: 'intent',
// Next-style search params (plain strings, repeated keys → arrays)
// instead of TanStack's JSON defaults, which coerce "2"→2/"true"→true
// and JSON-quote strings on write. The whole app — including the
// next/router compat shim and nuqs — expects the Next semantics.
parseSearch,
stringifySearch,
// Inlined via Vite's `define` at build time; stays undefined (= app at `/`)
// unless NEXT_PUBLIC_BASE_PATH is set. Must agree with Vite `base`
basepath: process.env.NEXT_PUBLIC_BASE_PATH || undefined,
+20 -24
View File
@@ -20,7 +20,6 @@ import '@/styles/stripe.css'
import '@/styles/ui.css'
import 'ui-patterns/ShimmeringLoader/index.css'
import { loader } from '@monaco-editor/react'
import * as Sentry from '@sentry/react'
import { TanStackDevtools } from '@tanstack/react-devtools'
import type { QueryClient } from '@tanstack/react-query'
@@ -47,7 +46,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,
@@ -72,7 +70,11 @@ import { GlobalErrorBoundaryState } from '@/components/ui/ErrorBoundary/GlobalEr
import { useCustomContent } from '@/hooks/custom-content/useCustomContent'
import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization'
import { AuthProvider } from '@/lib/auth'
import { configureMonacoLoader } from '@/lib/configure-monaco-loader'
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'
@@ -104,11 +106,13 @@ const FeatureFlagProviderWithOrgContext = ({
)
}
// Keep dev-only components out of the production bundle.
const IS_DEV_TOOLBAR_ENABLED =
const IS_NON_PROD_ENV =
process.env.NEXT_PUBLIC_ENVIRONMENT === 'local' ||
process.env.NEXT_PUBLIC_ENVIRONMENT === 'staging'
// Keep dev-only components out of the production bundle.
const IS_DEV_TOOLBAR_ENABLED = IS_NON_PROD_ENV
const ResourceWarningsTab = IS_DEV_TOOLBAR_ENABLED
? lazy(() =>
import('@/components/ui/DevToolbar/ResourceWarningsTab').then((m) => ({
@@ -131,20 +135,14 @@ const devToolbarExtraTabs: ExtraTab[] = IS_DEV_TOOLBAR_ENABLED
]
: []
// [Joshen] Attempt for offline support/bypass ISP issues is to store the assets required for monaco
// locally. We're however, only storing the assets which we need (based on what the network tab loads
// while using monaco). If we end up facing more effort trying to maintain this, probably to either
// use cloudflare or find some way to pull all the files from a CDN via a CLI, rather than tracking individual files
// The alternative was to import * as monaco from 'monaco-editor' but i couldn't get it working
loader.config({
paths: {
vs: IS_PLATFORM
? 'https://cdnjs.cloudflare.com/ajax/libs/monaco-editor/0.52.2/min/vs'
: `${BASE_PATH}/monaco-editor`,
},
})
configureMonacoLoader()
const FAVICON_ROUTE = '/favicon'
// Non-prod (local + hosted staging) uses the white favicon, matching the Next
// build (pages/_app.tsx passes `/favicon/staging` to MetaFaviconsPagesRouter for
// non-prod). Uses the same synchronous NEXT_PUBLIC_ENVIRONMENT signal as
// IS_DEV_TOOLBAR_ENABLED above, so it works at module scope (the `head()` route
// option isn't a React component and can't run the async CLI check _app does).
const FAVICON_ROUTE = IS_NON_PROD_ENV ? '/favicon/staging' : '/favicon'
const THEME_COLOR = '1E1E1E'
const APPLICATION_NAME = 'Supabase Studio'
@@ -200,13 +198,10 @@ function buildRootHead() {
]
const links: Array<Record<string, string>> = [
// Google Fonts — Source Code Pro (the local CustomFont is declared in styles/fonts.css).
{ rel: 'preconnect', href: 'https://fonts.googleapis.com' },
{ rel: 'preconnect', href: 'https://fonts.gstatic.com', crossOrigin: '' },
{
rel: 'stylesheet',
href: 'https://fonts.googleapis.com/css2?family=Source+Code+Pro:wght@400;500;600;700&display=swap',
},
// Fonts (Inter, Manrope, Source Code Pro) are all vendored via @font-face in
// styles/fonts.css — no Google Fonts CDN dependency at runtime, matching how
// next/font self-hosts them on the Next build (and keeping self-hosted/offline
// studio working).
...APPLE_TOUCH_ICON_SIZES.map((size) => ({
rel: 'apple-touch-icon-precomposed',
sizes: size,
@@ -288,6 +283,7 @@ export const Route = createRootRouteWithContext<RouterContext>()({
pathname: location.pathname,
search: location.search as Record<string, string | string[] | undefined>,
isPlatform: IS_PLATFORM,
hash: location.hash,
})
if (!match) return
const href = BASE_PATH ? `${BASE_PATH}${match.destination}` : match.destination
+16 -4
View File
@@ -1,6 +1,7 @@
import { createFileRoute, redirect } from '@tanstack/react-router'
import { IS_PLATFORM } from '@/lib/constants'
import { stringifySearch } from '@/lib/router-search-params'
// `/` is never rendered — it always redirects. Mirrors the Next.js
// `redirects()` rules in next.config.ts: platform sends users to `/org`
@@ -10,15 +11,26 @@ export const Route = createFileRoute('/')({
validateSearch: (search: Record<string, unknown>) => ({
next: typeof search.next === 'string' ? search.next : undefined,
}),
beforeLoad: ({ search }) => {
beforeLoad: ({ search, location }) => {
// Next's redirects() carries the incoming query and hash through to the
// destination — deep links like `/?next=new-project&projectName=x` must
// keep `projectName`. Only the consumed `next` param is dropped (and only
// when it matched); everything else passes through.
const suffix = (shouldConsumeNext: boolean) => {
const carried = { ...location.search } as Record<string, unknown>
if (shouldConsumeNext) delete carried.next
return `${stringifySearch(carried)}${location.hash ? `#${location.hash}` : ''}`
}
// `href` instead of `to` because these targets aren't in the TanStack
// routeTree yet — they're still on the Next.js pages side during the
// migration. Swap to `to` once `/org`, `/new/new-project`, and
// `/project/default` are migrated.
if (IS_PLATFORM) {
if (search.next === 'new-project') throw redirect({ href: '/new/new-project' })
throw redirect({ href: '/org' })
if (search.next === 'new-project') {
throw redirect({ href: `/new/new-project${suffix(true)}` })
}
throw redirect({ href: `/org${suffix(false)}` })
}
throw redirect({ href: '/project/default' })
throw redirect({ href: `/project/default${suffix(false)}` })
},
})
+13
View File
@@ -164,9 +164,22 @@ async function pipeWebResponse(response, res) {
})
}
// Security headers for the self-hosted server. Mirrors the non-platform branch
// of next.config.ts `headers()` (self-hosted is always IS_PLATFORM=false, so the
// CSP is just `frame-ancestors 'none'` and there's no HSTS). The platform CSP is
// applied at the edge via vercel.ts instead; see security-headers.ts. Set before
// any response is written so both the static and handler paths inherit them.
const SECURITY_HEADERS = [
['X-Frame-Options', 'DENY'],
['X-Content-Type-Options', 'nosniff'],
['Content-Security-Policy', "frame-ancestors 'none';"],
['Referrer-Policy', 'strict-origin-when-cross-origin'],
]
const port = Number(process.env.PORT || 8082)
createServer(async (req, res) => {
try {
for (const [key, value] of SECURITY_HEADERS) res.setHeader(key, value)
if (await serveStatic(req, res)) return
const response = await handler.fetch(toWebRequest(req))
await pipeWebResponse(response, res)
+35
View File
@@ -0,0 +1,35 @@
import { getCSP } from './csp'
// Security response headers for the app. On the Next build these are applied via
// `next.config.ts` `headers()`; the TanStack build has no such hook and (on
// Vercel) serves a static shell with no server to attach them, so they're
// applied through `vercel.ts` (deploy) and `scripts/serve.js` (self-hosted)
// instead. Keep this in sync with the `/(.*?)` header block in next.config.ts.
//
// Env-gated exactly like next.config:
// - CSP: full `getCSP()` on platform, else just `frame-ancestors 'none'`.
// - HSTS: only on platform + Vercel (never for self-hosted / previews on a
// bare IP), so it's omitted rather than sent empty.
export function getSecurityHeaders(): Array<{ key: string; value: string }> {
const isPlatform = process.env.NEXT_PUBLIC_IS_PLATFORM === 'true'
const isVercel = process.env.VERCEL === '1'
const headers = [
{ key: 'X-Frame-Options', value: 'DENY' },
{ key: 'X-Content-Type-Options', value: 'nosniff' },
{
key: 'Content-Security-Policy',
value: isPlatform ? getCSP() : "frame-ancestors 'none';",
},
{ key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' },
]
if (isPlatform && isVercel) {
headers.push({
key: 'Strict-Transport-Security',
value: 'max-age=31536000; includeSubDomains; preload',
})
}
return headers
}
@@ -0,0 +1,27 @@
import { readdirSync, statSync } from 'node:fs'
import { join } from 'node:path'
import { describe, expect, it } from 'vitest'
import { INTEGRATION_OVERVIEW_IDS } from './overviews'
const INTEGRATIONS_DIR = join(__dirname)
const idsOnDisk = readdirSync(INTEGRATIONS_DIR)
.filter((entry) => {
try {
return statSync(join(INTEGRATIONS_DIR, entry, 'overview.md')).isFile()
} catch {
return false
}
})
.sort()
describe('integration overview registry', () => {
// The registry uses literal import specifiers (required for both bundlers
// to statically analyze them), so it can't glob the directory at runtime.
// This keeps it honest: adding/removing an overview.md must be mirrored in
// overviews.ts.
it('stays in sync with static-data/integrations/*/overview.md', () => {
expect([...INTEGRATION_OVERVIEW_IDS].sort()).toEqual(idsOnDisk)
})
})
@@ -0,0 +1,63 @@
/**
* Registry of integration overview markdown, keyed by integration id
* (= directory name under `static-data/integrations/`).
*
* The import specifiers must stay as string literals: both bundlers can only
* code-split and apply their md-as-string loaders (raw-loader rule in
* next.config.ts, `mdRawLoader` plugin in vite.config.ts) to imports they can
* statically analyze. A template-literal specifier like
* ``import(`@/static-data/integrations/${id}/overview.md`)`` happens to work
* under webpack/turbopack (context modules) but is left untouched by
* Vite/Rolldown and throws `TypeError: Failed to resolve module specifier`
* at runtime in the TanStack build.
*
* `overviews.test.ts` asserts this map stays in sync with the files on disk —
* when adding a new `overview.md`, add its entry here.
*/
const INTEGRATION_OVERVIEWS = {
airtable_wrapper: () => import('@/static-data/integrations/airtable_wrapper/overview.md'),
auth0_wrapper: () => import('@/static-data/integrations/auth0_wrapper/overview.md'),
bigquery_wrapper: () => import('@/static-data/integrations/bigquery_wrapper/overview.md'),
cal_wrapper: () => import('@/static-data/integrations/cal_wrapper/overview.md'),
calendly_wrapper: () => import('@/static-data/integrations/calendly_wrapper/overview.md'),
cfd1_wrapper: () => import('@/static-data/integrations/cfd1_wrapper/overview.md'),
clerk_wrapper: () => import('@/static-data/integrations/clerk_wrapper/overview.md'),
clickhouse_wrapper: () => import('@/static-data/integrations/clickhouse_wrapper/overview.md'),
cognito_wrapper: () => import('@/static-data/integrations/cognito_wrapper/overview.md'),
cron: () => import('@/static-data/integrations/cron/overview.md'),
data_api: () => import('@/static-data/integrations/data_api/overview.md'),
firebase_wrapper: () => import('@/static-data/integrations/firebase_wrapper/overview.md'),
graphiql: () => import('@/static-data/integrations/graphiql/overview.md'),
hubspot_wrapper: () => import('@/static-data/integrations/hubspot_wrapper/overview.md'),
iceberg_wrapper: () => import('@/static-data/integrations/iceberg_wrapper/overview.md'),
logflare_wrapper: () => import('@/static-data/integrations/logflare_wrapper/overview.md'),
mssql_wrapper: () => import('@/static-data/integrations/mssql_wrapper/overview.md'),
notion_wrapper: () => import('@/static-data/integrations/notion_wrapper/overview.md'),
orb_wrapper: () => import('@/static-data/integrations/orb_wrapper/overview.md'),
paddle_wrapper: () => import('@/static-data/integrations/paddle_wrapper/overview.md'),
queues: () => import('@/static-data/integrations/queues/overview.md'),
redis_wrapper: () => import('@/static-data/integrations/redis_wrapper/overview.md'),
s3_vectors_wrapper: () => import('@/static-data/integrations/s3_vectors_wrapper/overview.md'),
s3_wrapper: () => import('@/static-data/integrations/s3_wrapper/overview.md'),
slack_wrapper: () => import('@/static-data/integrations/slack_wrapper/overview.md'),
snowflake_wrapper: () => import('@/static-data/integrations/snowflake_wrapper/overview.md'),
stripe_sync_engine: () => import('@/static-data/integrations/stripe_sync_engine/overview.md'),
stripe_wrapper: () => import('@/static-data/integrations/stripe_wrapper/overview.md'),
vault: () => import('@/static-data/integrations/vault/overview.md'),
webhooks: () => import('@/static-data/integrations/webhooks/overview.md'),
} satisfies Record<string, () => Promise<{ default: string }>>
export const INTEGRATION_OVERVIEW_IDS = Object.keys(
INTEGRATION_OVERVIEWS
) as (keyof typeof INTEGRATION_OVERVIEWS)[]
/**
* Loads the overview markdown for an integration. Resolves to `null` when the
* integration has no bundled overview (e.g. marketplace apps).
*/
export async function loadIntegrationOverview(integrationId: string): Promise<string | null> {
const load = INTEGRATION_OVERVIEWS[integrationId as keyof typeof INTEGRATION_OVERVIEWS]
if (!load) return null
const mod = await load()
return String(mod.default)
}
+61 -5
View File
@@ -1,11 +1,25 @@
/*
* Font setup for the Vite/TanStack Start build.
* The Next.js build uses next/font via fonts/index.ts; keep the
* CSS var names (--font-custom, --font-source-code-pro) in sync so
* styles that reference them work in both pipelines.
* Source Code Pro is loaded via a <link> in the root route's head().
*
* The Next.js build uses next/font (fonts/index.ts + the inline :root style in
* pages/_app.tsx). This file is the TanStack equivalent, and the CSS var names
* MUST match what the theme/Tailwind consume:
* --font-sans -> body (Inter) [theme.css: --font-sans]
* --font-heading -> headings (Manrope) [globals.css @theme inline]
* --font-source-code-pro -> mono (Source Code Pro)
* If a var is renamed on the Next side (fonts/index.ts / theme.css), rename it
* here too — otherwise the TanStack build silently falls through to the theme
* fallback chain.
*
* All three families are vendored below (matching next/font, which self-hosts
* them on the Next build) so nothing depends on the Google Fonts CDN at runtime
* — important for self-hosted / offline studio. Inter and Source Code Pro reuse
* the woff2 files fonts/index.ts already ships; Manrope's subset woff2 live in
* fonts/manrope/. Weight ranges mirror fonts/index.ts (Inter 100–900, Source
* Code Pro 200–900, Manrope 200–800; Manrope has no italic).
*/
/* Inter — body (--font-sans) */
@font-face {
font-family: 'Inter';
src: url('../fonts/inter/InterVariable.woff2') format('woff2');
@@ -22,7 +36,49 @@
font-display: swap;
}
/* Manrope — headings (--font-heading). Latin + Latin-ext subsets, standard
* Google Fonts unicode-ranges. No italic axis. */
@font-face {
font-family: Manrope;
src: url('../fonts/manrope/manrope-latin-wght-normal.woff2') format('woff2');
font-weight: 200 800;
font-style: normal;
font-display: swap;
unicode-range:
U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329,
U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
font-family: Manrope;
src: url('../fonts/manrope/manrope-latin-ext-wght-normal.woff2') format('woff2');
font-weight: 200 800;
font-style: normal;
font-display: swap;
unicode-range:
U+0100-02AF, U+0304, U+0308, U+0329, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0,
U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* Source Code Pro — mono (--font-source-code-pro) */
@font-face {
font-family: 'Source Code Pro';
src: url('../fonts/source-code-pro/SourceCodePro-Variable.woff2') format('woff2');
font-weight: 200 900;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Source Code Pro';
src: url('../fonts/source-code-pro/SourceCodePro-Variable-Italic.woff2') format('woff2');
font-weight: 200 900;
font-style: italic;
font-display: swap;
}
:root {
--font-custom: 'Inter', system-ui, 'Helvetica Neue', Helvetica, Arial, sans-serif;
--font-sans: 'Inter', system-ui, 'Helvetica Neue', Helvetica, Arial, sans-serif;
--font-heading: 'Manrope', system-ui, 'Helvetica Neue', Helvetica, Arial, sans-serif;
--font-source-code-pro: 'Source Code Pro', 'Office Code Pro', Menlo, monospace;
}
+8
View File
@@ -0,0 +1,8 @@
/* eslint-disable no-restricted-exports -- ambient module shape requires a default export */
// `.md` files import as their raw text content:
// - Next/turbopack: raw-loader rule in next.config.ts
// - Vite/TanStack: mdRawLoader plugin in vite.config.ts
declare module '*.md' {
const content: string
export default content
}
+6
View File
@@ -7,6 +7,7 @@ import {
SHARED_REDIRECTS,
type StudioRedirect,
} from './redirects.shared'
import { getSecurityHeaders } from './security-headers'
// STUDIO_FRAMEWORK gates the TanStack Start deploy. When the env var is
// unset (the default — used by the Next.js prod deploy) this file returns
@@ -55,6 +56,11 @@ function routesFor(prefix: string) {
routes.rewrite(`${prefix}/((?!.*\\.\\w+$).*)`, '/_shell'),
],
headers: [
// Security headers for every response. The Next build sets these via
// next.config.ts `headers()`; the TanStack build serves a static shell
// with no server to attach them, so they live here. Matches next.config's
// `/(.*?)` block (CSP, X-Frame-Options, HSTS, etc.).
{ source: `${prefix}/(.*)`, headers: getSecurityHeaders() },
// Dynamic function responses must not be cached by any shared cache —
// handlers can still opt in with their own Cache-Control on the
// Response when a response IS safe to cache.
+150 -39
View File
@@ -1,5 +1,6 @@
/* eslint-disable no-restricted-exports */
import fs from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import tailwindcss from '@tailwindcss/vite'
@@ -52,6 +53,89 @@ function nextCompat(): Plugin {
}
}
// Import specifiers (as they appear in app source) for files that import as
// raw text but whose extension the bundler would otherwise treat as code —
// the Deno typings that `components/ui/AIEditor` feeds to Monaco as extra
// libs. Deliberately an exact-specifier allowlist — do NOT widen to
// `*.d.ts`: hijacking declaration-file resolution globally would corrupt
// every package that ships `.d.ts` next to its JS.
const RAW_TEXT_SPECIFIERS: Record<string, string> = {
'@/public/deno/edge-runtime.d.ts': path.join(rootDir, 'public/deno/edge-runtime.d.ts'),
'@/public/deno/lib.deno.d.ts': path.join(rootDir, 'public/deno/lib.deno.d.ts'),
}
// `\0`-prefixed so the Rolldown dep scanner externalizes the module instead
// of descending into it (see `shouldExternalizeDep` in vite); `.js`-suffixed
// so no TS transform ever sees a `.d.ts`-looking id.
const RAW_TEXT_PREFIX = '\0studio-raw-text:'
const RAW_TEXT_SUFFIX = '.js'
// Mirror the raw-loader rules from next.config.ts: serve `*.md` files (used
// by `static-data/integrations/*/overview.md` via
// `static-data/integrations/overviews.ts`) and the Deno typings in
// `public/deno/*.d.ts` as JS modules whose default export is the file's
// text. Vite has `?raw` for this, but the query suffix would have to live
// in shared app source where it breaks the webpack/turbopack raw-loader
// rule, so the import specifiers stay query-free and this plugin does the
// conversion for the Vite pipeline.
//
// The `.d.ts` files can't go through a plain `transform` like the `.md`
// files do: the dep scanner's native scan pipeline skips JS transform/load
// hooks entirely and parses whatever the id resolves to, and raw TS
// *declaration* syntax (`get stdin(): WritableStream;`) is a parse error in
// its runtime-TS grammar — the whole dependency scan fails and Vite skips
// pre-bundling outright. Resolving the specifier to a `\0`-virtual id keeps
// the scanner out (it externalizes `\0` ids) and the `load` hook then
// serves the file's text for the real pipelines (dev, build, SSR).
function rawTextLoader(): Plugin {
return {
name: 'studio-raw-text-loader',
enforce: 'pre',
resolveId(id) {
const file = RAW_TEXT_SPECIFIERS[id]
if (file) return RAW_TEXT_PREFIX + file + RAW_TEXT_SUFFIX
},
load(id) {
if (!id.startsWith(RAW_TEXT_PREFIX)) return
const file = id.slice(RAW_TEXT_PREFIX.length, -RAW_TEXT_SUFFIX.length)
const content = fs.readFileSync(file, 'utf-8')
return { code: `export default ${JSON.stringify(content)}`, map: null }
},
transform(code, id) {
if (!id.endsWith('.md')) return
return { code: `export default ${JSON.stringify(code)}`, map: null }
},
}
}
// Swap graphiql's webpack worker setup for its Vite one in client builds.
//
// App source imports `graphiql/setup-workers/webpack` (GraphiQLTab.tsx),
// 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 and Monaco falls back to running the json /
// editorWorkerService / graphql workers on the main thread ("Could not
// create web worker(s)..." console warning). graphiql also ships
// `setup-workers/vite`, which imports the same three workers via Vite's
// `?worker` suffix; importing that unconditionally would break the Next
// build, so the swap happens here instead of in app source.
//
// SSR resolution is left untouched: neither variant's `getWorker` ever runs
// during SSR, and the webpack flavor is a plain global assignment while the
// vite flavor's `?worker` imports don't belong in the server graph.
function graphiqlViteWorkers(): Plugin {
return {
name: 'studio-graphiql-vite-workers',
enforce: 'pre',
resolveId(id, importer, options) {
if (id !== 'graphiql/setup-workers/webpack' || options.ssr) return
return this.resolve('graphiql/setup-workers/vite', importer, { skipSelf: true })
},
}
}
// Short-circuit UMD wrappers' AMD branch by string-replacing the
// `define.amd` check. Vite's `config.define` doesn't reach pre-bundled
// deps (Vite 8's Rolldown-based optimizer doesn't honour member-
@@ -75,13 +159,30 @@ function nextCompat(): Plugin {
function umdAmdShortCircuit(): Plugin {
// Matches both unminified (`typeof define === 'function' && define.amd`)
// and minified (`"function" == typeof define && define.amd`) forms of
// the UMD AMD-detection check. Replaces the whole expression with
// `false` so dead-code elimination drops the AMD branch.
// the UMD AMD-detection check.
const AMD_CHECK_PATTERNS = [
/typeof\s+define\s*===?\s*['"]function['"]\s*&&\s*define\.amd/g,
/['"]function['"]\s*===?\s*typeof\s+define\s*&&\s*define\.amd/g,
]
// Only short-circuit when `define` is the *global* AMD loader (Monaco's
// CDN loader) — that's the one we never want UMD wrappers to register
// against. Some vendored bundles install their own *local* `define` shim
// and rely on the AMD branch to capture their exports:
// `monaco-editor/esm/vs/base/common/marked/marked.js` (pulled in by
// @graphiql/react's bundled Monaco) wraps marked's UMD in
// `function define(deps, factory) { factory(__marked_exports) }` and its
// ESM tail reads `__marked_exports.X || exports.X`. Replacing the check
// with a bare `false` diverts the factory to the global-object branch,
// leaving `__marked_exports` empty, and the tail's `exports.X` fallback
// then throws `ReferenceError: exports is not defined` — the GraphiQL
// editor pane never mounts. The `define !== globalThis.define` guard
// keeps such local AMD shims working while still disarming the global
// one. (The operand order — guard *before* `define.amd` — also ensures
// the emitted expression can never re-match AMD_CHECK_PATTERNS.)
const AMD_CHECK_REPLACEMENT =
'(typeof define === "function" && define !== globalThis.define && define.amd)'
return {
name: 'studio-umd-amd-short-circuit',
enforce: 'pre',
@@ -92,7 +193,7 @@ function umdAmdShortCircuit(): Plugin {
if (id.includes('monaco-editor/min/vs/loader')) return
let next = code
for (const pattern of AMD_CHECK_PATTERNS) {
next = next.replace(pattern, 'false')
next = next.replace(pattern, AMD_CHECK_REPLACEMENT)
}
if (next === code) return
return { code: next, map: null }
@@ -311,19 +412,15 @@ export default defineConfig(({ command, mode }) => {
// - `global` → `globalThis`: makes Node-style libs (`randombytes` via
// `generate-password-browser`, etc.) work in the browser. Surfaces
// on /auth/hooks via `randombytes/browser.js:16`.
// - `define.amd` → `false`: short-circuits the AMD branch in UMD
// wrappers (papaparse, others). Monaco's CDN loader installs an
// AMD-style `window.define` at runtime; without this substitution,
// UMD libs evaluate after Monaco has loaded and call an anonymous
// `define([], t)` that Monaco's queue rejects with "Can only have
// one anonymous define call per script file". Surfaces concretely
// on /functions/[slug] (papaparse pulled in by invocations).
// Monaco's loader.js itself runs from a CDN script tag (not in our
// bundle / not pre-bundled), so its own `define.amd = true` write
// isn't affected by the substitution.
//
// NOTE: `define.amd` is deliberately NOT substituted here. The AMD
// short-circuit is handled exclusively by the `umdAmdShortCircuit()`
// transform above — a blanket `'define.amd': 'false'` define would also
// rewrite the *read* in vendored bundles that install their own local
// `define` shim (monaco-editor's `esm/vs/base/common/marked/marked.js`)
// and break them — see the plugin's comment for the failure mode.
const sharedDefines = {
global: 'globalThis',
'define.amd': 'false',
}
return {
@@ -332,8 +429,40 @@ export default defineConfig(({ command, mode }) => {
},
resolve: {
tsconfigPaths: true,
alias: [
// `@sentry/nextjs`'s client entry drags in Next runtime internals
// (`next/dist/shared/lib/constants`), whose module scope evaluates
// `process?.features?.typescript` — optional chaining doesn't guard
// an undeclared `process` in the browser, so every built chunk
// containing it (e.g. table-editor) crashes at load with
// "ReferenceError: process is not defined". Dev is unaffected
// because the dev pipeline shims `process`. Point the bare import
// at a shim that re-exports `@sentry/react` (same 10.x version —
// it's what `@sentry/nextjs` wraps on the client) plus explicit
// stand-ins for the Next-only APIs. Next build (`build:next`)
// doesn't read this config and keeps the real package.
{
find: /^@sentry\/nextjs$/,
replacement: path.resolve(rootDir, 'compat/sentry-nextjs.ts'),
},
],
},
...(basePath && { base: basePath }),
optimizeDeps: {
// graphiql's Vite worker setup (swapped in for the webpack one by the
// `graphiqlViteWorkers` plugin above) imports Monaco's workers with
// Vite's `?worker` suffix. The dep optimizer can't load `?worker` ids
// (UNLOADABLE_DEPENDENCY: "No such file or directory" for
// `json.worker.js?worker` etc.), so keep the whole chain out of
// pre-bundling; the modules then go through the normal transform
// pipeline where Vite's built-in worker plugin turns each `?worker`
// import into a spawnable Worker constructor.
exclude: [
'graphiql/setup-workers/webpack',
'graphiql/setup-workers/vite',
'@graphiql/react/setup-workers/vite',
],
},
define: {
...publicEnvDefines,
...sharedDefines,
@@ -418,34 +547,16 @@ export default defineConfig(({ command, mode }) => {
// entire exports object `{ default: fn }`, and call sites like
// `AwesomeDebouncePromise(fn, 500)` crash with "is not a function" at
// SSR module evaluation. Surfaces on routes that load the table grid.
// `@sentry/nextjs`'s CJS entry doesn't surface `startSpan` (and other
// v8 APIs) onto the namespace shape Vite's SSR externalizer produces,
// so `import * as Sentry from '@sentry/nextjs'` + `Sentry.startSpan`
// crashes with "is not a function" inside the pg-meta proxy on the
// first table-editor request.
noExternal: [
'lodash',
/^next(\/|$)/,
'tslib',
'react-use',
'awesome-debounce-promise',
'@sentry/nextjs',
],
// Vite 8.0.13's SSR module runner evaluates `@sentry/nextjs`'s
// CJS file via `runInlinedModule` without the CJS-compat wrapper
// older vite applied, crashing with "exports is not defined" at
// SSR. Forcing pre-bundling via esbuild rewrites it to ESM
// before the SSR runner sees it. Only `@sentry/nextjs` needs
// this — the other CJS deps in `noExternal` work via vite's SSR
// transform; pre-bundling React-using deps (e.g. `react-use`)
// inlines a duplicate React into the bundle and breaks hook
// dedupe at SSR (useRef → null).
optimizeDeps: {
include: ['@sentry/nextjs'],
},
// `@sentry/nextjs` deliberately has no entry here: the resolve.alias
// above rewrites it to the `@sentry/react`-backed shim before SSR
// resolution ever sees the id, and `@sentry/react` ships real ESM
// ("import" condition → build/esm), so plain externalization works.
noExternal: ['lodash', /^next(\/|$)/, 'tslib', 'react-use', 'awesome-debounce-promise'],
},
plugins: [
nextCompat(),
rawTextLoader(),
graphiqlViteWorkers(),
ssrStubGraphiql(),
umdAmdShortCircuit(),
assertNoChunkCycles(),
@@ -18,13 +18,20 @@ import { toUrl } from '../utils/to-url.js'
test.describe('Monaco / GraphiQL coexistence', () => {
test.skip(env.IS_PLATFORM, 'Self-hosted mode only — GraphiQL + SQL editor on one local project')
// Client-side navigation via the Next router (keeps already-loaded chunks/CSS in place).
// Client-side navigation (keeps already-loaded chunks/CSS in place). The Next
// build exposes `window.next.router`; the TanStack build has no equivalent
// global, but its router subscribes to popstate, so pushState + popstate
// emulates the same SPA navigation without unloading chunks.
const routerPush = async (page: Page, path: string) => {
await page.evaluate((p) => {
const router = (window as unknown as { next?: { router?: { push: (p: string) => void } } })
.next?.router
if (!router) throw new Error('Next router not available for client-side navigation')
router.push(p)
if (router) {
router.push(p)
return
}
window.history.pushState({}, '', p)
window.dispatchEvent(new PopStateEvent('popstate'))
}, path)
}