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
new file mode 100644
index 00000000..e1a882a6
--- /dev/null
+++ b/e2e/instant-marketing-hard-nav.spec.ts
@@ -0,0 +1,70 @@
+import { instant } from "@next/playwright";
+import { expect, test } from "@playwright/test";
+
+import { dismissCookieBannerIfVisible } from "./helpers/dismissCookieBanner";
+import { hasProductionInstantShell } from "./helpers/instantNavigationShell";
+
+/**
+ * L5: hard navigation (MPA reload) instant shell for default-locale marketing.
+ * 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" });
+
+ 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/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 bf4c8804..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 |
-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`); `RootHtmlShell` sets `lang`/`dir` from the proxy locale header.
---
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..a6a75beb 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)~~ — `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.
---