Files
supabase/apps/studio/redirects.shared.test.ts
Binita Dhakal cd34776be1 fix(studio): honor MAINTENANCE_MODE in the TanStack runtime (#48616)
## What kind of change does this PR introduce?

Bug fix.

## What is the current behavior?

Fixes #48559 (diagnosed by @ayaangazali)

The TanStack Start runtime never applies maintenance mode.

`matchRedirect` in `apps/studio/redirects.shared.ts` takes a
`maintenanceMode`
flag, and both other consumers wire it from the environment:

- `apps/studio/next.config.ts` — `process.env.MAINTENANCE_MODE ===
'true'`
- `apps/studio/vercel.ts` — same

The TanStack call site in `apps/studio/routes/__root.tsx` passed only
`pathname`, `search`, `isPlatform` and `hash`, so `maintenanceMode` fell
back
to its `= false` default. With `MAINTENANCE_MODE=true` on a TanStack
deploy
that produced two wrong behaviors:

1. No path redirected to `/maintenance` — the app served normally during
   maintenance.
2. Because the flag read false, the "not in maintenance" branch still
applied
and sent `/maintenance` → `/`, making `routes/maintenance.tsx`
unreachable.

Mainly affects self-hosted / Node-server TanStack deploys; the platform
deploy
is covered by the Vercel edge layer, which does wire the flag.

## What is the new behavior?

The TanStack runtime honors `MAINTENANCE_MODE` the same way the Next
runtime
and the edge config do.

**Design note.** The issue asked whether this needs a new `NEXT_PUBLIC_`
variable or server-side plumbing, since both would change deployment
configuration for self-hosters. Neither is needed. `MAINTENANCE_MODE` is
already a *build-time* variable in both existing consumers — Next bakes
`redirects()` into `routes-manifest.json` during `next build`, and
`vercel.ts`
reads it while emitting `vercel.json`. Toggling maintenance has always
required
a rebuild, never just a server restart. And `vite.config.ts` isn't bound
by
Next's "only `NEXT_PUBLIC_`" rule: it controls `define` directly, and
already
re-exposes unprefixed `VERCEL_*` vars the same way. So the existing
unprefixed
variable is inlined at build time, giving exact parity with **no new env
var
and no config change for self-hosters**.

Three changes:

1. `vite.config.ts` — inline `process.env.MAINTENANCE_MODE` into the
bundle.
Falls back to `''` rather than being left undefined, so the browser
bundle
never ends up with a bare `process.env` reference (the failure mode the
file
   already guards against for the Sentry vars).
2. `routes/__root.tsx` — read it into `IS_MAINTENANCE_MODE` and pass it
to
   `matchRedirect`.
3. `redirects.shared.test.ts` — 4 tests for the maintenance branches of
   `matchRedirect`, which had no coverage at all.

`turbo.jsonc` already lists `MAINTENANCE_MODE` under the build task's
`env`, so
cache invalidation is correct for the Vite build too — no change needed.
No
README or docs change either, since the env contract is unchanged.

## Additional context

Verified end-to-end, not just by unit test.

**Browser repro** — built SPA served via `scripts/serve.js`, driven in
headless
Chromium:

| `MAINTENANCE_MODE=true` | lands on | |
| --- | --- | --- |
| `/project/default` | `/maintenance` | fixes behavior 1 |
| `/` | `/maintenance` | |
| `/maintenance` | `/maintenance` | fixes behavior 2 |

The maintenance page renders real content ("Under Maintenance — We are
currently improving our services…"), so the route is genuinely
reachable.

| control, var unset | lands on | |
| --- | --- | --- |
| `/project/default` | `/project/default` | normal routing intact |
| `/` | `/project/default` | root redirect intact |
| `/maintenance` | `/project/default` | correctly bounces away |

**Bundle inspection** — the flag compiles to a literal `true` with the
variable
set and `false` without it, confirming the define reaches the client.

**Shell prerender** — checked explicitly, since the maintenance-on rule
is a
catch-all. Builds with `MAINTENANCE_MODE=true` prerender the SPA shell
and pass
the post-build smoke test; the prerenderer crawls `/` and the root
`beforeLoad`
redirect does not fire during shell generation, so no guard is required.

**Checks** — 20 unit tests pass, typecheck 8/8, ESLint ratchet passes,
Prettier
clean.


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

- **New Features**
  - Added maintenance-mode routing for unavailable pages.
  - Preserves query parameters and URL fragments during redirects.
- Allows access to maintenance and image paths while maintenance mode is
active.
- Automatically returns visitors to the home page when maintenance mode
is disabled.
  - Maintenance behavior is controlled by the deployment configuration.

- **Tests**
- Added coverage for maintenance-mode redirects, URL preservation, and
exceptions.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

Co-authored-by: Alaister Young <10985857+alaister@users.noreply.github.com>
2026-09-01 06:32:18 +00:00

219 lines
6.5 KiB
TypeScript

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('redirects the legacy compute and disk route while preserving query and hash', () => {
expect(
matchRedirect({
pathname: '/project/abc/settings/compute-and-disk',
search: { upgrade: 'micro' },
isPlatform: true,
hash: 'disk',
})
).toEqual({
destination: '/project/abc/settings/infrastructure?upgrade=micro#disk',
permanent: true,
})
})
it('redirects legacy replication replica detail to infrastructure', () => {
expect(
matchRedirect({
pathname: '/project/abc/database/replication/replica/replica-1',
search: {},
isPlatform: true,
})
).toEqual({
destination: '/project/abc/settings/infrastructure/replica/replica-1',
permanent: true,
})
})
it('redirects the legacy compute billing panel to the CPU section', () => {
expect(
matchRedirect({
pathname: '/project/abc/settings/billing/subscription',
search: { panel: 'computeInstance', source: 'banner' },
isPlatform: true,
})
).toEqual({
destination: '/project/abc/settings/infrastructure?source=banner#cpu',
permanent: true,
})
})
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()
})
})
describe('matchRedirect maintenance mode', () => {
it('sends every other path to /maintenance when enabled', () => {
expect(
matchRedirect({
pathname: '/project/abc/editor',
search: {},
isPlatform: true,
maintenanceMode: true,
})
).toEqual({ destination: '/maintenance', permanent: false })
})
it('carries query and hash onto /maintenance', () => {
expect(
matchRedirect({
pathname: '/project/abc/editor',
search: { a: '1' },
isPlatform: true,
maintenanceMode: true,
hash: 'section',
})
).toEqual({ destination: '/maintenance?a=1#section', permanent: false })
})
it('leaves /maintenance and /img reachable when enabled', () => {
expect(
matchRedirect({
pathname: '/maintenance',
search: {},
isPlatform: true,
maintenanceMode: true,
})
).toBeNull()
expect(
matchRedirect({
pathname: '/img/supabase-logo.svg',
search: {},
isPlatform: true,
maintenanceMode: true,
})
).toBeNull()
})
it('bounces /maintenance back to / when disabled', () => {
expect(matchRedirect({ pathname: '/maintenance', search: {}, isPlatform: true })).toEqual({
destination: '/',
permanent: false,
})
expect(
matchRedirect({
pathname: '/maintenance',
search: {},
isPlatform: true,
maintenanceMode: false,
})
).toEqual({ destination: '/', permanent: false })
})
})