From 3c969fc47741196121f72bcba6ee1372337a83cd Mon Sep 17 00:00:00 2001 From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com> Date: Thu, 23 Jul 2026 16:49:34 +1000 Subject: [PATCH 1/2] fix(app-router): honor reactStrictMode App Router client roots were never wrapped in React StrictMode, so the router ignored both Next.js default-on behavior and explicit configuration. Publish the resolved App Router strict-mode define and select StrictMode or Fragment around the shared hydration and CSR root. This preserves the Pages Router default while matching Next.js for App Router applications. Add config and browser regression coverage for default, enabled, and disabled values. --- packages/vinext/src/check.ts | 4 +- packages/vinext/src/index.ts | 6 ++ .../vinext/src/server/app-browser-entry.ts | 17 ++++-- tests/check.test.ts | 5 +- .../e2e/app-router/react-strict-mode.spec.ts | 9 +++ .../app-basic/app/react-strict-mode/page.tsx | 10 ++++ .../app/react-strict-mode/render-probe.tsx | 20 +++++++ tests/react-strict-mode.test.ts | 60 +++++++++++++++++++ 8 files changed, 120 insertions(+), 11 deletions(-) create mode 100644 tests/e2e/app-router/react-strict-mode.spec.ts create mode 100644 tests/fixtures/app-basic/app/react-strict-mode/page.tsx create mode 100644 tests/fixtures/app-basic/app/react-strict-mode/render-probe.tsx create mode 100644 tests/react-strict-mode.test.ts 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..9e62a26c83 --- /dev/null +++ b/tests/e2e/app-router/react-strict-mode.spec.ts @@ -0,0 +1,9 @@ +import { expect, test } from "@playwright/test"; +import { waitForAppRouterHydration } from "../helpers"; + +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"); +}); 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/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", + ); + }); +}); From 7db6b7bd83e5754abbfd798e44a9fc259c4af2b1 Mon Sep 17 00:00:00 2001 From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com> Date: Thu, 23 Jul 2026 17:02:53 +1000 Subject: [PATCH 2/2] test(app-router): preserve reactStrictMode opt-out The App Router browser coverage only exercised the default-enabled path, so a boolean/string mismatch could enable Strict Mode even when the emitted define was false. Run an isolated development fixture with reactStrictMode set to false and assert a single client render. The fixture keeps its optimizer cache and server lifecycle separate from the default-enabled app. --- .../e2e/app-router/react-strict-mode.spec.ts | 53 +++++++++++++++++++ .../react-strict-mode-disabled/app/layout.tsx | 9 ++++ .../react-strict-mode-disabled/app/page.tsx | 20 +++++++ .../react-strict-mode-disabled/next.config.ts | 7 +++ .../react-strict-mode-disabled/package.json | 4 ++ .../react-strict-mode-disabled/vite.config.ts | 6 +++ 6 files changed, 99 insertions(+) create mode 100644 tests/fixtures/app-basic/react-strict-mode-disabled/app/layout.tsx create mode 100644 tests/fixtures/app-basic/react-strict-mode-disabled/app/page.tsx create mode 100644 tests/fixtures/app-basic/react-strict-mode-disabled/next.config.ts create mode 100644 tests/fixtures/app-basic/react-strict-mode-disabled/package.json create mode 100644 tests/fixtures/app-basic/react-strict-mode-disabled/vite.config.ts diff --git a/tests/e2e/app-router/react-strict-mode.spec.ts b/tests/e2e/app-router/react-strict-mode.spec.ts index 9e62a26c83..ef2f59809a 100644 --- a/tests/e2e/app-router/react-strict-mode.spec.ts +++ b/tests/e2e/app-router/react-strict-mode.spec.ts @@ -1,5 +1,18 @@ +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"); @@ -7,3 +20,43 @@ test("enables Strict Mode by default for the App Router", async ({ 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/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 })], +});