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/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 new file mode 100644 index 00000000..34662ce2 --- /dev/null +++ b/e2e/helpers/landingWebGLCanvases.ts @@ -0,0 +1,103 @@ +import { expect, 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; + +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 { + await expect + .poll(async () => countActiveLandingWebGLCanvases(page), { + timeout: timeoutMs, + }) + .toBeGreaterThanOrEqual(expectedMin); + + const states = await readLandingWebGLCanvasStates(page); + return states.filter(isActiveLandingCanvas); +} + +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; +} + +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/helpers/playgroundMonacoEditor.ts b/e2e/helpers/playgroundMonacoEditor.ts new file mode 100644 index 00000000..30f8d4e4 --- /dev/null +++ b/e2e/helpers/playgroundMonacoEditor.ts @@ -0,0 +1,79 @@ +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; +} + +export function collectPythonRunnerRuntimeErrors(page: Page): string[] { + const messages: string[] = []; + + const maybeCollect = (text: string) => { + if ( + /pythonrunner has been disposed/i.test(text) || + /pythonrunner init superseded/i.test(text) || + /pythonrunner released/i.test(text) || + /worker crashed/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 + .getByRole("banner") + .getByRole("link", { name: /dstruct/i }) + .first() + .click(); +} 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/home-webgl-nav.spec.ts b/e2e/home-webgl-nav.spec.ts new file mode 100644 index 00000000..f04843b9 --- /dev/null +++ b/e2e/home-webgl-nav.spec.ts @@ -0,0 +1,56 @@ +import { expect, test } from "@playwright/test"; + +import { + clickFooterPrivacyPolicyLink, + dismissCookieBannerIfVisible, +} from "./helpers/dismissCookieBanner"; +import { + collectWebGlContextLostMessages, + forceLoseActiveLandingWebGLContexts, + 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 clickFooterPrivacyPolicyLink(page); + 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); + // 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); + + const lostCount = await forceLoseActiveLandingWebGLContexts(page); + expect(lostCount).toBeGreaterThanOrEqual(2); + + 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 new file mode 100644 index 00000000..570e2f57 --- /dev/null +++ b/e2e/instant-marketing-nav.spec.ts @@ -0,0 +1,64 @@ +import { instant } from "@next/playwright"; +import { expect, test } from "@playwright/test"; + +import { + clickFooterPrivacyPolicyLink, + dismissCookieBannerIfVisible, +} from "./helpers/dismissCookieBanner"; +import { waitForActiveLandingWebGLCanvases } from "./helpers/landingWebGLCanvases"; + +/** + * 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 clickFooterPrivacyPolicyLink(page); + 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.", + ); + }); + await waitForActiveLandingWebGLCanvases(page); + }); +}); diff --git a/e2e/playground-monaco-nav.spec.ts b/e2e/playground-monaco-nav.spec.ts new file mode 100644 index 00000000..3c099c05 --- /dev/null +++ b/e2e/playground-monaco-nav.spec.ts @@ -0,0 +1,81 @@ +import { expect, test } from "@playwright/test"; + +import { dismissCookieBannerIfVisible } from "./helpers/dismissCookieBanner"; +import { + clickAppBarHomeLink, + collectMonacoRuntimeErrors, + collectPythonRunnerRuntimeErrors, + waitForPlaygroundMonacoEditor, +} from "./helpers/playgroundMonacoEditor"; + +const PLAYGROUND_PYTHON_URL = + "/playground/invert-binary-tree?view=code&language=python"; + +test.describe("playground runtime 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/invert-binary-tree?view=code"); + await dismissCookieBannerIfVisible(page); + await waitForPlaygroundMonacoEditor(page); + + await expect( + page.locator(".monaco-editor .view-lines").first(), + ).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/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/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..cd19d4a3 100644 --- a/src/app/(default-locale)/layout.tsx +++ b/src/app/(default-locale)/layout.tsx @@ -2,7 +2,7 @@ import { baseLocale } from "#/i18n/i18n-util"; import { LocaleAppLayout } from "#/app/locale-app/LocaleAppLayout"; -/** Locale shell uses session/i18n loaders — opt out until cached (L5). */ +/** 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). */ 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..49011e45 --- /dev/null +++ b/src/app/RootHtmlShell.tsx @@ -0,0 +1,36 @@ +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; +}; + +/** 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; + 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..486c48f3 100644 --- a/src/app/[lang]/layout.tsx +++ b/src/app/[lang]/layout.tsx @@ -1,6 +1,6 @@ import { LocaleAppLayout } from "#/app/locale-app/LocaleAppLayout"; -/** Locale shell uses session/i18n loaders — opt out until cached (L5). */ +/** Locale shell reads session/headers — opts out; marketing pages opt in below (L5). */ export const instant = false; export default async function LangLayout({ 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..3b311386 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,52 +1,27 @@ -import { headers } from "next/headers"; - -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 } 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). */ +/** 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 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} (set in proxy for App Router locale paths). */ export default async 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} - - ); + return {children}; } diff --git a/src/app/locale-app/LocaleAppLayout.tsx b/src/app/locale-app/LocaleAppLayout.tsx index 8a56ed1f..a7634103 100644 --- a/src/app/locale-app/LocaleAppLayout.tsx +++ b/src/app/locale-app/LocaleAppLayout.tsx @@ -23,9 +23,11 @@ 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 [i18n, session, headerList] = await Promise.all([ + loadI18nForLocale(locale), + getServerSession(authOptions), + headers(), + ]); const ssrDeviceType = parseSsrDeviceTypeHeader( headerList.get(APP_ROUTER_SSR_DEVICE_TYPE_HEADER), ); 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/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/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/configureMonacoLoader.ts b/src/features/codeRunner/lib/configureMonacoLoader.ts new file mode 100644 index 00000000..840b452d --- /dev/null +++ b/src/features/codeRunner/lib/configureMonacoLoader.ts @@ -0,0 +1,38 @@ +"use client"; + +import { loader } from "@monaco-editor/react"; + +import packageJson from "../../../../package.json"; + +let isConfigured = false; + +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({ + paths: { + vs: `https://cdn.jsdelivr.net/npm/monaco-editor@${resolveMonacoCdnVersion()}/min/vs`, + }, + }); + isConfigured = true; +} + +configureMonacoLoader(); diff --git a/src/features/codeRunner/lib/pythonRunner.spec.ts b/src/features/codeRunner/lib/pythonRunner.spec.ts index 5780530b..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(); @@ -95,4 +149,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..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); @@ -340,7 +350,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 +360,22 @@ 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; + if (this.state === "initializing" && this.pendingInitReject) { + this.pendingInitReject(new Error("PythonRunner released")); + this.pendingInitReject = 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/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} /> import("@monaco-editor/react"), { @@ -18,9 +20,14 @@ type CodeRunnerProps = EditorProps & { React.SetStateAction >; 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 +36,22 @@ export const CodeRunner: React.FC = ({ }) => { const { mode } = useColorScheme(); const isMobile = useMobileLayout(); + 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; 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); + editorRef.current = mountedEditor; + 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 ; +}; diff --git a/src/features/playground/hooks/usePlaygroundRuntimeRelease.ts b/src/features/playground/hooks/usePlaygroundRuntimeRelease.ts new file mode 100644 index 00000000..b6fccdc3 --- /dev/null +++ b/src/features/playground/hooks/usePlaygroundRuntimeRelease.ts @@ -0,0 +1,8 @@ +import { useLayoutEffect } from "react"; + +import { pythonRunner } from "#/features/codeRunner/lib/pythonRunner"; + +/** Release Pyodide when playground hides or unmounts (Activity / cacheComponents). */ +export const usePlaygroundRuntimeRelease = (): void => { + useLayoutEffect(() => () => 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/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/src/shared/hooks/__tests__/useDeferredClientMount.test.ts b/src/shared/hooks/__tests__/useDeferredClientMount.test.ts new file mode 100644 index 00000000..ac5b17f2 --- /dev/null +++ b/src/shared/hooks/__tests__/useDeferredClientMount.test.ts @@ -0,0 +1,43 @@ +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.isReady).toBe(true); + }); + expect(result.current.mountKey).toBe(1); + }); + + 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 new file mode 100644 index 00000000..02ed4232 --- /dev/null +++ b/src/shared/hooks/useDeferredClientMount.ts @@ -0,0 +1,42 @@ +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 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, +): DeferredClientMountState { + const [isReady, setIsReady] = useState(false); + const [mountKey, setMountKey] = useState(0); + const onCleanupRef = useRef(onCleanup); + + useLayoutEffect(() => { + onCleanupRef.current = onCleanup; + }); + + useLayoutEffect(() => { + let frameId = 0; + frameId = window.requestAnimationFrame(() => { + setIsReady(true); + setMountKey((previousKey) => previousKey + 1); + }); + + return () => { + window.cancelAnimationFrame(frameId); + onCleanupRef.current?.(); + setIsReady(false); + }; + }, []); + + return { isReady, mountKey }; +} 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/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/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..31c944d0 --- /dev/null +++ b/src/shared/ui/molecules/WebGLCanvasShell.tsx @@ -0,0 +1,82 @@ +"use client"; + +import { Canvas, type CanvasProps } from "@react-three/fiber"; +import React, { useCallback, useRef, useState } from "react"; + +import { useDeferredClientMount } from "#/shared/hooks/useDeferredClientMount"; +import { + attachWebGLContextRecovery, + disposeWebGLRenderer, +} from "#/shared/lib/webglCanvasRecovery"; + +type WebGLCanvasShellInnerProps = CanvasProps & { + onContextLost: () => void; +}; + +const WebGLCanvasShellInner: React.FC = ({ + onContextLost, + onCreated, + ...canvasProps +}) => { + const disposeRendererRef = useRef<(() => void) | undefined>(undefined); + const detachContextRecoveryRef = useRef<(() => void) | undefined>(undefined); + + const onShellCleanup = useCallback(() => { + detachContextRecoveryRef.current?.(); + detachContextRecoveryRef.current = undefined; + disposeWebGLRenderer(disposeRendererRef.current); + disposeRendererRef.current = undefined; + }, []); + + const { isReady } = useDeferredClientMount(onShellCleanup); + + 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 ( + { + // 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); + }); + }} + /> + ); +}; 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 ea27234f..6ccafe3e 100644 --- a/vibe-docs/Instant-Navigations-TODO.md +++ b/vibe-docs/Instant-Navigations-TODO.md @@ -42,6 +42,12 @@ ## 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 → 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 +- [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) diff --git a/vibe-docs/Locale-Migration-Design.md b/vibe-docs/Locale-Migration-Design.md index 47e50333..7f727463 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. ~~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. ---