From 5f0bd0a672202200592149836da28d0299f9d392 Mon Sep 17 00:00:00 2001 From: Manas Srivastava Date: Fri, 29 May 2026 14:01:17 +0530 Subject: [PATCH] fix(prerender): emit /app/checkout + /app/billing SPA shells (HTTP 200, was 404) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit UI-3 from the QA backlog (P2). External CTA entries to /app/checkout (from /pricing → "Start Pro") and /app/billing (from magic-link → "manage billing") shipped HTTP 404 status. Body hydrated correctly via GH Pages' 404.html SPA fallback (catch-all React Route picks up the path on hydrate), but the status code lied — analytics noise + tab-strip title flash + SEO confusion if the URLs were ever indexed. Root cause: scripts/prerender.mjs Step 4.6 emits pre-generated SPA shells for /login, /login/callback, /claim, /cli-auth, but no /app/* sub-paths. /app itself gets a shell (Step 4.5), but bookmarked /app/checkout and /app/billing direct hits never get HTTP 200. Fix: add /app/checkout and /app/billing to Step 4.6's authShellRoutes. GH Pages now serves dist/app/checkout/index.html and dist/app/billing/index.html with HTTP 200; the SPA hydrates as before. Other /app/* deep links (/app/resources/, etc.) stay on the 404 fallback — they aren't external CTA destinations and aren't worth pre-generating with static paths. Also add ROUTE_META entries for both routes so the pre-rendered shows "Checkout · instanode" / "Billing · instanode" instead of bleeding through the homepage title. Verified via gate (npm run build): dist/app/checkout/index.html → exists, <title>Checkout · instanode dist/app/billing/index.html → exists, Billing · instanode Regression coverage in src/prerender.test.ts: - both new routes appear in the authShellRoutes literal - original 4 auth routes still present - ROUTE_META has titles for both new routes Co-Authored-By: Claude Opus 4.7 (1M context) --- scripts/prerender.mjs | 26 +++++++++++++++++++++++- src/prerender.test.ts | 47 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 72 insertions(+), 1 deletion(-) create mode 100644 src/prerender.test.ts diff --git a/scripts/prerender.mjs b/scripts/prerender.mjs index 0366da2..1ec4a61 100644 --- a/scripts/prerender.mjs +++ b/scripts/prerender.mjs @@ -193,6 +193,21 @@ const ROUTE_META = { title: 'Dashboard · instanode', description: 'instanode dashboard — manage resources, deployments, and billing.', }, + // UI-3 (2026-05-29): /app/checkout and /app/billing are external CTA + // destinations (from /pricing "Start Pro", magic-link "manage billing", + // and agent-clickable upsell links). Without pre-generated shells they + // hit GH Pages' 404.html fallback and ship HTTP 404 even when the body + // hydrates correctly — analytics noise + tab-strip title flash. Step + // 4.6 emits dist/app/checkout/index.html and dist/app/billing/index.html + // so external entries see HTTP 200. + '/app/checkout': { + title: 'Checkout · instanode', + description: 'Complete your instanode plan upgrade.', + }, + '/app/billing': { + title: 'Billing · instanode', + description: 'Manage your instanode subscription, payment method, and invoices.', + }, } /** escapeHtmlAttr — minimal escaping for text injected into an HTML @@ -429,7 +444,16 @@ async function main() { // appears in the CLI test mock and any stale terminal scrollback / // chat transcript a user pastes. Without an entry under dist/cli-auth/, // GH Pages returns its 404 shell and the React Navigate never runs. - const authShellRoutes = ['/login', '/login/callback', '/claim', '/cli-auth'] + // UI-3 (2026-05-29): add /app/checkout and /app/billing so external CTA + // entry from /pricing → "Start Pro" or magic-link → "manage billing" + // hits pre-generated dist/app/checkout/index.html and + // dist/app/billing/index.html shells with HTTP 200 instead of the + // GH Pages 404.html fallback (status code 404 even when the body + // hydrates correctly via the catch-all SPA shell). The /app shell + // already covers /app on its own (Step 4.5). Other /app/* deep links + // remain on the 404-status fallback — they are not external CTA + // destinations and aren't worth pre-generating. + const authShellRoutes = ['/login', '/login/callback', '/claim', '/cli-auth', '/app/checkout', '/app/billing'] for (const route of authShellRoutes) { const p = resolve(DIST, route.replace(/^\//, ''), 'index.html') await mkdir(dirname(p), { recursive: true }) diff --git a/src/prerender.test.ts b/src/prerender.test.ts new file mode 100644 index 0000000..04fc7b4 --- /dev/null +++ b/src/prerender.test.ts @@ -0,0 +1,47 @@ +/* prerender.test.ts — UI-3 regression guard for the SPA-fallback HTTP 404 + * bug on /app/checkout and /app/billing. + * + * GH Pages serves dist/404.html with HTTP 404 for every URL that doesn't + * match a real file under dist/. Direct hits to /app/checkout?plan=... + * (external CTA from /pricing → "Start Pro") therefore shipped HTTP 404 + * even though the SPA shell hydrated and CheckoutPage rendered correctly. + * + * Fix: prerender.mjs Step 4.6 now emits dist/app/checkout/index.html and + * dist/app/billing/index.html as pre-generated SPA shells. GH Pages + * serves those with HTTP 200. + * + * This test pins the source-of-truth list in prerender.mjs so a future + * refactor of Step 4.6 can't silently regress either route. (We don't + * shell out to the prerender script itself — the gate's `npm run build` + * already exercises the writeFile path; this is a fast static check.) + */ + +import { readFileSync } from 'node:fs' +import { resolve } from 'node:path' +import { describe, it, expect } from 'vitest' + +const PRERENDER = resolve(__dirname, '..', 'scripts', 'prerender.mjs') + +describe('prerender.mjs Step 4.6 — authShellRoutes (UI-3)', () => { + const src = readFileSync(PRERENDER, 'utf-8') + + it('emits an SPA shell for /app/checkout (external CTA destination from /pricing)', () => { + expect(src).toMatch(/['"]\/app\/checkout['"]/) + }) + + it('emits an SPA shell for /app/billing (external CTA destination from magic-link)', () => { + expect(src).toMatch(/['"]\/app\/billing['"]/) + }) + + it('still emits the original auth shells (/login, /login/callback, /claim, /cli-auth)', () => { + expect(src).toMatch(/['"]\/login['"]/) + expect(src).toMatch(/['"]\/login\/callback['"]/) + expect(src).toMatch(/['"]\/claim['"]/) + expect(src).toMatch(/['"]\/cli-auth['"]/) + }) + + it('ROUTE_META carries titles for both new auth shells (so /app/checkout and /app/billing get sensible tags, not the homepage default)', () => { + expect(src).toMatch(/['"]\/app\/checkout['"]:\s*{[^}]*title:/) + expect(src).toMatch(/['"]\/app\/billing['"]:\s*{[^}]*title:/) + }) +})