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>
This commit is contained in:
Binita DhakalandAlaister Young authored and GitHub committed 2026-09-01 06:32:18 +00:00
1 parent 4ab1a6cbd2
commit cd34776be1
3 files changed
+79

No files matched your search

+59
View File
@@ -157,3 +157,62 @@ describe('matchRedirect query/hash preservation', () => {
).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 })
})
})
+7
View File
@@ -139,6 +139,12 @@ const IS_NON_PROD_ENV =
process.env.NEXT_PUBLIC_ENVIRONMENT === 'local' ||
process.env.NEXT_PUBLIC_ENVIRONMENT === 'staging'
// Mirrors the `MAINTENANCE_MODE` reads in `next.config.ts` and `vercel.ts`.
// The var is unprefixed, so vite.config.ts inlines it explicitly (see the
// define there) rather than it arriving via the NEXT_PUBLIC_ sweep — that
// keeps the toggle a single build-time env var across all three runtimes.
const IS_MAINTENANCE_MODE = process.env.MAINTENANCE_MODE === 'true'
// Keep dev-only components out of the production bundle.
const IS_DEV_TOOLBAR_ENABLED = IS_NON_PROD_ENV
@@ -329,6 +335,7 @@ export const Route = createRootRouteWithContext<RouterContext>()({
pathname: location.pathname,
search: location.search as Record<string, string | string[] | undefined>,
isPlatform: IS_PLATFORM,
maintenanceMode: IS_MAINTENANCE_MODE,
hash: location.hash,
})
if (!match) return
+13
View File
@@ -602,6 +602,19 @@ export default defineConfig(({ command, mode }) => {
}
}
// `MAINTENANCE_MODE` gates the "redirect everything to /maintenance" rule.
// It's deliberately unprefixed, and the other two consumers both read it at
// BUILD time: `next.config.ts` reads it in `redirects()`, which Next bakes
// into `routes-manifest.json` during `next build`, and `vercel.ts` reads it
// while emitting `vercel.json`. So flipping maintenance has always meant a
// rebuild/redeploy, never just a server restart. Inline it here on the same
// terms so the isomorphic `beforeLoad` in `routes/__root.tsx` — which
// mirrors those rules for the TanStack runtime — can read it on the client
// too, without self-hosters having to set a second, NEXT_PUBLIC_-prefixed
// var. Falls back to `''` (not left undefined) so the browser bundle never
// ends up with a bare `process.env` reference.
publicEnvDefines['process.env.MAINTENANCE_MODE'] = JSON.stringify(env.MAINTENANCE_MODE ?? '')
// Sentry init (lib/sentry-client-options.ts, reached via router.tsx) reads
// these at runtime in the browser. When a var is unset it gets no define
// entry above, which would leave a literal `process.env.*` in the built