mirror of
https://github.com/supabase/supabase.git
synced 2026-09-06 09:59:03 +08:00
## 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>
219 lines
6.5 KiB
TypeScript
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 })
|
|
})
|
|
})
|