From 3113424bab13276b6b3bb81f2e83514c5b9db000 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 23 Aug 2026 20:49:33 +0000 Subject: [PATCH 01/11] feat(app): L5 instant marketing navigations Suspense-split root locale headers() via RootHtmlShell and remove root instant=false. Cache locale bundles with use cache; wrap session and playground device hints in LocaleAppLayoutRuntime Suspense with an instant shell fallback. Opt marketing pages into instant=true; keep playground/profile at instant=false. Add @next/playwright instant() e2e tests for client navigations between /, /privacy, and /daily. --- e2e/instant-marketing-nav.spec.ts | 57 +++++++++++++++++++ package.json | 1 + pnpm-lock.yaml | 15 +++++ src/app/(default-locale)/daily/page.tsx | 3 + src/app/(default-locale)/layout.tsx | 3 - src/app/(default-locale)/page.tsx | 3 + .../playground/[[...slug]]/page.tsx | 3 + src/app/(default-locale)/privacy/page.tsx | 3 + .../profile/[userId]/page.tsx | 3 + src/app/RootHtmlShell.tsx | 55 ++++++++++++++++++ src/app/[lang]/daily/page.tsx | 3 + src/app/[lang]/layout.tsx | 3 - src/app/[lang]/page.tsx | 3 + .../[lang]/playground/[[...slug]]/page.tsx | 3 + src/app/[lang]/privacy/page.tsx | 3 + src/app/[lang]/profile/[userId]/page.tsx | 3 + src/app/layout.tsx | 40 ++++--------- src/app/locale-app/LocaleAppLayout.tsx | 31 +++++----- src/app/locale-app/LocaleAppLayoutRuntime.tsx | 40 +++++++++++++ src/app/locale-app/LocaleAppLayoutShell.tsx | 27 +++++++++ .../publicPageMetadataFromTranslation.ts | 9 ++- src/i18n/loadI18nForLocale.ts | 6 ++ vibe-docs/Instant-Navigations-TODO.md | 9 ++- vibe-docs/Locale-Migration-Design.md | 10 ++-- 24 files changed, 274 insertions(+), 62 deletions(-) create mode 100644 e2e/instant-marketing-nav.spec.ts create mode 100644 src/app/RootHtmlShell.tsx create mode 100644 src/app/locale-app/LocaleAppLayoutRuntime.tsx create mode 100644 src/app/locale-app/LocaleAppLayoutShell.tsx diff --git a/e2e/instant-marketing-nav.spec.ts b/e2e/instant-marketing-nav.spec.ts new file mode 100644 index 00000000..e01d742c --- /dev/null +++ b/e2e/instant-marketing-nav.spec.ts @@ -0,0 +1,57 @@ +import { instant } from "@next/playwright"; +import { expect, test } from "@playwright/test"; + +import { dismissCookieBannerIfVisible } from "./helpers/dismissCookieBanner"; + +/** + * L5: marketing pages use `instant = true`; client navigations between them are validated. + * Serial mode avoids `next-instant-navigation-testing` cookie races on localhost. + */ +test.describe("instant marketing navigations (L5)", () => { + test.describe.configure({ mode: "serial" }); + + test.beforeEach(async ({ page }) => { + await page.goto("/"); + await dismissCookieBannerIfVisible(page); + }); + + test("privacy is instant on client navigation from home", async ({ page }) => { + await instant(page, async () => { + await page.getByRole("link", { name: /privacy policy/i }).click(); + await page.waitForURL((url) => url.pathname === "/privacy"); + await expect(page.getByRole("heading", { level: 1 })).toHaveText( + "Privacy Policy", + ); + }); + }); + + test("daily shell is instant on client navigation from home", async ({ + page, + }) => { + await instant(page, async () => { + await page.getByRole("link", { name: /daily problem/i }).click(); + await page.waitForURL((url) => url.pathname === "/daily"); + await expect(page.getByRole("heading", { level: 4 })).toContainText( + "Not sure what to solve?", + ); + }); + }); + + test("home hero is instant on client navigation from privacy", async ({ + page, + }) => { + await page.goto("/privacy"); + await dismissCookieBannerIfVisible(page); + + await instant(page, async () => { + await page + .getByRole("link", { name: /dstruct/i }) + .first() + .click(); + await page.waitForURL((url) => url.pathname === "/"); + await expect(page.getByRole("heading", { level: 1 })).toContainText( + "Your code, frame by frame.", + ); + }); + }); +}); diff --git a/package.json b/package.json index bb653ed6..e53c907d 100644 --- a/package.json +++ b/package.json @@ -122,6 +122,7 @@ "@graphql-codegen/typescript-operations": "^4.6.1", "@graphql-codegen/typescript-react-apollo": "^4.4.1", "@next/eslint-plugin-next": "16.3.2", + "@next/playwright": "16.3.2", "@playwright/test": "1.51.1", "@semantic-release/changelog": "^6.0.3", "@semantic-release/git": "^10.0.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 69f6d3ef..5787debf 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -228,6 +228,9 @@ importers: '@next/eslint-plugin-next': specifier: 16.3.2 version: 16.3.2(eslint@10.8.0(jiti@2.7.0)) + '@next/playwright': + specifier: 16.3.2 + version: 16.3.2(@playwright/test@1.51.1) '@playwright/test': specifier: 1.51.1 version: 1.51.1 @@ -1911,6 +1914,14 @@ packages: '@next/eslint-plugin-next@16.3.2': resolution: {integrity: sha512-z+HW1cZgt8QhByw8p2EbxF94AImgsKIYUbtSkA7Zld2T9yrKAlys4jNOcAOCtv6csX2CoA/5qCVyesL5pHmJ0A==} + '@next/playwright@16.3.2': + resolution: {integrity: sha512-UpgLbbWMleUS1lrWAL/rp+yJETnKaGxDhtSYvj1QCKU3vS8EuoHVsA+28YLp2Ew/mkGciAzSsNoGVveMa9RomQ==} + peerDependencies: + '@playwright/test': '>=1.0.0' + peerDependenciesMeta: + '@playwright/test': + optional: true + '@next/swc-darwin-arm64@16.3.2': resolution: {integrity: sha512-ib5Llm93YCKoKWDh6ZaHq6QWTuOZ2bRkSnUwMmX8dsRIOkBNL1vVlSiUKSfixPL9SSh9pvukzqajk/klkn5vqg==} engines: {node: '>= 10'} @@ -8771,6 +8782,10 @@ snapshots: transitivePeerDependencies: - eslint + '@next/playwright@16.3.2(@playwright/test@1.51.1)': + optionalDependencies: + '@playwright/test': 1.51.1 + '@next/swc-darwin-arm64@16.3.2': optional: true diff --git a/src/app/(default-locale)/daily/page.tsx b/src/app/(default-locale)/daily/page.tsx index 0a596730..44613bd6 100644 --- a/src/app/(default-locale)/daily/page.tsx +++ b/src/app/(default-locale)/daily/page.tsx @@ -5,6 +5,9 @@ import { baseLocale } from "#/i18n/i18n-util"; import { publicPageMetadataFromTranslation } from "#/app/locale-app/publicPageMetadataFromTranslation"; +/** Marketing daily — instant client navigations to sibling routes (L5). */ +export const instant = true; + export async function generateMetadata(): Promise { return publicPageMetadataFromTranslation( baseLocale, diff --git a/src/app/(default-locale)/layout.tsx b/src/app/(default-locale)/layout.tsx index 7d57bc43..7b3c4476 100644 --- a/src/app/(default-locale)/layout.tsx +++ b/src/app/(default-locale)/layout.tsx @@ -2,9 +2,6 @@ import { baseLocale } from "#/i18n/i18n-util"; import { LocaleAppLayout } from "#/app/locale-app/LocaleAppLayout"; -/** Locale shell uses session/i18n loaders — opt out until cached (L5). */ -export const instant = false; - /** Default-locale (`en`) public App shell at unprefixed URLs (L2). */ export default async function DefaultLocaleLayout({ children, diff --git a/src/app/(default-locale)/page.tsx b/src/app/(default-locale)/page.tsx index a9193978..aad592f1 100644 --- a/src/app/(default-locale)/page.tsx +++ b/src/app/(default-locale)/page.tsx @@ -5,6 +5,9 @@ import { baseLocale } from "#/i18n/i18n-util"; import { publicPageMetadataFromTranslation } from "#/app/locale-app/publicPageMetadataFromTranslation"; +/** Marketing home — instant client navigations to sibling routes (L5). */ +export const instant = true; + export async function generateMetadata(): Promise { return publicPageMetadataFromTranslation(baseLocale, "/", (translation) => ({ title: translation.SITE_SEO_TITLE, diff --git a/src/app/(default-locale)/playground/[[...slug]]/page.tsx b/src/app/(default-locale)/playground/[[...slug]]/page.tsx index 715c049b..f3ae84d4 100644 --- a/src/app/(default-locale)/playground/[[...slug]]/page.tsx +++ b/src/app/(default-locale)/playground/[[...slug]]/page.tsx @@ -8,6 +8,9 @@ import { SplitPanelsLayoutSkeleton } from "#/shared/ui/templates/SplitPanelsLayo import { publicAppMetadata } from "#/app/locale-app/publicAppMetadata"; +/** Playground — heavy client app; defer instant validation (L5). */ +export const instant = false; + export async function generateMetadata({ params, }: { diff --git a/src/app/(default-locale)/privacy/page.tsx b/src/app/(default-locale)/privacy/page.tsx index c6ce1443..152b1a61 100644 --- a/src/app/(default-locale)/privacy/page.tsx +++ b/src/app/(default-locale)/privacy/page.tsx @@ -5,6 +5,9 @@ import { baseLocale } from "#/i18n/i18n-util"; import { publicPageMetadataFromTranslation } from "#/app/locale-app/publicPageMetadataFromTranslation"; +/** Marketing privacy — instant client navigations to sibling routes (L5). */ +export const instant = true; + export async function generateMetadata(): Promise { return publicPageMetadataFromTranslation( baseLocale, diff --git a/src/app/(default-locale)/profile/[userId]/page.tsx b/src/app/(default-locale)/profile/[userId]/page.tsx index 105ce27d..61ce2eec 100644 --- a/src/app/(default-locale)/profile/[userId]/page.tsx +++ b/src/app/(default-locale)/profile/[userId]/page.tsx @@ -6,6 +6,9 @@ import { baseLocale } from "#/i18n/i18n-util"; import { publicPageMetadataFromTranslation } from "#/app/locale-app/publicPageMetadataFromTranslation"; +/** Profile — user-specific SSR; defer instant validation (L5). */ +export const instant = false; + export async function generateMetadata({ params, }: { diff --git a/src/app/RootHtmlShell.tsx b/src/app/RootHtmlShell.tsx new file mode 100644 index 00000000..d1371228 --- /dev/null +++ b/src/app/RootHtmlShell.tsx @@ -0,0 +1,55 @@ +import { headers } from "next/headers"; +import type { ReactNode } from "react"; + +import type { Locales } from "#/i18n/i18n-types"; +import { getDocumentTextDirection } from "#/i18n/localeMeta"; +import { fontVariableClassNames } from "#/shared/fonts/appFonts"; +import { materialIconsStylesheetHref } from "#/shared/lib/appDocumentMetadata"; +import { APP_ROUTER_LOCALE_HEADER } from "#/shared/lib/appRouterLocaleHeader"; + +type RootHtmlShellProps = { + children: ReactNode; +}; + +/** Static fallback while request locale header resolves (L5 Suspense split). */ +export function RootHtmlShellFallback({ children }: RootHtmlShellProps) { + return ( + + + + + + + + {children} + + ); +} + +/** Reads proxy locale header for `` / `dir` (runtime — wrapped in Suspense). */ +export async function RootHtmlShell({ children }: RootHtmlShellProps) { + const headerList = await headers(); + const locale = (headerList.get(APP_ROUTER_LOCALE_HEADER) ?? "en") as Locales; + const htmlDir = getDocumentTextDirection(locale); + + return ( + + + {/* Parity with `pages/_document.tsx` — Dark Reader must see this literal empty meta. */} + + + + + + {children} + + ); +} diff --git a/src/app/[lang]/daily/page.tsx b/src/app/[lang]/daily/page.tsx index a46ae8bf..b57d77e6 100644 --- a/src/app/[lang]/daily/page.tsx +++ b/src/app/[lang]/daily/page.tsx @@ -6,6 +6,9 @@ import { locales } from "#/i18n/i18n-util"; import { publicPageMetadataFromTranslation } from "#/app/locale-app/publicPageMetadataFromTranslation"; +/** Marketing daily — instant client navigations to sibling routes (L5). */ +export const instant = true; + export async function generateMetadata({ params, }: { diff --git a/src/app/[lang]/layout.tsx b/src/app/[lang]/layout.tsx index c38e4ee6..e2719a71 100644 --- a/src/app/[lang]/layout.tsx +++ b/src/app/[lang]/layout.tsx @@ -1,8 +1,5 @@ import { LocaleAppLayout } from "#/app/locale-app/LocaleAppLayout"; -/** Locale shell uses session/i18n loaders — opt out until cached (L5). */ -export const instant = false; - export default async function LangLayout({ children, params, diff --git a/src/app/[lang]/page.tsx b/src/app/[lang]/page.tsx index 1133e76a..ad146f54 100644 --- a/src/app/[lang]/page.tsx +++ b/src/app/[lang]/page.tsx @@ -6,6 +6,9 @@ import { locales } from "#/i18n/i18n-util"; import { publicPageMetadataFromTranslation } from "#/app/locale-app/publicPageMetadataFromTranslation"; +/** Marketing home — instant client navigations to sibling routes (L5). */ +export const instant = true; + export async function generateMetadata({ params, }: { diff --git a/src/app/[lang]/playground/[[...slug]]/page.tsx b/src/app/[lang]/playground/[[...slug]]/page.tsx index 09280ac6..758ca86b 100644 --- a/src/app/[lang]/playground/[[...slug]]/page.tsx +++ b/src/app/[lang]/playground/[[...slug]]/page.tsx @@ -9,6 +9,9 @@ import { SplitPanelsLayoutSkeleton } from "#/shared/ui/templates/SplitPanelsLayo import { publicAppMetadata } from "#/app/locale-app/publicAppMetadata"; +/** Playground — heavy client app; defer instant validation (L5). */ +export const instant = false; + export async function generateMetadata({ params, }: { diff --git a/src/app/[lang]/privacy/page.tsx b/src/app/[lang]/privacy/page.tsx index 4eac4f58..3678d16c 100644 --- a/src/app/[lang]/privacy/page.tsx +++ b/src/app/[lang]/privacy/page.tsx @@ -6,6 +6,9 @@ import { locales } from "#/i18n/i18n-util"; import { publicPageMetadataFromTranslation } from "#/app/locale-app/publicPageMetadataFromTranslation"; +/** Marketing privacy — instant client navigations to sibling routes (L5). */ +export const instant = true; + export async function generateMetadata({ params, }: { diff --git a/src/app/[lang]/profile/[userId]/page.tsx b/src/app/[lang]/profile/[userId]/page.tsx index 7c1dece7..74eca26f 100644 --- a/src/app/[lang]/profile/[userId]/page.tsx +++ b/src/app/[lang]/profile/[userId]/page.tsx @@ -7,6 +7,9 @@ import { locales } from "#/i18n/i18n-util"; import { publicPageMetadataFromTranslation } from "#/app/locale-app/publicPageMetadataFromTranslation"; +/** Profile — user-specific SSR; defer instant validation (L5). */ +export const instant = false; + export async function generateMetadata({ params, }: { diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 3f1be35c..13fe2197 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,52 +1,32 @@ -import { headers } from "next/headers"; +import { Suspense } from "react"; -import type { Locales } from "#/i18n/i18n-types"; -import { getDocumentTextDirection } from "#/i18n/localeMeta"; import "#/shared/fonts/appFonts"; -import { fontVariableClassNames } from "#/shared/fonts/appFonts"; import { appDocumentMetadata, appDocumentViewport, - materialIconsStylesheetHref, } from "#/shared/lib/appDocumentMetadata"; -import { APP_ROUTER_LOCALE_HEADER } from "#/shared/lib/appRouterLocaleHeader"; +import { RootHtmlShell, RootHtmlShellFallback } from "#/app/RootHtmlShell"; import "#/styles/globals.css"; import "overlayscrollbars/overlayscrollbars.css"; export { appDocumentMetadata as metadata, appDocumentViewport as viewport }; -/** Root reads request locale header — opt out of instant validation until Suspense split (L5). */ -export const instant = false; - /** - * Minimal root shell for App Router only. Locale comes from {@link APP_ROUTER_LOCALE_HEADER} - * (set in proxy for App Router locale paths). + * Minimal root shell for App Router only. Locale for `` comes from + * {@link APP_ROUTER_LOCALE_HEADER} via {@link RootHtmlShell} (Suspense-split, L5). */ -export default async function RootLayout({ +export default function RootLayout({ children, }: { children: React.ReactNode; }) { - const headerList = await headers(); - const locale = (headerList.get(APP_ROUTER_LOCALE_HEADER) ?? "en") as Locales; - const htmlDir = getDocumentTextDirection(locale); - return ( - - - {/* Parity with `pages/_document.tsx` — Dark Reader must see this literal empty meta. */} - - - - - - {children} - + {children}} + > + {children} + ); } diff --git a/src/app/locale-app/LocaleAppLayout.tsx b/src/app/locale-app/LocaleAppLayout.tsx index 8a56ed1f..33c24075 100644 --- a/src/app/locale-app/LocaleAppLayout.tsx +++ b/src/app/locale-app/LocaleAppLayout.tsx @@ -1,15 +1,12 @@ -import { getServerSession } from "next-auth"; -import { headers } from "next/headers"; import { notFound } from "next/navigation"; +import { Suspense } from "react"; import type { Locales } from "#/i18n/i18n-types"; import { locales } from "#/i18n/i18n-util"; import { loadI18nForLocale } from "#/i18n/loadI18nForLocale"; -import { authOptions } from "#/server/auth/authOptions"; -import { APP_ROUTER_SSR_DEVICE_TYPE_HEADER } from "#/shared/lib/appRouterLocaleHeader"; -import { parseSsrDeviceTypeHeader } from "#/shared/lib/ssrDevice"; -import { AppRootLayoutClient } from "#/app/AppRootLayoutClient"; +import { LocaleAppLayoutRuntime } from "#/app/locale-app/LocaleAppLayoutRuntime"; +import { LocaleAppLayoutShell } from "#/app/locale-app/LocaleAppLayoutShell"; /** Shared App Router locale layout for `app/[lang]` and `(default-locale)`. */ export async function LocaleAppLayout({ @@ -23,21 +20,19 @@ export async function LocaleAppLayout({ notFound(); } const locale = localeParam as Locales; - const session = await getServerSession(authOptions); const i18n = await loadI18nForLocale(locale); - const headerList = await headers(); - const ssrDeviceType = parseSsrDeviceTypeHeader( - headerList.get(APP_ROUTER_SSR_DEVICE_TYPE_HEADER), - ); return ( - + {children} + + } > - {children} - + + {children} + + ); } diff --git a/src/app/locale-app/LocaleAppLayoutRuntime.tsx b/src/app/locale-app/LocaleAppLayoutRuntime.tsx new file mode 100644 index 00000000..01214543 --- /dev/null +++ b/src/app/locale-app/LocaleAppLayoutRuntime.tsx @@ -0,0 +1,40 @@ +import { getServerSession } from "next-auth"; +import { headers } from "next/headers"; + +import type { I18nProps } from "#/i18n/getI18nProps"; +import type { Locales } from "#/i18n/i18n-types"; +import { authOptions } from "#/server/auth/authOptions"; +import { APP_ROUTER_SSR_DEVICE_TYPE_HEADER } from "#/shared/lib/appRouterLocaleHeader"; +import { parseSsrDeviceTypeHeader } from "#/shared/lib/ssrDevice"; + +import { AppRootLayoutClient } from "#/app/AppRootLayoutClient"; + +type LocaleAppLayoutRuntimeProps = { + children: React.ReactNode; + locale: Locales; + i18n: I18nProps; +}; + +/** Session + playground device hints — runtime APIs wrapped in Suspense (L5). */ +export async function LocaleAppLayoutRuntime({ + children, + locale, + i18n, +}: LocaleAppLayoutRuntimeProps) { + const session = await getServerSession(authOptions); + const headerList = await headers(); + const ssrDeviceType = parseSsrDeviceTypeHeader( + headerList.get(APP_ROUTER_SSR_DEVICE_TYPE_HEADER), + ); + + return ( + + {children} + + ); +} diff --git a/src/app/locale-app/LocaleAppLayoutShell.tsx b/src/app/locale-app/LocaleAppLayoutShell.tsx new file mode 100644 index 00000000..699906e3 --- /dev/null +++ b/src/app/locale-app/LocaleAppLayoutShell.tsx @@ -0,0 +1,27 @@ +"use client"; + +import type { ReactNode } from "react"; + +import type { I18nProps } from "#/i18n/getI18nProps"; +import type { Locales } from "#/i18n/i18n-types"; + +import { AppRootLayoutClient } from "#/app/AppRootLayoutClient"; + +type LocaleAppLayoutShellProps = { + children: ReactNode; + locale: Locales; + i18n: I18nProps; +}; + +/** Instant shell while session / device headers stream in (logged-out, desktop theme). */ +export function LocaleAppLayoutShell({ + children, + locale, + i18n, +}: LocaleAppLayoutShellProps) { + return ( + + {children} + + ); +} diff --git a/src/app/locale-app/publicPageMetadataFromTranslation.ts b/src/app/locale-app/publicPageMetadataFromTranslation.ts index cb012c8d..04fdb86c 100644 --- a/src/app/locale-app/publicPageMetadataFromTranslation.ts +++ b/src/app/locale-app/publicPageMetadataFromTranslation.ts @@ -1,4 +1,5 @@ import type { Metadata } from "next"; +import { cacheLife } from "next/cache"; import type { Locales, Translation } from "#/i18n/i18n-types"; import { importLocaleAsync } from "#/i18n/i18n-util.async"; @@ -10,6 +11,12 @@ type PublicPageCopy = { description: string; }; +async function loadTranslationCached(locale: Locales): Promise { + "use cache"; + cacheLife("max"); + return importLocaleAsync(locale); +} + /** Server-only SEO metadata for `app/[lang]/*` using locale dictionaries. */ export async function publicPageMetadataFromTranslation( locale: Locales, @@ -17,7 +24,7 @@ export async function publicPageMetadataFromTranslation( pickCopy: (translation: Translation) => PublicPageCopy, options?: { indexable?: boolean }, ): Promise { - const translation = await importLocaleAsync(locale); + const translation = await loadTranslationCached(locale); const { title, description } = pickCopy(translation); return publicAppMetadata({ diff --git a/src/i18n/loadI18nForLocale.ts b/src/i18n/loadI18nForLocale.ts index 67d2f6b8..be24797d 100644 --- a/src/i18n/loadI18nForLocale.ts +++ b/src/i18n/loadI18nForLocale.ts @@ -1,13 +1,19 @@ +import { cacheLife } from "next/cache"; + import { type I18nProps } from "#/i18n/getI18nProps"; import type { Locales } from "#/i18n/i18n-types"; import { importLocaleAsync } from "#/i18n/i18n-util.async"; /** * Server-only: load one locale bundle for App Router RSC props (e.g. root layout). + * Cached for Cache Components static shell (L5). */ export async function loadI18nForLocale( locale: Locales, ): Promise { + "use cache"; + cacheLife("max"); + const translations = { [locale]: await importLocaleAsync(locale) }; return { translations }; } diff --git a/vibe-docs/Instant-Navigations-TODO.md b/vibe-docs/Instant-Navigations-TODO.md index ea27234f..f372cec4 100644 --- a/vibe-docs/Instant-Navigations-TODO.md +++ b/vibe-docs/Instant-Navigations-TODO.md @@ -42,6 +42,9 @@ ## Phase 3 — Instant Nav / Cache Components (L5 in progress) - [x] **`cacheComponents` + `partialPrefetching`** enabled (incremental — `instant = false` on runtime segments) -- [ ] Remove `instant = false` from marketing routes (cache session/i18n or Suspense-split root `headers()`) -- [ ] `unstable_instant` on marketing routes -- [ ] `@next/playwright` `instant()` tests +- [x] Suspense-split root `headers()` locale read (removed root `instant = false`) +- [x] `'use cache'` on `loadI18nForLocale` + metadata translations; Suspense-wrap session/device in `LocaleAppLayout` +- [x] `instant = true` on marketing pages (`/`, `/privacy`, `/daily`); `instant = false` on playground/profile +- [x] `@next/playwright` `instant()` tests for marketing client navigations +- [ ] Initial page-load instant shell (hard navigation) — metadata/body still stream after root Suspense +- [ ] Playground/profile instant adoption (optional) diff --git a/vibe-docs/Locale-Migration-Design.md b/vibe-docs/Locale-Migration-Design.md index 47e50333..20194982 100644 --- a/vibe-docs/Locale-Migration-Design.md +++ b/vibe-docs/Locale-Migration-Design.md @@ -90,10 +90,12 @@ Pages `i18n` auto-redirects `/en/*` → unprefixed URLs, so **`next.config` rewr ### L5 — Instant Nav flags (in progress) -1. ~~Enable `cacheComponents`, `partialPrefetching`~~ — enabled with `instant = false` on runtime segments. -2. Resolve root `headers()` / Cache Components blockers in `app/layout.tsx` (Suspense split or `'use cache: private'`). -3. Remove `instant = false` from marketing routes; add `unstable_instant` where validated. -4. Add `@next/playwright` `instant()` tests. +1. ~~Enable `cacheComponents`, `partialPrefetching`~~ — enabled. +2. ~~Suspense-split root `headers()` locale read~~ — `RootHtmlShell` + removed root `instant = false`. +3. ~~Cache i18n + Suspense-wrap session/device in locale layouts~~ — `'use cache'` on translations; `instant = true` on marketing pages. +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. --- From bc9e249c9a29509fd899984254563e776711c927 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 23 Aug 2026 20:49:56 +0000 Subject: [PATCH 02/11] style(e2e): fix prettier on instant marketing spec --- e2e/instant-marketing-nav.spec.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/e2e/instant-marketing-nav.spec.ts b/e2e/instant-marketing-nav.spec.ts index e01d742c..61205428 100644 --- a/e2e/instant-marketing-nav.spec.ts +++ b/e2e/instant-marketing-nav.spec.ts @@ -15,7 +15,9 @@ test.describe("instant marketing navigations (L5)", () => { await dismissCookieBannerIfVisible(page); }); - test("privacy is instant on client navigation from home", async ({ page }) => { + test("privacy is instant on client navigation from home", async ({ + page, + }) => { await instant(page, async () => { await page.getByRole("link", { name: /privacy policy/i }).click(); await page.waitForURL((url) => url.pathname === "/privacy"); From 803cbb36ce36be1ef629c399777ef02a9d8bcd56 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 23 Aug 2026 20:57:37 +0000 Subject: [PATCH 03/11] fix(app): single provider mount and correct RTL html dir Address deep review findings on L5 instant marketing: - Remove LocaleAppLayout Suspense swap that remounted TrpcProvider/Redux on every hard navigation (session flash, lost client state). - Restore single AppRootLayoutClient with parallel session/i18n/headers load. - Drop root Suspense fallback that hardcoded lang=en/dir=ltr; read locale header directly in RootHtmlShell with root instant=false. - Re-add locale layout instant=false (session/headers runtime reads). - Add e2e regression for /ar html dir=rtl. --- e2e/app-locale-routes.spec.ts | 8 ++++ src/app/(default-locale)/layout.tsx | 3 ++ src/app/RootHtmlShell.tsx | 21 +--------- src/app/[lang]/layout.tsx | 3 ++ src/app/layout.tsx | 19 ++++----- src/app/locale-app/LocaleAppLayout.tsx | 35 +++++++++------- src/app/locale-app/LocaleAppLayoutRuntime.tsx | 40 ------------------- src/app/locale-app/LocaleAppLayoutShell.tsx | 27 ------------- vibe-docs/Instant-Navigations-TODO.md | 6 +-- vibe-docs/Locale-Migration-Design.md | 4 +- 10 files changed, 48 insertions(+), 118 deletions(-) delete mode 100644 src/app/locale-app/LocaleAppLayoutRuntime.tsx delete mode 100644 src/app/locale-app/LocaleAppLayoutShell.tsx diff --git a/e2e/app-locale-routes.spec.ts b/e2e/app-locale-routes.spec.ts index eefd2665..d0db25d5 100644 --- a/e2e/app-locale-routes.spec.ts +++ b/e2e/app-locale-routes.spec.ts @@ -17,6 +17,14 @@ test.describe("app/[lang] public routes (non-default locales)", () => { ); }); + test("ar home sets rtl document direction from proxy locale header", async ({ + page, + }) => { + await page.goto("/ar"); + await expect(page.locator("html")).toHaveAttribute("lang", "ar"); + await expect(page.locator("html")).toHaveAttribute("dir", "rtl"); + }); + test("de privacy is indexable with locale canonical", async ({ page }) => { await page.goto("/de/privacy"); await expect(page.locator('meta[name="robots"]')).toHaveCount(0); diff --git a/src/app/(default-locale)/layout.tsx b/src/app/(default-locale)/layout.tsx index 7b3c4476..cd19d4a3 100644 --- a/src/app/(default-locale)/layout.tsx +++ b/src/app/(default-locale)/layout.tsx @@ -2,6 +2,9 @@ import { baseLocale } from "#/i18n/i18n-util"; import { LocaleAppLayout } from "#/app/locale-app/LocaleAppLayout"; +/** Locale shell reads session/headers — opts out; marketing pages opt in below (L5). */ +export const instant = false; + /** Default-locale (`en`) public App shell at unprefixed URLs (L2). */ export default async function DefaultLocaleLayout({ children, diff --git a/src/app/RootHtmlShell.tsx b/src/app/RootHtmlShell.tsx index d1371228..49011e45 100644 --- a/src/app/RootHtmlShell.tsx +++ b/src/app/RootHtmlShell.tsx @@ -11,26 +11,7 @@ type RootHtmlShellProps = { children: ReactNode; }; -/** Static fallback while request locale header resolves (L5 Suspense split). */ -export function RootHtmlShellFallback({ children }: RootHtmlShellProps) { - return ( - - - - - - - - {children} - - ); -} - -/** Reads proxy locale header for `` / `dir` (runtime — wrapped in Suspense). */ +/** Reads proxy locale header for `` / `dir` (request-time). */ export async function RootHtmlShell({ children }: RootHtmlShellProps) { const headerList = await headers(); const locale = (headerList.get(APP_ROUTER_LOCALE_HEADER) ?? "en") as Locales; diff --git a/src/app/[lang]/layout.tsx b/src/app/[lang]/layout.tsx index e2719a71..486c48f3 100644 --- a/src/app/[lang]/layout.tsx +++ b/src/app/[lang]/layout.tsx @@ -1,5 +1,8 @@ import { LocaleAppLayout } from "#/app/locale-app/LocaleAppLayout"; +/** Locale shell reads session/headers — opts out; marketing pages opt in below (L5). */ +export const instant = false; + export default async function LangLayout({ children, params, diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 13fe2197..3b311386 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,32 +1,27 @@ -import { Suspense } from "react"; - import "#/shared/fonts/appFonts"; import { appDocumentMetadata, appDocumentViewport, } from "#/shared/lib/appDocumentMetadata"; -import { RootHtmlShell, RootHtmlShellFallback } from "#/app/RootHtmlShell"; +import { RootHtmlShell } from "#/app/RootHtmlShell"; import "#/styles/globals.css"; import "overlayscrollbars/overlayscrollbars.css"; export { appDocumentMetadata as metadata, appDocumentViewport as viewport }; +/** Root reads request locale header — opts out of page-level instant validation (L5). */ +export const instant = false; + /** * Minimal root shell for App Router only. Locale for `` comes from - * {@link APP_ROUTER_LOCALE_HEADER} via {@link RootHtmlShell} (Suspense-split, L5). + * {@link APP_ROUTER_LOCALE_HEADER} (set in proxy for App Router locale paths). */ -export default function RootLayout({ +export default async function RootLayout({ children, }: { children: React.ReactNode; }) { - return ( - {children}} - > - {children} - - ); + return {children}; } diff --git a/src/app/locale-app/LocaleAppLayout.tsx b/src/app/locale-app/LocaleAppLayout.tsx index 33c24075..a7634103 100644 --- a/src/app/locale-app/LocaleAppLayout.tsx +++ b/src/app/locale-app/LocaleAppLayout.tsx @@ -1,12 +1,15 @@ +import { getServerSession } from "next-auth"; +import { headers } from "next/headers"; import { notFound } from "next/navigation"; -import { Suspense } from "react"; import type { Locales } from "#/i18n/i18n-types"; import { locales } from "#/i18n/i18n-util"; import { loadI18nForLocale } from "#/i18n/loadI18nForLocale"; +import { authOptions } from "#/server/auth/authOptions"; +import { APP_ROUTER_SSR_DEVICE_TYPE_HEADER } from "#/shared/lib/appRouterLocaleHeader"; +import { parseSsrDeviceTypeHeader } from "#/shared/lib/ssrDevice"; -import { LocaleAppLayoutRuntime } from "#/app/locale-app/LocaleAppLayoutRuntime"; -import { LocaleAppLayoutShell } from "#/app/locale-app/LocaleAppLayoutShell"; +import { AppRootLayoutClient } from "#/app/AppRootLayoutClient"; /** Shared App Router locale layout for `app/[lang]` and `(default-locale)`. */ export async function LocaleAppLayout({ @@ -20,19 +23,23 @@ export async function LocaleAppLayout({ notFound(); } const locale = localeParam as Locales; - const i18n = await loadI18nForLocale(locale); + const [i18n, session, headerList] = await Promise.all([ + loadI18nForLocale(locale), + getServerSession(authOptions), + headers(), + ]); + const ssrDeviceType = parseSsrDeviceTypeHeader( + headerList.get(APP_ROUTER_SSR_DEVICE_TYPE_HEADER), + ); return ( - - {children} - - } + - - {children} - - + {children} + ); } diff --git a/src/app/locale-app/LocaleAppLayoutRuntime.tsx b/src/app/locale-app/LocaleAppLayoutRuntime.tsx deleted file mode 100644 index 01214543..00000000 --- a/src/app/locale-app/LocaleAppLayoutRuntime.tsx +++ /dev/null @@ -1,40 +0,0 @@ -import { getServerSession } from "next-auth"; -import { headers } from "next/headers"; - -import type { I18nProps } from "#/i18n/getI18nProps"; -import type { Locales } from "#/i18n/i18n-types"; -import { authOptions } from "#/server/auth/authOptions"; -import { APP_ROUTER_SSR_DEVICE_TYPE_HEADER } from "#/shared/lib/appRouterLocaleHeader"; -import { parseSsrDeviceTypeHeader } from "#/shared/lib/ssrDevice"; - -import { AppRootLayoutClient } from "#/app/AppRootLayoutClient"; - -type LocaleAppLayoutRuntimeProps = { - children: React.ReactNode; - locale: Locales; - i18n: I18nProps; -}; - -/** Session + playground device hints — runtime APIs wrapped in Suspense (L5). */ -export async function LocaleAppLayoutRuntime({ - children, - locale, - i18n, -}: LocaleAppLayoutRuntimeProps) { - const session = await getServerSession(authOptions); - const headerList = await headers(); - const ssrDeviceType = parseSsrDeviceTypeHeader( - headerList.get(APP_ROUTER_SSR_DEVICE_TYPE_HEADER), - ); - - return ( - - {children} - - ); -} diff --git a/src/app/locale-app/LocaleAppLayoutShell.tsx b/src/app/locale-app/LocaleAppLayoutShell.tsx deleted file mode 100644 index 699906e3..00000000 --- a/src/app/locale-app/LocaleAppLayoutShell.tsx +++ /dev/null @@ -1,27 +0,0 @@ -"use client"; - -import type { ReactNode } from "react"; - -import type { I18nProps } from "#/i18n/getI18nProps"; -import type { Locales } from "#/i18n/i18n-types"; - -import { AppRootLayoutClient } from "#/app/AppRootLayoutClient"; - -type LocaleAppLayoutShellProps = { - children: ReactNode; - locale: Locales; - i18n: I18nProps; -}; - -/** Instant shell while session / device headers stream in (logged-out, desktop theme). */ -export function LocaleAppLayoutShell({ - children, - locale, - i18n, -}: LocaleAppLayoutShellProps) { - return ( - - {children} - - ); -} diff --git a/vibe-docs/Instant-Navigations-TODO.md b/vibe-docs/Instant-Navigations-TODO.md index f372cec4..3441685d 100644 --- a/vibe-docs/Instant-Navigations-TODO.md +++ b/vibe-docs/Instant-Navigations-TODO.md @@ -42,9 +42,9 @@ ## Phase 3 — Instant Nav / Cache Components (L5 in progress) - [x] **`cacheComponents` + `partialPrefetching`** enabled (incremental — `instant = false` on runtime segments) -- [x] Suspense-split root `headers()` locale read (removed root `instant = false`) -- [x] `'use cache'` on `loadI18nForLocale` + metadata translations; Suspense-wrap session/device in `LocaleAppLayout` +- [x] Suspense-split root `headers()` locale read → direct read + root `instant = false` (correct RTL, no en/ltr fallback) +- [x] `'use cache'` on `loadI18nForLocale` + metadata translations; single `AppRootLayoutClient` (no Suspense provider swap) - [x] `instant = true` on marketing pages (`/`, `/privacy`, `/daily`); `instant = false` on playground/profile - [x] `@next/playwright` `instant()` tests for marketing client navigations -- [ ] Initial page-load instant shell (hard navigation) — metadata/body still stream after root Suspense +- [ ] Initial page-load instant shell (hard navigation) — follow-up - [ ] Playground/profile instant adoption (optional) diff --git a/vibe-docs/Locale-Migration-Design.md b/vibe-docs/Locale-Migration-Design.md index 20194982..7f727463 100644 --- a/vibe-docs/Locale-Migration-Design.md +++ b/vibe-docs/Locale-Migration-Design.md @@ -91,8 +91,8 @@ Pages `i18n` auto-redirects `/en/*` → unprefixed URLs, so **`next.config` rewr ### L5 — Instant Nav flags (in progress) 1. ~~Enable `cacheComponents`, `partialPrefetching`~~ — enabled. -2. ~~Suspense-split root `headers()` locale read~~ — `RootHtmlShell` + removed root `instant = false`. -3. ~~Cache i18n + Suspense-wrap session/device in locale layouts~~ — `'use cache'` on translations; `instant = true` on marketing pages. +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. From f50ff6e841125a6b913f9b095f61c28c3d46d16b Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 23 Aug 2026 21:11:33 +0000 Subject: [PATCH 04/11] fix(ui): recover landing WebGL canvases after App Router navigation Decorative home 3D models showed gray radial-gradient masks with 'THREE.WebGLRenderer: Context Lost' after navigating away and back. Add WebGLCanvasShell: defer mount one frame, dispose renderer on unmount, and remount on webglcontextlost. Use it from LogoModelView and PythonLogoModelView. --- .../lib/__tests__/webglCanvasRecovery.test.ts | 39 +++++++++ src/shared/lib/webglCanvasRecovery.ts | 29 +++++++ src/shared/ui/molecules/LogoModelView.tsx | 9 +- .../ui/molecules/PythonLogoModelView.tsx | 9 +- src/shared/ui/molecules/WebGLCanvasShell.tsx | 83 +++++++++++++++++++ 5 files changed, 157 insertions(+), 12 deletions(-) create mode 100644 src/shared/lib/__tests__/webglCanvasRecovery.test.ts create mode 100644 src/shared/lib/webglCanvasRecovery.ts create mode 100644 src/shared/ui/molecules/WebGLCanvasShell.tsx diff --git a/src/shared/lib/__tests__/webglCanvasRecovery.test.ts b/src/shared/lib/__tests__/webglCanvasRecovery.test.ts new file mode 100644 index 00000000..2a7f844d --- /dev/null +++ b/src/shared/lib/__tests__/webglCanvasRecovery.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it, vi } from "vitest"; + +import { + attachWebGLContextRecovery, + disposeWebGLRenderer, +} from "#/shared/lib/webglCanvasRecovery"; + +describe("attachWebGLContextRecovery", () => { + it("calls onContextLost when webglcontextlost fires", () => { + const canvas = document.createElement("canvas"); + const onContextLost = vi.fn(); + + attachWebGLContextRecovery(canvas, onContextLost); + canvas.dispatchEvent(new Event("webglcontextlost")); + + expect(onContextLost).toHaveBeenCalledTimes(1); + }); + + it("detaches the listener when cleanup runs", () => { + const canvas = document.createElement("canvas"); + const onContextLost = vi.fn(); + + const detach = attachWebGLContextRecovery(canvas, onContextLost); + detach(); + canvas.dispatchEvent(new Event("webglcontextlost")); + + expect(onContextLost).not.toHaveBeenCalled(); + }); +}); + +describe("disposeWebGLRenderer", () => { + it("swallows dispose errors from lost contexts", () => { + expect(() => + disposeWebGLRenderer(() => { + throw new Error("context lost"); + }), + ).not.toThrow(); + }); +}); diff --git a/src/shared/lib/webglCanvasRecovery.ts b/src/shared/lib/webglCanvasRecovery.ts new file mode 100644 index 00000000..176e4b01 --- /dev/null +++ b/src/shared/lib/webglCanvasRecovery.ts @@ -0,0 +1,29 @@ +/** Attach listeners that remount the canvas when the GPU reclaims the WebGL context. */ +export function attachWebGLContextRecovery( + canvas: HTMLCanvasElement, + onContextLost: () => void, +): () => void { + const handleContextLost = (event: Event) => { + event.preventDefault(); + onContextLost(); + }; + + canvas.addEventListener("webglcontextlost", handleContextLost); + + return () => { + canvas.removeEventListener("webglcontextlost", handleContextLost); + }; +} + +/** Best-effort renderer teardown when a decorative canvas unmounts. */ +export function disposeWebGLRenderer(dispose: (() => void) | undefined): void { + if (!dispose) { + return; + } + + try { + dispose(); + } catch { + // Context may already be lost during App Router navigations. + } +} diff --git a/src/shared/ui/molecules/LogoModelView.tsx b/src/shared/ui/molecules/LogoModelView.tsx index 42ae1ff5..f26650c6 100644 --- a/src/shared/ui/molecules/LogoModelView.tsx +++ b/src/shared/ui/molecules/LogoModelView.tsx @@ -1,11 +1,11 @@ import { useTheme } from "@mui/material"; import { OrbitControls } from "@react-three/drei"; -import { Canvas } from "@react-three/fiber"; import React from "react"; import { type OrbitControls as ThreeOrbitControls } from "three-stdlib"; import { useMobileLayout } from "#/shared/hooks"; import { OrbitModelCanvasTouchScroll } from "#/shared/ui/molecules/OrbitModelCanvasTouchScroll"; +import { WebGLCanvasShell } from "#/shared/ui/molecules/WebGLCanvasShell"; import { BinaryTreeModel } from "#/3d-models/BinaryTreeModel"; @@ -32,12 +32,9 @@ export const LogoModelView: React.FC = ({ const pointerEventsDisabled = isMobile || !interactive; return ( - { - gl.setClearColor("#000000", 0); - }} style={{ background: "transparent", ...(pointerEventsDisabled ? { pointerEvents: "none" } : {}), @@ -76,6 +73,6 @@ export const LogoModelView: React.FC = ({ dampingFactor={0.005} /> - + ); }; diff --git a/src/shared/ui/molecules/PythonLogoModelView.tsx b/src/shared/ui/molecules/PythonLogoModelView.tsx index 06485898..0bdd1d7a 100644 --- a/src/shared/ui/molecules/PythonLogoModelView.tsx +++ b/src/shared/ui/molecules/PythonLogoModelView.tsx @@ -1,11 +1,11 @@ import { useTheme } from "@mui/material"; import { OrbitControls } from "@react-three/drei"; -import { Canvas } from "@react-three/fiber"; import React from "react"; import { type OrbitControls as ThreeOrbitControls } from "three-stdlib"; import { useMobileLayout } from "#/shared/hooks"; import { OrbitModelCanvasTouchScroll } from "#/shared/ui/molecules/OrbitModelCanvasTouchScroll"; +import { WebGLCanvasShell } from "#/shared/ui/molecules/WebGLCanvasShell"; import { PythonLogoModel } from "#/3d-models/PythonLogoModel"; @@ -32,12 +32,9 @@ export const PythonLogoModelView: React.FC = ({ const pointerEventsDisabled = isMobile || !interactive; return ( - { - gl.setClearColor("#000000", 0); - }} style={{ background: "transparent", ...(pointerEventsDisabled ? { pointerEvents: "none" } : {}), @@ -76,6 +73,6 @@ export const PythonLogoModelView: React.FC = ({ dampingFactor={0.005} /> - + ); }; diff --git a/src/shared/ui/molecules/WebGLCanvasShell.tsx b/src/shared/ui/molecules/WebGLCanvasShell.tsx new file mode 100644 index 00000000..659753d5 --- /dev/null +++ b/src/shared/ui/molecules/WebGLCanvasShell.tsx @@ -0,0 +1,83 @@ +"use client"; + +import { Canvas, type CanvasProps } from "@react-three/fiber"; +import React, { useEffect, useRef, useState } from "react"; + +import { + attachWebGLContextRecovery, + disposeWebGLRenderer, +} from "#/shared/lib/webglCanvasRecovery"; + +type WebGLCanvasShellInnerProps = CanvasProps & { + onContextLost: () => void; +}; + +const WebGLCanvasShellInner: React.FC = ({ + onContextLost, + onCreated, + ...canvasProps +}) => { + const [isReady, setIsReady] = useState(false); + const disposeRendererRef = useRef<(() => void) | undefined>(undefined); + const detachContextRecoveryRef = useRef<(() => void) | undefined>(undefined); + + useEffect(() => { + const frameId = window.requestAnimationFrame(() => { + setIsReady(true); + }); + + return () => { + window.cancelAnimationFrame(frameId); + detachContextRecoveryRef.current?.(); + detachContextRecoveryRef.current = undefined; + disposeWebGLRenderer(disposeRendererRef.current); + disposeRendererRef.current = undefined; + }; + }, []); + + if (!isReady) { + return null; + } + + return ( + { + const { gl } = state; + gl.setClearColor("#000000", 0); + + detachContextRecoveryRef.current?.(); + detachContextRecoveryRef.current = attachWebGLContextRecovery( + gl.domElement, + onContextLost, + ); + disposeRendererRef.current = () => { + gl.dispose(); + }; + + onCreated?.(state); + }} + /> + ); +}; + +type WebGLCanvasShellProps = CanvasProps; + +/** + * R3F canvas wrapper for decorative marketing models. + * Defers mount one frame (avoids off-screen prefetch init), disposes on unmount, + * and remounts when the browser reports `webglcontextlost`. + */ +export const WebGLCanvasShell: React.FC = (props) => { + const [canvasKey, setCanvasKey] = useState(0); + + return ( + { + setCanvasKey((previousKey) => previousKey + 1); + }} + /> + ); +}; From a7473340f38ad855ae7911faadf76e8faf58f9b8 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 23 Aug 2026 21:20:16 +0000 Subject: [PATCH 05/11] test(e2e): cover landing WebGL recovery after navigation Assert active WebGL canvases on home after privacy round-trip with no Context Lost console errors, and remount after WEBGL_lose_context. --- e2e/helpers/landingWebGLCanvases.ts | 72 +++++++++++++++++++++++++++++ e2e/home-webgl-nav.spec.ts | 64 +++++++++++++++++++++++++ 2 files changed, 136 insertions(+) create mode 100644 e2e/helpers/landingWebGLCanvases.ts create mode 100644 e2e/home-webgl-nav.spec.ts diff --git a/e2e/helpers/landingWebGLCanvases.ts b/e2e/helpers/landingWebGLCanvases.ts new file mode 100644 index 00000000..d70f927c --- /dev/null +++ b/e2e/helpers/landingWebGLCanvases.ts @@ -0,0 +1,72 @@ +import type { Page } from "@playwright/test"; + +export type WebGLCanvasState = { + width: number; + height: number; + contextLost: boolean; +}; + +/** Decorative landing canvases are R3F WebGL (not 2d/metrics). */ +export async function readLandingWebGLCanvasStates( + page: Page, +): Promise { + return page.evaluate(() => { + const states: WebGLCanvasState[] = []; + + for (const canvas of document.querySelectorAll("canvas")) { + if (!(canvas instanceof HTMLCanvasElement)) { + continue; + } + + const context = canvas.getContext("webgl2") ?? canvas.getContext("webgl"); + if (!context) { + continue; + } + + states.push({ + width: canvas.width, + height: canvas.height, + contextLost: context.isContextLost(), + }); + } + + return states; + }); +} + +const isActiveLandingCanvas = (state: WebGLCanvasState) => + !state.contextLost && state.width >= 64 && state.height >= 64; + +/** Home mounts two decorative models (hero tree + Python section). */ +export async function waitForActiveLandingWebGLCanvases( + page: Page, + expectedMin = 2, + timeoutMs = 20_000, +): Promise { + const deadline = Date.now() + timeoutMs; + + while (Date.now() < deadline) { + const states = await readLandingWebGLCanvasStates(page); + const active = states.filter(isActiveLandingCanvas); + if (active.length >= expectedMin) { + return active; + } + await page.waitForTimeout(250); + } + + const lastStates = await readLandingWebGLCanvasStates(page); + throw new Error( + `Expected at least ${expectedMin} active WebGL canvases, got ${lastStates.filter(isActiveLandingCanvas).length}: ${JSON.stringify(lastStates)}`, + ); +} + +export function collectWebGlContextLostMessages(page: Page): string[] { + const messages: string[] = []; + page.on("console", (message) => { + const text = message.text(); + if (/webglrenderer:\s*context lost/i.test(text)) { + messages.push(text); + } + }); + return messages; +} diff --git a/e2e/home-webgl-nav.spec.ts b/e2e/home-webgl-nav.spec.ts new file mode 100644 index 00000000..2268af57 --- /dev/null +++ b/e2e/home-webgl-nav.spec.ts @@ -0,0 +1,64 @@ +import { expect, test } from "@playwright/test"; + +import { dismissCookieBannerIfVisible } from "./helpers/dismissCookieBanner"; +import { + collectWebGlContextLostMessages, + waitForActiveLandingWebGLCanvases, +} from "./helpers/landingWebGLCanvases"; + +test.describe("home landing WebGL canvases", () => { + test.describe.configure({ mode: "serial" }); + + test("keep active WebGL contexts after client navigation away and back", async ({ + page, + }) => { + const contextLostMessages = collectWebGlContextLostMessages(page); + + await page.goto("/"); + await dismissCookieBannerIfVisible(page); + await waitForActiveLandingWebGLCanvases(page); + + await page.getByRole("link", { name: /privacy policy/i }).click(); + await page.waitForURL((url) => url.pathname === "/privacy"); + + await page + .getByRole("link", { name: /dstruct/i }) + .first() + .click(); + await page.waitForURL((url) => url.pathname === "/"); + + const activeCanvases = await waitForActiveLandingWebGLCanvases(page); + expect(activeCanvases.length).toBeGreaterThanOrEqual(2); + expect(contextLostMessages).toEqual([]); + }); + + test("remounts landing canvases after forced WEBGL_lose_context", async ({ + page, + }) => { + await page.goto("/"); + await dismissCookieBannerIfVisible(page); + await waitForActiveLandingWebGLCanvases(page); + + const lostCount = await page.evaluate(() => { + let lost = 0; + for (const canvas of document.querySelectorAll("canvas")) { + if (!(canvas instanceof HTMLCanvasElement)) { + continue; + } + const context = + canvas.getContext("webgl2") ?? canvas.getContext("webgl"); + const extension = context?.getExtension("WEBGL_lose_context"); + if (extension) { + extension.loseContext(); + lost += 1; + } + } + return lost; + }); + + expect(lostCount).toBeGreaterThanOrEqual(1); + + const recovered = await waitForActiveLandingWebGLCanvases(page); + expect(recovered.length).toBeGreaterThanOrEqual(2); + }); +}); From 6701015d6928a545935ce7cc41744f06ad9456ee Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 23 Aug 2026 21:35:09 +0000 Subject: [PATCH 06/11] fix(ui): remount WebGL after Activity hide and stabilize e2e Reset isReady on WebGLCanvasShell cleanup so Cache Components Activity boundaries recreate canvases after gl.dispose(). Defer context-loss remount one frame and tighten e2e helpers (footer privacy link, active-canvas-only lose_context, instant-nav WebGL assertion). --- e2e/helpers/dismissCookieBanner.ts | 8 +++ e2e/helpers/landingWebGLCanvases.ts | 61 +++++++++++++++----- e2e/home-webgl-nav.spec.ts | 34 +++++------ e2e/instant-marketing-nav.spec.ts | 9 ++- src/shared/ui/molecules/WebGLCanvasShell.tsx | 12 +++- 5 files changed, 84 insertions(+), 40 deletions(-) diff --git a/e2e/helpers/dismissCookieBanner.ts b/e2e/helpers/dismissCookieBanner.ts index e3b02111..721ef3be 100644 --- a/e2e/helpers/dismissCookieBanner.ts +++ b/e2e/helpers/dismissCookieBanner.ts @@ -14,3 +14,11 @@ export async function dismissCookieBannerIfVisible(page: Page): Promise { .waitFor({ state: "hidden", timeout: 15_000 }) .catch(() => undefined); } + +/** Footer privacy link (avoids cookie-banner duplicate when banner is still open). */ +export async function clickFooterPrivacyPolicyLink(page: Page): Promise { + await page + .getByRole("contentinfo") + .getByRole("link", { name: /privacy policy/i }) + .click(); +} diff --git a/e2e/helpers/landingWebGLCanvases.ts b/e2e/helpers/landingWebGLCanvases.ts index d70f927c..34662ce2 100644 --- a/e2e/helpers/landingWebGLCanvases.ts +++ b/e2e/helpers/landingWebGLCanvases.ts @@ -1,4 +1,4 @@ -import type { Page } from "@playwright/test"; +import { expect, type Page } from "@playwright/test"; export type WebGLCanvasState = { width: number; @@ -37,27 +37,27 @@ export async function readLandingWebGLCanvasStates( const isActiveLandingCanvas = (state: WebGLCanvasState) => !state.contextLost && state.width >= 64 && state.height >= 64; +export async function countActiveLandingWebGLCanvases( + page: Page, +): Promise { + const states = await readLandingWebGLCanvasStates(page); + return states.filter(isActiveLandingCanvas).length; +} + /** Home mounts two decorative models (hero tree + Python section). */ export async function waitForActiveLandingWebGLCanvases( page: Page, expectedMin = 2, timeoutMs = 20_000, ): Promise { - const deadline = Date.now() + timeoutMs; + await expect + .poll(async () => countActiveLandingWebGLCanvases(page), { + timeout: timeoutMs, + }) + .toBeGreaterThanOrEqual(expectedMin); - while (Date.now() < deadline) { - const states = await readLandingWebGLCanvasStates(page); - const active = states.filter(isActiveLandingCanvas); - if (active.length >= expectedMin) { - return active; - } - await page.waitForTimeout(250); - } - - const lastStates = await readLandingWebGLCanvasStates(page); - throw new Error( - `Expected at least ${expectedMin} active WebGL canvases, got ${lastStates.filter(isActiveLandingCanvas).length}: ${JSON.stringify(lastStates)}`, - ); + const states = await readLandingWebGLCanvasStates(page); + return states.filter(isActiveLandingCanvas); } export function collectWebGlContextLostMessages(page: Page): string[] { @@ -70,3 +70,34 @@ export function collectWebGlContextLostMessages(page: Page): string[] { }); return messages; } + +const MIN_LANDING_CANVAS_DIMENSION_PX = 64; + +/** Forces WEBGL_lose_context on active landing-sized WebGL canvases (recovery e2e). */ +export async function forceLoseActiveLandingWebGLContexts( + page: Page, +): Promise { + return page.evaluate((minDimension) => { + let lost = 0; + for (const canvas of document.querySelectorAll("canvas")) { + if (!(canvas instanceof HTMLCanvasElement)) { + continue; + } + if (canvas.width < minDimension || canvas.height < minDimension) { + continue; + } + + const context = canvas.getContext("webgl2") ?? canvas.getContext("webgl"); + if (!context || context.isContextLost()) { + continue; + } + + const extension = context.getExtension("WEBGL_lose_context"); + if (extension) { + extension.loseContext(); + lost += 1; + } + } + return lost; + }, MIN_LANDING_CANVAS_DIMENSION_PX); +} diff --git a/e2e/home-webgl-nav.spec.ts b/e2e/home-webgl-nav.spec.ts index 2268af57..f04843b9 100644 --- a/e2e/home-webgl-nav.spec.ts +++ b/e2e/home-webgl-nav.spec.ts @@ -1,8 +1,12 @@ import { expect, test } from "@playwright/test"; -import { dismissCookieBannerIfVisible } from "./helpers/dismissCookieBanner"; +import { + clickFooterPrivacyPolicyLink, + dismissCookieBannerIfVisible, +} from "./helpers/dismissCookieBanner"; import { collectWebGlContextLostMessages, + forceLoseActiveLandingWebGLContexts, waitForActiveLandingWebGLCanvases, } from "./helpers/landingWebGLCanvases"; @@ -18,7 +22,7 @@ test.describe("home landing WebGL canvases", () => { await dismissCookieBannerIfVisible(page); await waitForActiveLandingWebGLCanvases(page); - await page.getByRole("link", { name: /privacy policy/i }).click(); + await clickFooterPrivacyPolicyLink(page); await page.waitForURL((url) => url.pathname === "/privacy"); await page @@ -37,28 +41,16 @@ test.describe("home landing WebGL canvases", () => { }) => { await page.goto("/"); await dismissCookieBannerIfVisible(page); - await waitForActiveLandingWebGLCanvases(page); - - const lostCount = await page.evaluate(() => { - let lost = 0; - for (const canvas of document.querySelectorAll("canvas")) { - if (!(canvas instanceof HTMLCanvasElement)) { - continue; - } - const context = - canvas.getContext("webgl2") ?? canvas.getContext("webgl"); - const extension = context?.getExtension("WEBGL_lose_context"); - if (extension) { - extension.loseContext(); - lost += 1; - } - } - return lost; + // Python decor lives below the fold; scroll so both models mount WebGL canvases. + await page.evaluate(() => { + window.scrollTo(0, document.body.scrollHeight); }); + await waitForActiveLandingWebGLCanvases(page, 2, 30_000); - expect(lostCount).toBeGreaterThanOrEqual(1); + const lostCount = await forceLoseActiveLandingWebGLContexts(page); + expect(lostCount).toBeGreaterThanOrEqual(2); - const recovered = await waitForActiveLandingWebGLCanvases(page); + const recovered = await waitForActiveLandingWebGLCanvases(page, 2, 30_000); expect(recovered.length).toBeGreaterThanOrEqual(2); }); }); diff --git a/e2e/instant-marketing-nav.spec.ts b/e2e/instant-marketing-nav.spec.ts index 61205428..570e2f57 100644 --- a/e2e/instant-marketing-nav.spec.ts +++ b/e2e/instant-marketing-nav.spec.ts @@ -1,7 +1,11 @@ import { instant } from "@next/playwright"; import { expect, test } from "@playwright/test"; -import { dismissCookieBannerIfVisible } from "./helpers/dismissCookieBanner"; +import { + clickFooterPrivacyPolicyLink, + dismissCookieBannerIfVisible, +} from "./helpers/dismissCookieBanner"; +import { waitForActiveLandingWebGLCanvases } from "./helpers/landingWebGLCanvases"; /** * L5: marketing pages use `instant = true`; client navigations between them are validated. @@ -19,7 +23,7 @@ test.describe("instant marketing navigations (L5)", () => { page, }) => { await instant(page, async () => { - await page.getByRole("link", { name: /privacy policy/i }).click(); + await clickFooterPrivacyPolicyLink(page); await page.waitForURL((url) => url.pathname === "/privacy"); await expect(page.getByRole("heading", { level: 1 })).toHaveText( "Privacy Policy", @@ -55,5 +59,6 @@ test.describe("instant marketing navigations (L5)", () => { "Your code, frame by frame.", ); }); + await waitForActiveLandingWebGLCanvases(page); }); }); diff --git a/src/shared/ui/molecules/WebGLCanvasShell.tsx b/src/shared/ui/molecules/WebGLCanvasShell.tsx index 659753d5..eee96e9a 100644 --- a/src/shared/ui/molecules/WebGLCanvasShell.tsx +++ b/src/shared/ui/molecules/WebGLCanvasShell.tsx @@ -21,13 +21,17 @@ const WebGLCanvasShellInner: React.FC = ({ const disposeRendererRef = useRef<(() => void) | undefined>(undefined); const detachContextRecoveryRef = useRef<(() => void) | undefined>(undefined); + // Defer mount one frame; reset on cleanup so Cache Components / Activity + // hide-show cycles recreate the canvas after gl.dispose(). useEffect(() => { - const frameId = window.requestAnimationFrame(() => { + let frameId = 0; + frameId = window.requestAnimationFrame(() => { setIsReady(true); }); return () => { window.cancelAnimationFrame(frameId); + setIsReady(false); detachContextRecoveryRef.current?.(); detachContextRecoveryRef.current = undefined; disposeWebGLRenderer(disposeRendererRef.current); @@ -76,7 +80,11 @@ export const WebGLCanvasShell: React.FC = (props) => { key={canvasKey} {...props} onContextLost={() => { - setCanvasKey((previousKey) => previousKey + 1); + // Defer remount one frame so gl.dispose() from the lost context can finish + // before R3F allocates a replacement (avoids flaky dual-canvas recovery). + window.requestAnimationFrame(() => { + setCanvasKey((previousKey) => previousKey + 1); + }); }} /> ); From 934a41f490d51fe5dd0ed9a99d081500b9e94640 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 24 Aug 2026 16:21:17 +0000 Subject: [PATCH 07/11] fix(codeRunner): remount Monaco after Activity hide on playground nav MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Defer Monaco mount one frame and reset on cleanup so Cache Components Activity hide-show cycles recreate the editor instead of rendering against a disposed InstantiationService. Clear CodePanel refs on unmount. Add e2e that navigates home ↔ playground three times and asserts Monaco stays visible with no dispose/domNode console errors. --- e2e/helpers/playgroundMonacoEditor.ts | 52 ++++++++++ e2e/playground-monaco-nav.spec.ts | 52 ++++++++++ src/features/codeRunner/ui/CodePanel.tsx | 8 ++ src/features/codeRunner/ui/CodeRunner.tsx | 117 ++++++++++++++-------- 4 files changed, 190 insertions(+), 39 deletions(-) create mode 100644 e2e/helpers/playgroundMonacoEditor.ts create mode 100644 e2e/playground-monaco-nav.spec.ts diff --git a/e2e/helpers/playgroundMonacoEditor.ts b/e2e/helpers/playgroundMonacoEditor.ts new file mode 100644 index 00000000..1e479ff6 --- /dev/null +++ b/e2e/helpers/playgroundMonacoEditor.ts @@ -0,0 +1,52 @@ +import { expect, type Page } from "@playwright/test"; + +/** Playground code panel uses Monaco (desktop split layout and mobile code view). */ +export async function waitForPlaygroundMonacoEditor( + page: Page, + timeoutMs = 30_000, +): Promise { + const editorLines = page.locator(".monaco-editor .view-lines").first(); + await expect(editorLines).toBeVisible({ timeout: timeoutMs }); +} + +export function collectMonacoRuntimeErrors(page: Page): string[] { + const messages: string[] = []; + + page.on("console", (message) => { + const type = message.type(); + if (type !== "error" && type !== "warning") { + return; + } + + const text = message.text(); + if ( + /instantiationservice has been disposed/i.test(text) || + /reading 'domnode'/i.test(text) || + /renderText/i.test(text) + ) { + messages.push(text); + } + }); + + page.on("pageerror", (error) => { + const text = error.message; + if ( + /instantiationservice has been disposed/i.test(text) || + /reading 'domnode'/i.test(text) || + /renderText/i.test(text) + ) { + messages.push(text); + } + }); + + return messages; +} + +/** Brand link back to marketing home. */ +export async function clickAppBarHomeLink(page: Page): Promise { + await page + .getByRole("banner") + .getByRole("link", { name: /dstruct/i }) + .first() + .click(); +} diff --git a/e2e/playground-monaco-nav.spec.ts b/e2e/playground-monaco-nav.spec.ts new file mode 100644 index 00000000..39d10547 --- /dev/null +++ b/e2e/playground-monaco-nav.spec.ts @@ -0,0 +1,52 @@ +import { expect, test } from "@playwright/test"; + +import { dismissCookieBannerIfVisible } from "./helpers/dismissCookieBanner"; +import { + clickAppBarHomeLink, + collectMonacoRuntimeErrors, + waitForPlaygroundMonacoEditor, +} from "./helpers/playgroundMonacoEditor"; + +const PLAYGROUND_CODE_URL = "/playground/invert-binary-tree?view=code"; + +test.describe("playground Monaco editor navigation", () => { + test.describe.configure({ mode: "serial" }); + + test("remounts Monaco after home and playground navigations", async ({ + page, + }) => { + test.setTimeout(120_000); + + const monacoErrors = collectMonacoRuntimeErrors(page); + + await page.goto("/"); + await dismissCookieBannerIfVisible(page); + await page.evaluate(() => { + localStorage.removeItem("lastPlaygroundPath"); + }); + + for (let roundIndex = 0; roundIndex < 3; roundIndex += 1) { + await page.getByTestId("cta-to-playground").click(); + await page.waitForURL( + (url) => url.pathname === "/playground/invert-binary-tree", + { timeout: 30_000 }, + ); + await waitForPlaygroundMonacoEditor(page); + + await clickAppBarHomeLink(page); + await page.waitForURL((url) => url.pathname === "/", { + timeout: 30_000, + }); + await dismissCookieBannerIfVisible(page); + } + + await page.goto(PLAYGROUND_CODE_URL); + await dismissCookieBannerIfVisible(page); + await waitForPlaygroundMonacoEditor(page); + + await expect( + page.locator(".monaco-editor .view-lines").first(), + ).toBeVisible(); + expect(monacoErrors).toEqual([]); + }); +}); diff --git a/src/features/codeRunner/ui/CodePanel.tsx b/src/features/codeRunner/ui/CodePanel.tsx index e7ffb9b1..137ade26 100644 --- a/src/features/codeRunner/ui/CodePanel.tsx +++ b/src/features/codeRunner/ui/CodePanel.tsx @@ -128,6 +128,13 @@ export const CodePanel: React.FC = ({ const store = useAppStore(); const playbackDecorationsRef = useRef([]); + const handleMonacoUnmount = useCallback(() => { + playbackDecorationsRef.current = []; + setMonacoInstance(null); + setEditorInstance(null); + setTextModel(null); + }, []); + const selectedProject = api.project.getBySlug.useQuery(projectSlug || "", { enabled: Boolean(projectSlug), }); @@ -570,6 +577,7 @@ export const CodePanel: React.FC = ({ setMonacoInstance={setMonacoInstance} setEditorInstance={setEditorInstance} setTextModel={setTextModel} + onEditorUnmount={handleMonacoUnmount} /> >; setTextModel: React.Dispatch>; + /** Clears parent-held Monaco refs when the shell unmounts or Activity hides the route. */ + onEditorUnmount?: () => void; }; -export const CodeRunner: React.FC = ({ +type MonacoEditorShellInnerProps = CodeRunnerProps; + +const MonacoEditorShellInner: React.FC = ({ + onEditorUnmount, setMonacoInstance, setEditorInstance, setTextModel, @@ -29,6 +34,25 @@ export const CodeRunner: React.FC = ({ }) => { const { mode } = useColorScheme(); const isMobile = useMobileLayout(); + const [isReady, setIsReady] = useState(false); + + // Defer mount one frame; reset on cleanup so Cache Components / Activity + // hide-show cycles recreate Monaco after @monaco-editor/react disposes on unmount. + useEffect(() => { + let frameId = 0; + frameId = window.requestAnimationFrame(() => { + setIsReady(true); + }); + + return () => { + window.cancelAnimationFrame(frameId); + setIsReady(false); + onEditorUnmount?.(); + }; + }, [onEditorUnmount]); + + const resolvedHeight = + typeof height === "number" ? `calc(${height}px - 6vh)` : height; return ( = ({ height: height === "100%" ? "100%" : undefined, }} > - { - const model = editor.getModel(); + {!isReady ? ( + + ) : ( + { + const model = mountedEditor.getModel(); - if (!model) { - console.error("No model found"); - return; - } + if (!model) { + console.error("No model found"); + return; + } - setEditorInstance?.(editor); - setMonacoInstance?.(monaco); - setTextModel(model); + setEditorInstance?.(mountedEditor); + setMonacoInstance?.(monaco); + setTextModel(model); - monaco.editor.defineTheme("app-dark", { - base: "vs-dark", - inherit: true, - rules: [], - colors: { - "editor.background": "#00000000", - focusBorder: "#00000000", - }, - }); + monaco.editor.defineTheme("app-dark", { + base: "vs-dark", + inherit: true, + rules: [], + colors: { + "editor.background": "#00000000", + focusBorder: "#00000000", + }, + }); - // set app-dark theme - monaco.editor.setTheme("app-dark"); - }} - /> + monaco.editor.setTheme("app-dark"); + }} + /> + )} ); }; + +/** + * Monaco wrapper for the playground code panel. + * Defers mount one frame, disposes on Activity hide/unmount, and clears parent refs. + */ +export const CodeRunner: React.FC = (props) => { + return ; +}; From 2de259950fe8e9cd4b8add858ccb8ad01a22986a Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 24 Aug 2026 16:45:51 +0000 Subject: [PATCH 08/11] fix(app): Activity lifecycle for Pyodide, deferred mount, and RAF cancel Add pythonRunner.release() and usePlaygroundRuntimeRelease so Pyodide terminates when playground hides under cacheComponents. Extract shared useDeferredClientMount for WebGL and Monaco shells. Cancel benchmark throttleWithRAF on unmount. Add e2e for Pyodide playground leave/return, hero preview instant nav, and document heavy-client lifecycle policy. --- e2e/helpers/playgroundMonacoEditor.ts | 26 ++++++++++++ e2e/home-hero-preview-nav.spec.ts | 42 +++++++++++++++++++ e2e/playground-monaco-nav.spec.ts | 35 ++++++++++++++-- .../codeRunner/hooks/useCodeExecution.ts | 9 +++- .../codeRunner/lib/pythonRunner.spec.ts | 14 +++++++ src/features/codeRunner/lib/pythonRunner.ts | 14 ++++++- src/features/codeRunner/ui/CodeRunner.tsx | 20 ++------- .../hooks/usePlaygroundRuntimeRelease.ts | 8 ++++ .../playground/ui/PlaygroundPageView.tsx | 3 ++ src/shared/hooks/useDeferredClientMount.ts | 24 +++++++++++ src/shared/lib/throttleWithRAF.ts | 22 ++++++++-- src/shared/ui/molecules/WebGLCanvasShell.tsx | 27 ++++-------- vibe-docs/Instant-Navigations-Design.md | 15 +++++++ vibe-docs/Instant-Navigations-TODO.md | 3 ++ 14 files changed, 219 insertions(+), 43 deletions(-) create mode 100644 e2e/home-hero-preview-nav.spec.ts create mode 100644 src/features/playground/hooks/usePlaygroundRuntimeRelease.ts create mode 100644 src/shared/hooks/useDeferredClientMount.ts diff --git a/e2e/helpers/playgroundMonacoEditor.ts b/e2e/helpers/playgroundMonacoEditor.ts index 1e479ff6..548a4f0a 100644 --- a/e2e/helpers/playgroundMonacoEditor.ts +++ b/e2e/helpers/playgroundMonacoEditor.ts @@ -42,6 +42,32 @@ export function collectMonacoRuntimeErrors(page: Page): string[] { return messages; } +export function collectPythonRunnerRuntimeErrors(page: Page): string[] { + const messages: string[] = []; + + const maybeCollect = (text: string) => { + if ( + /pythonrunner has been disposed/i.test(text) || + /worker crashed/i.test(text) || + /pyodide/i.test(text) + ) { + messages.push(text); + } + }; + + page.on("console", (message) => { + if (message.type() === "error") { + maybeCollect(message.text()); + } + }); + + page.on("pageerror", (error) => { + maybeCollect(error.message); + }); + + return messages; +} + /** Brand link back to marketing home. */ export async function clickAppBarHomeLink(page: Page): Promise { await page diff --git a/e2e/home-hero-preview-nav.spec.ts b/e2e/home-hero-preview-nav.spec.ts new file mode 100644 index 00000000..761fd983 --- /dev/null +++ b/e2e/home-hero-preview-nav.spec.ts @@ -0,0 +1,42 @@ +import { instant } from "@next/playwright"; +import { expect, test } from "@playwright/test"; + +import { + clickFooterPrivacyPolicyLink, + dismissCookieBannerIfVisible, +} from "./helpers/dismissCookieBanner"; +import { clickAppBarHomeLink } from "./helpers/playgroundMonacoEditor"; + +test.describe("home hero preview instant navigation", () => { + test.describe.configure({ mode: "serial" }); + + test("keeps playback controls after instant nav away and back", async ({ + page, + }) => { + test.setTimeout(90_000); + + await page.goto("/"); + await dismissCookieBannerIfVisible(page); + + const previewHeading = page.getByRole("heading", { + name: "Time-travel playback", + exact: true, + }); + await expect(previewHeading).toBeVisible(); + const playButton = page.getByRole("button", { name: "Play", exact: true }); + await expect(playButton).toBeEnabled({ timeout: 30_000 }); + + await instant(page, async () => { + await clickFooterPrivacyPolicyLink(page); + await page.waitForURL((url) => url.pathname === "/privacy"); + }); + + await instant(page, async () => { + await clickAppBarHomeLink(page); + await page.waitForURL((url) => url.pathname === "/"); + }); + + await expect(previewHeading).toBeVisible(); + await expect(playButton).toBeEnabled({ timeout: 30_000 }); + }); +}); diff --git a/e2e/playground-monaco-nav.spec.ts b/e2e/playground-monaco-nav.spec.ts index 39d10547..3c099c05 100644 --- a/e2e/playground-monaco-nav.spec.ts +++ b/e2e/playground-monaco-nav.spec.ts @@ -4,12 +4,14 @@ import { dismissCookieBannerIfVisible } from "./helpers/dismissCookieBanner"; import { clickAppBarHomeLink, collectMonacoRuntimeErrors, + collectPythonRunnerRuntimeErrors, waitForPlaygroundMonacoEditor, } from "./helpers/playgroundMonacoEditor"; -const PLAYGROUND_CODE_URL = "/playground/invert-binary-tree?view=code"; +const PLAYGROUND_PYTHON_URL = + "/playground/invert-binary-tree?view=code&language=python"; -test.describe("playground Monaco editor navigation", () => { +test.describe("playground runtime navigation", () => { test.describe.configure({ mode: "serial" }); test("remounts Monaco after home and playground navigations", async ({ @@ -40,7 +42,7 @@ test.describe("playground Monaco editor navigation", () => { await dismissCookieBannerIfVisible(page); } - await page.goto(PLAYGROUND_CODE_URL); + await page.goto("/playground/invert-binary-tree?view=code"); await dismissCookieBannerIfVisible(page); await waitForPlaygroundMonacoEditor(page); @@ -49,4 +51,31 @@ test.describe("playground Monaco editor navigation", () => { ).toBeVisible(); expect(monacoErrors).toEqual([]); }); + + test("releases Pyodide worker after leaving playground with Python selected", async ({ + page, + }) => { + test.setTimeout(120_000); + + const runtimeErrors = [ + ...collectMonacoRuntimeErrors(page), + ...collectPythonRunnerRuntimeErrors(page), + ]; + + await page.goto(PLAYGROUND_PYTHON_URL); + await dismissCookieBannerIfVisible(page); + await waitForPlaygroundMonacoEditor(page); + + await clickAppBarHomeLink(page); + await page.waitForURL((url) => url.pathname === "/", { timeout: 30_000 }); + + await page.goto(PLAYGROUND_PYTHON_URL); + await dismissCookieBannerIfVisible(page); + await waitForPlaygroundMonacoEditor(page); + + await expect( + page.locator(".monaco-editor .view-lines").first(), + ).toBeVisible(); + expect(runtimeErrors).toEqual([]); + }); }); diff --git a/src/features/codeRunner/hooks/useCodeExecution.ts b/src/features/codeRunner/hooks/useCodeExecution.ts index f29d3a1f..a7690769 100644 --- a/src/features/codeRunner/hooks/useCodeExecution.ts +++ b/src/features/codeRunner/hooks/useCodeExecution.ts @@ -1,7 +1,7 @@ "use client"; import { useSnackbar } from "notistack"; -import { useCallback, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { useDispatch } from "react-redux"; import { generate } from "short-uuid"; @@ -107,6 +107,13 @@ export const useCodeExecution = ( [dispatch], ); + useEffect( + () => () => { + throttledBenchmarkProgress.cancel(); + }, + [throttledBenchmarkProgress], + ); + // Handles execution errors and updates Redux store accordingly const handleExecutionError = useCallback( (errorValue: unknown, startTimestamp: number) => { diff --git a/src/features/codeRunner/lib/pythonRunner.spec.ts b/src/features/codeRunner/lib/pythonRunner.spec.ts index 5780530b..28e6e695 100644 --- a/src/features/codeRunner/lib/pythonRunner.spec.ts +++ b/src/features/codeRunner/lib/pythonRunner.spec.ts @@ -95,4 +95,18 @@ describe("PythonRunner.formatCode", () => { runner.dispose(); }); + + it("release resets worker so init can warm again after Activity hide", async () => { + const runner = new PythonRunner(); + await runner.init({ workerFactory: () => new MockPythonWorker() }); + expect(runner.isReady).toBe(true); + + runner.release(); + expect(runner.isReady).toBe(false); + + await runner.init({ workerFactory: () => new MockPythonWorker() }); + expect(runner.isReady).toBe(true); + + runner.dispose(); + }); }); diff --git a/src/features/codeRunner/lib/pythonRunner.ts b/src/features/codeRunner/lib/pythonRunner.ts index ea3371f5..a9300338 100644 --- a/src/features/codeRunner/lib/pythonRunner.ts +++ b/src/features/codeRunner/lib/pythonRunner.ts @@ -340,7 +340,7 @@ class PythonRunner { }); } - /** Terminate the worker and release resources. */ + /** Terminate the worker and release resources. Terminal — use {@link release} for Activity cycles. */ dispose(): void { this.state = "disposed"; this.initPromise = null; @@ -350,6 +350,18 @@ class PythonRunner { } } + /** + * Terminate worker and reset to idle so `init()` can warm a fresh worker after + * playground Activity hide/show or client navigation away from `/playground`. + */ + release(): void { + if (this.state === "disposed") { + return; + } + this.onProgressRef.current = null; + this.resetWorker(); + } + /** Whether the runner has a warm, ready-to-use worker. */ get isReady(): boolean { return this.state === "ready"; diff --git a/src/features/codeRunner/ui/CodeRunner.tsx b/src/features/codeRunner/ui/CodeRunner.tsx index ce36ac1d..14add683 100644 --- a/src/features/codeRunner/ui/CodeRunner.tsx +++ b/src/features/codeRunner/ui/CodeRunner.tsx @@ -2,8 +2,9 @@ import type { EditorProps, Monaco } from "@monaco-editor/react"; import { Box, Skeleton, useColorScheme } from "@mui/material"; import type { editor } from "monaco-editor"; import dynamic from "next/dynamic"; -import React, { useEffect, useState } from "react"; +import React from "react"; +import { useDeferredClientMount } from "#/shared/hooks/useDeferredClientMount"; import { useMobileLayout } from "#/shared/hooks/useMobileLayout"; const MonacoEditor = dynamic(() => import("@monaco-editor/react"), { @@ -34,22 +35,7 @@ const MonacoEditorShellInner: React.FC = ({ }) => { const { mode } = useColorScheme(); const isMobile = useMobileLayout(); - const [isReady, setIsReady] = useState(false); - - // Defer mount one frame; reset on cleanup so Cache Components / Activity - // hide-show cycles recreate Monaco after @monaco-editor/react disposes on unmount. - useEffect(() => { - let frameId = 0; - frameId = window.requestAnimationFrame(() => { - setIsReady(true); - }); - - return () => { - window.cancelAnimationFrame(frameId); - setIsReady(false); - onEditorUnmount?.(); - }; - }, [onEditorUnmount]); + const isReady = useDeferredClientMount(onEditorUnmount); const resolvedHeight = typeof height === "number" ? `calc(${height}px - 6vh)` : height; diff --git a/src/features/playground/hooks/usePlaygroundRuntimeRelease.ts b/src/features/playground/hooks/usePlaygroundRuntimeRelease.ts new file mode 100644 index 00000000..38e4866d --- /dev/null +++ b/src/features/playground/hooks/usePlaygroundRuntimeRelease.ts @@ -0,0 +1,8 @@ +import { useEffect } from "react"; + +import { pythonRunner } from "#/features/codeRunner/lib/pythonRunner"; + +/** Release Pyodide when playground hides or unmounts (Activity / cacheComponents). */ +export const usePlaygroundRuntimeRelease = (): void => { + useEffect(() => () => pythonRunner.release(), []); +}; diff --git a/src/features/playground/ui/PlaygroundPageView.tsx b/src/features/playground/ui/PlaygroundPageView.tsx index c230b475..9d36f553 100644 --- a/src/features/playground/ui/PlaygroundPageView.tsx +++ b/src/features/playground/ui/PlaygroundPageView.tsx @@ -8,6 +8,7 @@ import { MainAppBar } from "#/features/appBar/ui/MainAppBar"; import { CodePanel } from "#/features/codeRunner/ui/CodePanel"; import { OutputPanel } from "#/features/output/ui/OutputPanel"; import { PlaygroundViewProvider } from "#/features/playground/context/PlaygroundViewContext"; +import { usePlaygroundRuntimeRelease } from "#/features/playground/hooks/usePlaygroundRuntimeRelease"; import { MobilePlayground } from "#/features/playground/ui/MobilePlayground"; import { ProjectPanel } from "#/features/project/ui/ProjectPanel"; import { TreeViewPanel } from "#/features/treeViewer/ui/TreeViewPanel"; @@ -48,6 +49,8 @@ export const PlaygroundPageView: React.FC = () => { const theme = useTheme(); const isMobile = useMobileLayout(); + usePlaygroundRuntimeRelease(); + const { data = {} } = useAppConfig(); return ( diff --git a/src/shared/hooks/useDeferredClientMount.ts b/src/shared/hooks/useDeferredClientMount.ts new file mode 100644 index 00000000..83fe3d23 --- /dev/null +++ b/src/shared/hooks/useDeferredClientMount.ts @@ -0,0 +1,24 @@ +import { useEffect, useState } from "react"; + +/** + * Defers client-only mount one frame; resets `isReady` on cleanup so Cache + * Components / Activity hide-show cycles recreate heavy runtimes (WebGL, Monaco). + */ +export function useDeferredClientMount(onCleanup?: () => void): boolean { + const [isReady, setIsReady] = useState(false); + + useEffect(() => { + let frameId = 0; + frameId = window.requestAnimationFrame(() => { + setIsReady(true); + }); + + return () => { + window.cancelAnimationFrame(frameId); + onCleanup?.(); + setIsReady(false); + }; + }, [onCleanup]); + + return isReady; +} diff --git a/src/shared/lib/throttleWithRAF.ts b/src/shared/lib/throttleWithRAF.ts index 753d4482..c8cb0bd3 100644 --- a/src/shared/lib/throttleWithRAF.ts +++ b/src/shared/lib/throttleWithRAF.ts @@ -2,12 +2,24 @@ * Throttles a callback using requestAnimationFrame. * Ensures the callback runs at most once per frame, using the latest args. */ +export type ThrottledWithRAF = ((...args: A) => void) & { + cancel: () => void; +}; + export function throttleWithRAF( fn: (...args: A) => void, -): (...args: A) => void { +): ThrottledWithRAF { let rafId: number | null = null; let latestArgs: A | null = null; + const cancel = () => { + if (rafId !== null) { + cancelAnimationFrame(rafId); + rafId = null; + } + latestArgs = null; + }; + const schedule = () => { if (rafId !== null) return; rafId = requestAnimationFrame(() => { @@ -20,8 +32,12 @@ export function throttleWithRAF( }); }; - return (...args: A) => { + const throttled = ((...args: A) => { latestArgs = args; schedule(); - }; + }) as ThrottledWithRAF; + + throttled.cancel = cancel; + + return throttled; } diff --git a/src/shared/ui/molecules/WebGLCanvasShell.tsx b/src/shared/ui/molecules/WebGLCanvasShell.tsx index eee96e9a..40a352e0 100644 --- a/src/shared/ui/molecules/WebGLCanvasShell.tsx +++ b/src/shared/ui/molecules/WebGLCanvasShell.tsx @@ -1,8 +1,9 @@ "use client"; import { Canvas, type CanvasProps } from "@react-three/fiber"; -import React, { useEffect, useRef, useState } from "react"; +import React, { useCallback, useRef, useState } from "react"; +import { useDeferredClientMount } from "#/shared/hooks/useDeferredClientMount"; import { attachWebGLContextRecovery, disposeWebGLRenderer, @@ -17,28 +18,18 @@ const WebGLCanvasShellInner: React.FC = ({ onCreated, ...canvasProps }) => { - const [isReady, setIsReady] = useState(false); const disposeRendererRef = useRef<(() => void) | undefined>(undefined); const detachContextRecoveryRef = useRef<(() => void) | undefined>(undefined); - // Defer mount one frame; reset on cleanup so Cache Components / Activity - // hide-show cycles recreate the canvas after gl.dispose(). - useEffect(() => { - let frameId = 0; - frameId = window.requestAnimationFrame(() => { - setIsReady(true); - }); - - return () => { - window.cancelAnimationFrame(frameId); - setIsReady(false); - detachContextRecoveryRef.current?.(); - detachContextRecoveryRef.current = undefined; - disposeWebGLRenderer(disposeRendererRef.current); - disposeRendererRef.current = undefined; - }; + const onShellCleanup = useCallback(() => { + detachContextRecoveryRef.current?.(); + detachContextRecoveryRef.current = undefined; + disposeWebGLRenderer(disposeRendererRef.current); + disposeRendererRef.current = undefined; }, []); + const isReady = useDeferredClientMount(onShellCleanup); + if (!isReady) { return null; } diff --git a/vibe-docs/Instant-Navigations-Design.md b/vibe-docs/Instant-Navigations-Design.md index 714538a6..bf4c8804 100644 --- a/vibe-docs/Instant-Navigations-Design.md +++ b/vibe-docs/Instant-Navigations-Design.md @@ -199,6 +199,21 @@ flowchart TD --- +## Heavy client lifecycle (Activity / `cacheComponents`) + +Visited routes can stay mounted in hidden Activity boundaries. Heavy clients must **defer mount one frame**, **dispose on hide/unmount**, and **clear parent refs** so show cycles recreate cleanly. + +| Runtime | Shell / hook | Route | +|---------|----------------|-------| +| R3F WebGL | `WebGLCanvasShell` + `useDeferredClientMount` | Home (`instant=true`) | +| Monaco | `CodeRunner` shell + `onEditorUnmount` | Playground | +| 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`. + +--- + ## References - [Next.js 16.3 blog](https://nextjs.org/blog/next-16-3) diff --git a/vibe-docs/Instant-Navigations-TODO.md b/vibe-docs/Instant-Navigations-TODO.md index 3441685d..6ccafe3e 100644 --- a/vibe-docs/Instant-Navigations-TODO.md +++ b/vibe-docs/Instant-Navigations-TODO.md @@ -46,5 +46,8 @@ - [x] `'use cache'` on `loadI18nForLocale` + metadata translations; single `AppRootLayoutClient` (no Suspense provider swap) - [x] `instant = true` on marketing pages (`/`, `/privacy`, `/daily`); `instant = false` on playground/profile - [x] `@next/playwright` `instant()` tests for marketing client navigations +- [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 - [ ] Playground/profile instant adoption (optional) From dc07d6415d5ed7145bcf35ccac133f7ecf21122f Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 29 Aug 2026 19:52:03 +0000 Subject: [PATCH 09/11] fix(app): harden Activity lifecycle edge cases from review Stabilize useDeferredClientMount with a ref + useLayoutEffect so cleanup identity changes do not remount WebGL/Monaco. Reject superseded Pyodide inits on release(), swallow cancelled init in usePythonCodeRunner, and tighten e2e runtime error collectors. Add unit tests. --- e2e/helpers/playgroundMonacoEditor.ts | 5 +- next-env.d.ts | 4 +- .../codeRunner/hooks/usePythonCodeRunner.tsx | 1 + .../codeRunner/lib/pythonRunner.spec.ts | 54 +++++++++++++++++++ src/features/codeRunner/lib/pythonRunner.ts | 14 +++++ .../__tests__/useDeferredClientMount.test.ts | 42 +++++++++++++++ src/shared/hooks/useDeferredClientMount.ts | 11 ++-- 7 files changed, 124 insertions(+), 7 deletions(-) create mode 100644 src/shared/hooks/__tests__/useDeferredClientMount.test.ts diff --git a/e2e/helpers/playgroundMonacoEditor.ts b/e2e/helpers/playgroundMonacoEditor.ts index 548a4f0a..30f8d4e4 100644 --- a/e2e/helpers/playgroundMonacoEditor.ts +++ b/e2e/helpers/playgroundMonacoEditor.ts @@ -48,8 +48,9 @@ export function collectPythonRunnerRuntimeErrors(page: Page): string[] { const maybeCollect = (text: string) => { if ( /pythonrunner has been disposed/i.test(text) || - /worker crashed/i.test(text) || - /pyodide/i.test(text) + /pythonrunner init superseded/i.test(text) || + /pythonrunner released/i.test(text) || + /worker crashed/i.test(text) ) { messages.push(text); } diff --git a/next-env.d.ts b/next-env.d.ts index ca6651b4..ac48aef8 100644 --- a/next-env.d.ts +++ b/next-env.d.ts @@ -1,8 +1,8 @@ /// /// /// -import "./.next/types/routes.d.ts"; -import "./.next/types/root-params.d.ts"; +import "./.next/dev/types/routes.d.ts"; +import "./.next/dev/types/root-params.d.ts"; // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/src/features/codeRunner/hooks/usePythonCodeRunner.tsx b/src/features/codeRunner/hooks/usePythonCodeRunner.tsx index 879663a3..bf3a9953 100644 --- a/src/features/codeRunner/hooks/usePythonCodeRunner.tsx +++ b/src/features/codeRunner/hooks/usePythonCodeRunner.tsx @@ -38,6 +38,7 @@ export const usePythonCodeRunner = () => { } }, }) + .catch(() => undefined) .finally(() => { if (cancelled) return; diff --git a/src/features/codeRunner/lib/pythonRunner.spec.ts b/src/features/codeRunner/lib/pythonRunner.spec.ts index 28e6e695..a22c4d34 100644 --- a/src/features/codeRunner/lib/pythonRunner.spec.ts +++ b/src/features/codeRunner/lib/pythonRunner.spec.ts @@ -83,6 +83,60 @@ class MockPythonWorker } } +class HangingInitWorker + extends EventTarget + implements + Pick< + Worker, + "postMessage" | "terminate" | "addEventListener" | "removeEventListener" + > +{ + onerror: Worker["onerror"] = null; + onmessage: Worker["onmessage"] = null; + onmessageerror: Worker["onmessageerror"] = null; + + postMessage(_msg: PythonWorkerInMessage) { + // Deliberately never responds — simulates slow Pyodide init. + } + + terminate() {} + + addEventListener( + type: string, + callback: EventListenerOrEventListenerObject | null, + options?: AddEventListenerOptions | boolean, + ): void { + super.addEventListener(type, callback, options); + } + + removeEventListener( + type: string, + callback: EventListenerOrEventListenerObject | null, + options?: EventListenerOptions | boolean, + ): void { + super.removeEventListener(type, callback, options); + } +} + +describe("PythonRunner.release", () => { + it("rejects in-flight init when playground Activity hides", async () => { + const runner = new PythonRunner(); + const initPromise = runner.init({ + workerFactory: () => new HangingInitWorker(), + }); + + runner.release(); + + await expect(initPromise).rejects.toThrow(/released/i); + expect(runner.isReady).toBe(false); + + await runner.init({ workerFactory: () => new MockPythonWorker() }); + expect(runner.isReady).toBe(true); + + runner.dispose(); + }); +}); + describe("PythonRunner.formatCode", () => { it("returns formatted source from worker FORMAT_RESULT", async () => { const runner = new PythonRunner(); diff --git a/src/features/codeRunner/lib/pythonRunner.ts b/src/features/codeRunner/lib/pythonRunner.ts index a9300338..7bd5d373 100644 --- a/src/features/codeRunner/lib/pythonRunner.ts +++ b/src/features/codeRunner/lib/pythonRunner.ts @@ -47,6 +47,8 @@ class PythonRunner { /** Serializes run(), formatCode(), and any future worker RPCs. */ private operationGate: Promise = Promise.resolve(); + private pendingInitReject: ((error: Error) => void) | null = null; + constructor(indexURL?: string) { this.indexURL = indexURL; } @@ -80,8 +82,11 @@ class PythonRunner { this.state = "initializing"; this.worker = (options?.workerFactory?.() ?? createWorker()) as Worker; this.onProgressRef.current = options?.onProgress ?? null; + const initWorker = this.worker; return new Promise((resolve, reject) => { + this.pendingInitReject = reject; + const onMessage = (event: MessageEvent) => { const data = event.data; if (data.type === "PROGRESS") { @@ -90,6 +95,10 @@ class PythonRunner { } if (data.type === "READY") { cleanup(); + if (this.worker !== initWorker) { + reject(new Error("PythonRunner init superseded")); + return; + } this.state = "ready"; resolve(); } else if (data.type === "ERROR" && data.requestId === "__init__") { @@ -113,6 +122,7 @@ class PythonRunner { this.worker?.removeEventListener("message", onMessage); this.worker?.removeEventListener("error", onError); this.initPromise = null; + this.pendingInitReject = null; }; this.worker!.addEventListener("message", onMessage); @@ -359,6 +369,10 @@ class PythonRunner { return; } this.onProgressRef.current = null; + if (this.state === "initializing" && this.pendingInitReject) { + this.pendingInitReject(new Error("PythonRunner released")); + this.pendingInitReject = null; + } this.resetWorker(); } diff --git a/src/shared/hooks/__tests__/useDeferredClientMount.test.ts b/src/shared/hooks/__tests__/useDeferredClientMount.test.ts new file mode 100644 index 00000000..8073e1e7 --- /dev/null +++ b/src/shared/hooks/__tests__/useDeferredClientMount.test.ts @@ -0,0 +1,42 @@ +import { renderHook, waitFor } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; + +import { useDeferredClientMount } from "#/shared/hooks/useDeferredClientMount"; + +describe("useDeferredClientMount", () => { + it("defers ready until the next animation frame", async () => { + vi.spyOn(window, "requestAnimationFrame").mockImplementation((callback) => { + callback(0); + return 1; + }); + + const { result } = renderHook(() => useDeferredClientMount()); + + await waitFor(() => { + expect(result.current).toBe(true); + }); + }); + + it("does not re-run mount effect when callback identity changes", async () => { + const firstCleanup = vi.fn(); + const secondCleanup = vi.fn(); + vi.spyOn(window, "requestAnimationFrame").mockImplementation((callback) => { + callback(0); + return 1; + }); + + const { rerender, unmount } = renderHook( + ({ cleanup }) => useDeferredClientMount(cleanup), + { initialProps: { cleanup: firstCleanup } }, + ); + + rerender({ cleanup: secondCleanup }); + + expect(firstCleanup).not.toHaveBeenCalled(); + + unmount(); + + expect(firstCleanup).not.toHaveBeenCalled(); + expect(secondCleanup).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/shared/hooks/useDeferredClientMount.ts b/src/shared/hooks/useDeferredClientMount.ts index 83fe3d23..f91c664e 100644 --- a/src/shared/hooks/useDeferredClientMount.ts +++ b/src/shared/hooks/useDeferredClientMount.ts @@ -1,4 +1,4 @@ -import { useEffect, useState } from "react"; +import { useEffect, useLayoutEffect, useRef, useState } from "react"; /** * Defers client-only mount one frame; resets `isReady` on cleanup so Cache @@ -6,6 +6,11 @@ import { useEffect, useState } from "react"; */ export function useDeferredClientMount(onCleanup?: () => void): boolean { const [isReady, setIsReady] = useState(false); + const onCleanupRef = useRef(onCleanup); + + useLayoutEffect(() => { + onCleanupRef.current = onCleanup; + }); useEffect(() => { let frameId = 0; @@ -15,10 +20,10 @@ export function useDeferredClientMount(onCleanup?: () => void): boolean { return () => { window.cancelAnimationFrame(frameId); - onCleanup?.(); + onCleanupRef.current?.(); setIsReady(false); }; - }, [onCleanup]); + }, []); return isReady; } From 0004618496098f8ba271b17281c23df9a5183434 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 29 Aug 2026 20:00:02 +0000 Subject: [PATCH 10/11] fix(runtime): harden Activity lifecycle for Monaco and deferred mount - Run deferred-mount cleanup in useLayoutEffect so WebGL/Monaco dispose synchronously before Cache Components hides the route - Expose mountKey from useDeferredClientMount for fresh client instances - Bundle monaco-editor via loader.config instead of jsDelivr CDN - Explicitly dispose Monaco editor on Activity hide; remount with mountKey - Release Pyodide with useLayoutEffect for the same hide timing --- .../codeRunner/lib/configureMonacoLoader.ts | 18 +++++++++++++ src/features/codeRunner/ui/CodeRunner.tsx | 19 ++++++++++++-- .../hooks/usePlaygroundRuntimeRelease.ts | 4 +-- .../__tests__/useDeferredClientMount.test.ts | 3 ++- src/shared/hooks/useDeferredClientMount.ts | 25 ++++++++++++++----- src/shared/ui/molecules/WebGLCanvasShell.tsx | 2 +- 6 files changed, 59 insertions(+), 12 deletions(-) create mode 100644 src/features/codeRunner/lib/configureMonacoLoader.ts diff --git a/src/features/codeRunner/lib/configureMonacoLoader.ts b/src/features/codeRunner/lib/configureMonacoLoader.ts new file mode 100644 index 00000000..7a4e0f0e --- /dev/null +++ b/src/features/codeRunner/lib/configureMonacoLoader.ts @@ -0,0 +1,18 @@ +"use client"; + +import { loader } from "@monaco-editor/react"; +import * as monaco from "monaco-editor"; + +let isConfigured = false; + +/** Use the app bundle for Monaco instead of the default jsDelivr CDN loader. */ +export function configureMonacoLoader(): void { + if (isConfigured) { + return; + } + + loader.config({ monaco }); + isConfigured = true; +} + +configureMonacoLoader(); diff --git a/src/features/codeRunner/ui/CodeRunner.tsx b/src/features/codeRunner/ui/CodeRunner.tsx index 14add683..414341cf 100644 --- a/src/features/codeRunner/ui/CodeRunner.tsx +++ b/src/features/codeRunner/ui/CodeRunner.tsx @@ -2,8 +2,9 @@ import type { EditorProps, Monaco } from "@monaco-editor/react"; import { Box, Skeleton, useColorScheme } from "@mui/material"; import type { editor } from "monaco-editor"; import dynamic from "next/dynamic"; -import React from "react"; +import React, { useCallback, useRef } from "react"; +import "#/features/codeRunner/lib/configureMonacoLoader"; import { useDeferredClientMount } from "#/shared/hooks/useDeferredClientMount"; import { useMobileLayout } from "#/shared/hooks/useMobileLayout"; @@ -35,7 +36,19 @@ const MonacoEditorShellInner: React.FC = ({ }) => { const { mode } = useColorScheme(); const isMobile = useMobileLayout(); - const isReady = useDeferredClientMount(onEditorUnmount); + const editorRef = useRef(null); + + const disposeEditor = useCallback(() => { + editorRef.current?.dispose(); + editorRef.current = null; + }, []); + + const onShellCleanup = useCallback(() => { + disposeEditor(); + onEditorUnmount?.(); + }, [disposeEditor, onEditorUnmount]); + + const { isReady, mountKey } = useDeferredClientMount(onShellCleanup); const resolvedHeight = typeof height === "number" ? `calc(${height}px - 6vh)` : height; @@ -55,6 +68,7 @@ const MonacoEditorShellInner: React.FC = ({ /> ) : ( = ({ return; } + editorRef.current = mountedEditor; setEditorInstance?.(mountedEditor); setMonacoInstance?.(monaco); setTextModel(model); diff --git a/src/features/playground/hooks/usePlaygroundRuntimeRelease.ts b/src/features/playground/hooks/usePlaygroundRuntimeRelease.ts index 38e4866d..b6fccdc3 100644 --- a/src/features/playground/hooks/usePlaygroundRuntimeRelease.ts +++ b/src/features/playground/hooks/usePlaygroundRuntimeRelease.ts @@ -1,8 +1,8 @@ -import { useEffect } from "react"; +import { useLayoutEffect } from "react"; import { pythonRunner } from "#/features/codeRunner/lib/pythonRunner"; /** Release Pyodide when playground hides or unmounts (Activity / cacheComponents). */ export const usePlaygroundRuntimeRelease = (): void => { - useEffect(() => () => pythonRunner.release(), []); + useLayoutEffect(() => () => pythonRunner.release(), []); }; diff --git a/src/shared/hooks/__tests__/useDeferredClientMount.test.ts b/src/shared/hooks/__tests__/useDeferredClientMount.test.ts index 8073e1e7..ac5b17f2 100644 --- a/src/shared/hooks/__tests__/useDeferredClientMount.test.ts +++ b/src/shared/hooks/__tests__/useDeferredClientMount.test.ts @@ -13,8 +13,9 @@ describe("useDeferredClientMount", () => { const { result } = renderHook(() => useDeferredClientMount()); await waitFor(() => { - expect(result.current).toBe(true); + expect(result.current.isReady).toBe(true); }); + expect(result.current.mountKey).toBe(1); }); it("does not re-run mount effect when callback identity changes", async () => { diff --git a/src/shared/hooks/useDeferredClientMount.ts b/src/shared/hooks/useDeferredClientMount.ts index f91c664e..02ed4232 100644 --- a/src/shared/hooks/useDeferredClientMount.ts +++ b/src/shared/hooks/useDeferredClientMount.ts @@ -1,21 +1,34 @@ -import { useEffect, useLayoutEffect, useRef, useState } from "react"; +import { useLayoutEffect, useRef, useState } from "react"; + +export type DeferredClientMountState = { + /** True one frame after mount or Activity show. */ + isReady: boolean; + /** Increments each show cycle so heavy clients can force a fresh instance. */ + mountKey: number; +}; /** - * Defers client-only mount one frame; resets `isReady` on cleanup so Cache - * Components / Activity hide-show cycles recreate heavy runtimes (WebGL, Monaco). + * Defers client-only mount one frame; resets on cleanup so Cache Components / + * Activity hide-show cycles recreate heavy runtimes (WebGL, Monaco). + * + * Cleanup runs in `useLayoutEffect` so disposal happens before the route is hidden. */ -export function useDeferredClientMount(onCleanup?: () => void): boolean { +export function useDeferredClientMount( + onCleanup?: () => void, +): DeferredClientMountState { const [isReady, setIsReady] = useState(false); + const [mountKey, setMountKey] = useState(0); const onCleanupRef = useRef(onCleanup); useLayoutEffect(() => { onCleanupRef.current = onCleanup; }); - useEffect(() => { + useLayoutEffect(() => { let frameId = 0; frameId = window.requestAnimationFrame(() => { setIsReady(true); + setMountKey((previousKey) => previousKey + 1); }); return () => { @@ -25,5 +38,5 @@ export function useDeferredClientMount(onCleanup?: () => void): boolean { }; }, []); - return isReady; + return { isReady, mountKey }; } diff --git a/src/shared/ui/molecules/WebGLCanvasShell.tsx b/src/shared/ui/molecules/WebGLCanvasShell.tsx index 40a352e0..31c944d0 100644 --- a/src/shared/ui/molecules/WebGLCanvasShell.tsx +++ b/src/shared/ui/molecules/WebGLCanvasShell.tsx @@ -28,7 +28,7 @@ const WebGLCanvasShellInner: React.FC = ({ disposeRendererRef.current = undefined; }, []); - const isReady = useDeferredClientMount(onShellCleanup); + const { isReady } = useDeferredClientMount(onShellCleanup); if (!isReady) { return null; From 7ba5d3e2b88c3c2093582d3fb65d0ed1344e704e Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 29 Aug 2026 20:12:57 +0000 Subject: [PATCH 11/11] fix(codeRunner): pin Monaco CDN to installed version for workers Bundling monaco via loader.config({ monaco }) without MonacoEnvironment workers breaks language services under Next/Turbopack. Pin jsDelivr to the package.json semver instead so editor and workers stay aligned. --- .../codeRunner/lib/configureMonacoLoader.ts | 26 ++++++++++++++++--- 1 file changed, 23 insertions(+), 3 deletions(-) diff --git a/src/features/codeRunner/lib/configureMonacoLoader.ts b/src/features/codeRunner/lib/configureMonacoLoader.ts index 7a4e0f0e..840b452d 100644 --- a/src/features/codeRunner/lib/configureMonacoLoader.ts +++ b/src/features/codeRunner/lib/configureMonacoLoader.ts @@ -1,17 +1,37 @@ "use client"; import { loader } from "@monaco-editor/react"; -import * as monaco from "monaco-editor"; + +import packageJson from "../../../../package.json"; let isConfigured = false; -/** Use the app bundle for Monaco instead of the default jsDelivr CDN loader. */ +function resolveMonacoCdnVersion(): string { + const specifier = packageJson.dependencies["monaco-editor"]; + const matchedVersion = specifier.match(/\d+\.\d+\.\d+/)?.[0]; + if (!matchedVersion) { + throw new Error( + `Could not parse monaco-editor version from package.json: ${specifier}`, + ); + } + return matchedVersion; +} + +/** + * Pin Monaco's CDN loader to the installed `monaco-editor` version so workers + * and editor stay in sync. Bundling via `loader.config({ monaco })` requires + * `MonacoEnvironment.getWorker`, which is non-trivial under Next/Turbopack. + */ export function configureMonacoLoader(): void { if (isConfigured) { return; } - loader.config({ monaco }); + loader.config({ + paths: { + vs: `https://cdn.jsdelivr.net/npm/monaco-editor@${resolveMonacoCdnVersion()}/min/vs`, + }, + }); isConfigured = true; }