diff --git a/packages/vinext/src/check.ts b/packages/vinext/src/check.ts index f5d22391e8..409a9bfae7 100644 --- a/packages/vinext/src/check.ts +++ b/packages/vinext/src/check.ts @@ -293,9 +293,9 @@ const CONFIG_SUPPORT: Record = { detail: "supported for Pages Router; App Router unchanged", }, reactStrictMode: { - status: "partial", + status: "supported", detail: - "enforced for the Pages Router (client root wrapped in when true); App Router is not yet wrapped (Next.js defaults App Router strict mode on)", + "enforced for both routers; App Router defaults on and Pages Router defaults off, matching Next.js", }, poweredByHeader: { status: "supported", diff --git a/packages/vinext/src/index.ts b/packages/vinext/src/index.ts index 4699d2d8ab..b10bf5423d 100644 --- a/packages/vinext/src/index.ts +++ b/packages/vinext/src/index.ts @@ -2196,6 +2196,12 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { } // Expose basePath to client-side code defines["process.env.__NEXT_ROUTER_BASEPATH"] = JSON.stringify(nextConfig.basePath); + // Next.js enables Strict Mode by default for the App Router while + // preserving an explicit `reactStrictMode: false`. + // See: packages/next/src/build/define-env.ts + defines["process.env.__NEXT_STRICT_MODE_APP"] = JSON.stringify( + nextConfig.reactStrictMode === null ? true : nextConfig.reactStrictMode, + ); // Let shared client shims compile out Pages-only behavior in pure App // Router builds while retaining it for Pages and hybrid applications. defines["process.env.__VINEXT_HAS_PAGES_ROUTER"] = JSON.stringify(String(hasPagesDir)); diff --git a/packages/vinext/src/server/app-browser-entry.ts b/packages/vinext/src/server/app-browser-entry.ts index 03e0238c89..1c613f9403 100644 --- a/packages/vinext/src/server/app-browser-entry.ts +++ b/packages/vinext/src/server/app-browser-entry.ts @@ -2,7 +2,9 @@ import { createElement, + Fragment, startTransition, + StrictMode, use, useEffect, useLayoutEffect, @@ -191,6 +193,7 @@ import { import { hasServerActions, loadServerActionClient } from "virtual:vinext-app-capabilities"; const HAS_CLIENT_REWRITES = process.env.__VINEXT_HAS_CLIENT_REWRITES !== "false"; +const StrictModeIfEnabled = process.env.__NEXT_STRICT_MODE_APP ? StrictMode : Fragment; type SearchParamInput = ConstructorParameters[0]; type DevErrorOverlayModule = typeof import("../client/dev-error-overlay.js"); @@ -1592,11 +1595,15 @@ function bootstrapHydration( onRecoverableError, onUncaughtError, }); - const children = createElement(BrowserRoot, { - hydrationCachePublication, - initialElements: root, - initialNavigationSnapshot, - }); + const children = createElement( + StrictModeIfEnabled, + null, + createElement(BrowserRoot, { + hydrationCachePublication, + initialElements: root, + initialNavigationSnapshot, + }), + ); const errorShellStyles = document.querySelectorAll("style[data-vinext-error-shell-style]"); if (document.documentElement.id === "__next_error__") { // Next.js client/app-index.tsx uses the document id alone to select CSR diff --git a/tests/check.test.ts b/tests/check.test.ts index 54289bbfbe..4de7ec069b 100644 --- a/tests/check.test.ts +++ b/tests/check.test.ts @@ -301,10 +301,7 @@ describe("analyzeConfig", () => { const items = analyzeConfig(tmpDir); expect(items.find((i) => i.name === "basePath")?.status).toBe("supported"); expect(items.find((i) => i.name === "trailingSlash")?.status).toBe("supported"); - // reactStrictMode is enforced for the Pages Router (client root wrapped in - // `` when `true`) but the App Router is not yet wrapped, - // so the status is `partial`. See `packages/vinext/src/check.ts`. - expect(items.find((i) => i.name === "reactStrictMode")?.status).toBe("partial"); + expect(items.find((i) => i.name === "reactStrictMode")?.status).toBe("supported"); }); it("detects unsupported webpack config", () => { diff --git a/tests/e2e/app-router/react-strict-mode.spec.ts b/tests/e2e/app-router/react-strict-mode.spec.ts new file mode 100644 index 0000000000..ef2f59809a --- /dev/null +++ b/tests/e2e/app-router/react-strict-mode.spec.ts @@ -0,0 +1,62 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { expect, test } from "@playwright/test"; +import { waitForAppRouterHydration } from "../helpers"; +import { + startChildViteDevServer, + stopChildProductionServer, + type ChildProductionServer, +} from "../production-server"; + +const disabledFixtureSource = path.resolve( + process.cwd(), + "tests/fixtures/app-basic/react-strict-mode-disabled", +); + +test("enables Strict Mode by default for the App Router", async ({ page }) => { + await page.goto("/react-strict-mode"); + await waitForAppRouterHydration(page); + + await expect(page.getByTestId("strict-mode-render-count")).toHaveText("2"); +}); + +test.describe("reactStrictMode: false", () => { + let disabledFixtureRoot: string | undefined; + let disabledServer: ChildProductionServer | undefined; + + test.beforeAll(async () => { + disabledFixtureRoot = await fs.mkdtemp(path.join(os.tmpdir(), "vinext-strict-mode-disabled-")); + await fs.cp(disabledFixtureSource, disabledFixtureRoot, { recursive: true }); + const sourceNodeModules = path.resolve(process.cwd(), "tests/fixtures/app-basic/node_modules"); + const fixtureNodeModules = path.join(disabledFixtureRoot, "node_modules"); + await fs.mkdir(fixtureNodeModules); + for (const entry of await fs.readdir(sourceNodeModules)) { + if (entry.startsWith(".")) continue; + await fs.symlink( + path.join(sourceNodeModules, entry), + path.join(fixtureNodeModules, entry), + "junction", + ); + } + disabledServer = await startChildViteDevServer(disabledFixtureRoot); + }); + + test.afterAll(async () => { + try { + if (disabledServer) await stopChildProductionServer(disabledServer); + } finally { + if (disabledFixtureRoot) { + await fs.rm(disabledFixtureRoot, { recursive: true, force: true }); + } + } + }); + + test("preserves the App Router Strict Mode opt-out", async ({ page }) => { + if (!disabledServer) throw new Error("Disabled Strict Mode fixture server did not start"); + await page.goto(`http://127.0.0.1:${disabledServer.port}/`); + await waitForAppRouterHydration(page); + + await expect(page.getByTestId("strict-mode-render-count")).toHaveText("1"); + }); +}); diff --git a/tests/fixtures/app-basic/app/react-strict-mode/page.tsx b/tests/fixtures/app-basic/app/react-strict-mode/page.tsx new file mode 100644 index 0000000000..2d51d226b7 --- /dev/null +++ b/tests/fixtures/app-basic/app/react-strict-mode/page.tsx @@ -0,0 +1,10 @@ +import { RenderProbe } from "./render-probe"; + +export default function ReactStrictModePage() { + return ( +
+

React Strict Mode

+ +
+ ); +} diff --git a/tests/fixtures/app-basic/app/react-strict-mode/render-probe.tsx b/tests/fixtures/app-basic/app/react-strict-mode/render-probe.tsx new file mode 100644 index 0000000000..1daabb2429 --- /dev/null +++ b/tests/fixtures/app-basic/app/react-strict-mode/render-probe.tsx @@ -0,0 +1,20 @@ +"use client"; + +import { useEffect } from "react"; + +let clientRenderCount = 0; + +export function RenderProbe() { + if (typeof window !== "undefined") { + clientRenderCount += 1; + } + + useEffect(() => { + const output = document.querySelector("[data-testid=strict-mode-render-count]"); + // Avoid scheduling another React render, which would contaminate the value + // this fixture is measuring. + if (output) output.textContent = String(clientRenderCount); + }, []); + + return

0

; +} diff --git a/tests/fixtures/app-basic/react-strict-mode-disabled/app/layout.tsx b/tests/fixtures/app-basic/react-strict-mode-disabled/app/layout.tsx new file mode 100644 index 0000000000..e53180eeb9 --- /dev/null +++ b/tests/fixtures/app-basic/react-strict-mode-disabled/app/layout.tsx @@ -0,0 +1,9 @@ +import type { ReactNode } from "react"; + +export default function RootLayout({ children }: { children: ReactNode }) { + return ( + + {children} + + ); +} diff --git a/tests/fixtures/app-basic/react-strict-mode-disabled/app/page.tsx b/tests/fixtures/app-basic/react-strict-mode-disabled/app/page.tsx new file mode 100644 index 0000000000..e60ca847f7 --- /dev/null +++ b/tests/fixtures/app-basic/react-strict-mode-disabled/app/page.tsx @@ -0,0 +1,20 @@ +"use client"; + +import { useEffect } from "react"; + +let clientRenderCount = 0; + +export default function Page() { + if (typeof window !== "undefined") { + clientRenderCount += 1; + } + + useEffect(() => { + const output = document.querySelector("[data-testid=strict-mode-render-count]"); + // Avoid scheduling another React render, which would contaminate the value + // this fixture is measuring. + if (output) output.textContent = String(clientRenderCount); + }, []); + + return

0

; +} diff --git a/tests/fixtures/app-basic/react-strict-mode-disabled/next.config.ts b/tests/fixtures/app-basic/react-strict-mode-disabled/next.config.ts new file mode 100644 index 0000000000..793483036b --- /dev/null +++ b/tests/fixtures/app-basic/react-strict-mode-disabled/next.config.ts @@ -0,0 +1,7 @@ +import type { NextConfig } from "vinext"; + +const nextConfig: NextConfig = { + reactStrictMode: false, +}; + +export default nextConfig; diff --git a/tests/fixtures/app-basic/react-strict-mode-disabled/package.json b/tests/fixtures/app-basic/react-strict-mode-disabled/package.json new file mode 100644 index 0000000000..e986b24bba --- /dev/null +++ b/tests/fixtures/app-basic/react-strict-mode-disabled/package.json @@ -0,0 +1,4 @@ +{ + "private": true, + "type": "module" +} diff --git a/tests/fixtures/app-basic/react-strict-mode-disabled/vite.config.ts b/tests/fixtures/app-basic/react-strict-mode-disabled/vite.config.ts new file mode 100644 index 0000000000..d0a0fd5057 --- /dev/null +++ b/tests/fixtures/app-basic/react-strict-mode-disabled/vite.config.ts @@ -0,0 +1,6 @@ +import { defineConfig } from "vite"; +import vinext from "vinext"; + +export default defineConfig({ + plugins: [vinext({ appDir: import.meta.dirname })], +}); diff --git a/tests/react-strict-mode.test.ts b/tests/react-strict-mode.test.ts new file mode 100644 index 0000000000..631e3fdcbe --- /dev/null +++ b/tests/react-strict-mode.test.ts @@ -0,0 +1,60 @@ +import fsp from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { describe, expect, it } from "vite-plus/test"; + +type VinextPlugin = { + name: string; + config?: (config: unknown, env: { command: string }) => unknown; +}; + +async function readAppStrictModeDefine(nextConfigBody: string): Promise { + const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "vinext-app-strict-mode-")); + const rootNodeModules = path.resolve(import.meta.dirname, "../node_modules"); + + try { + await fsp.symlink(rootNodeModules, path.join(tmpDir, "node_modules"), "junction"); + await fsp.mkdir(path.join(tmpDir, "app"), { recursive: true }); + await fsp.writeFile( + path.join(tmpDir, "app", "layout.tsx"), + "export default function Layout({ children }) { return {children}; }", + ); + await fsp.writeFile( + path.join(tmpDir, "app", "page.tsx"), + "export default function Page() { return null; }", + ); + await fsp.writeFile(path.join(tmpDir, "next.config.mjs"), nextConfigBody); + + const vinext = (await import("../packages/vinext/src/index.js")).default; + const plugins = vinext() as VinextPlugin[]; + const configPlugin = plugins.find( + (plugin) => plugin.name === "vinext:config" && typeof plugin.config === "function", + ); + expect(configPlugin).toBeDefined(); + + const result = (await configPlugin!.config!( + { root: tmpDir, build: {}, plugins: [], optimizeDeps: {} }, + { command: "build" }, + )) as { define?: Record }; + + return result.define?.["process.env.__NEXT_STRICT_MODE_APP"]; + } finally { + await fsp.rm(tmpDir, { recursive: true, force: true }); + } +} + +describe("App Router reactStrictMode define", () => { + it("enables Strict Mode by default", async () => { + expect(await readAppStrictModeDefine("export default {};")).toBe("true"); + }); + + it("enables Strict Mode when configured", async () => { + expect(await readAppStrictModeDefine("export default { reactStrictMode: true };")).toBe("true"); + }); + + it("disables Strict Mode when configured", async () => { + expect(await readAppStrictModeDefine("export default { reactStrictMode: false };")).toBe( + "false", + ); + }); +});