From cd34776be155eb9960fc2e2147349c70c6ad3d35 Mon Sep 17 00:00:00 2001 From: Binita Dhakal <152636304+binitadkl@users.noreply.github.com> Date: Tue, 1 Sep 2026 01:32:18 -0500 Subject: [PATCH] fix(studio): honor MAINTENANCE_MODE in the TanStack runtime (#48616) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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. ## 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. Co-authored-by: Alaister Young <10985857+alaister@users.noreply.github.com> --- apps/studio/redirects.shared.test.ts | 59 ++++++++++++++++++++++++++++ apps/studio/routes/__root.tsx | 7 ++++ apps/studio/vite.config.ts | 13 ++++++ 3 files changed, 79 insertions(+) diff --git a/apps/studio/redirects.shared.test.ts b/apps/studio/redirects.shared.test.ts index 5667989470f..fa08c3a640a 100644 --- a/apps/studio/redirects.shared.test.ts +++ b/apps/studio/redirects.shared.test.ts @@ -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 }) + }) +}) diff --git a/apps/studio/routes/__root.tsx b/apps/studio/routes/__root.tsx index 4d1c42e7c45..9c2e251ef9e 100644 --- a/apps/studio/routes/__root.tsx +++ b/apps/studio/routes/__root.tsx @@ -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()({ pathname: location.pathname, search: location.search as Record, isPlatform: IS_PLATFORM, + maintenanceMode: IS_MAINTENANCE_MODE, hash: location.hash, }) if (!match) return diff --git a/apps/studio/vite.config.ts b/apps/studio/vite.config.ts index a238efe54a3..b7bd19442e1 100644 --- a/apps/studio/vite.config.ts +++ b/apps/studio/vite.config.ts @@ -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