Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions packages/vinext/src/check.ts
Original file line number Diff line number Diff line change
Expand Up @@ -293,9 +293,9 @@ const CONFIG_SUPPORT: Record<string, { status: Status; detail?: string }> = {
detail: "supported for Pages Router; App Router unchanged",
},
reactStrictMode: {
status: "partial",
status: "supported",
detail:
"enforced for the Pages Router (client root wrapped in <React.StrictMode> 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",
Expand Down
6 changes: 6 additions & 0 deletions packages/vinext/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand Down
17 changes: 12 additions & 5 deletions packages/vinext/src/server/app-browser-entry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@

import {
createElement,
Fragment,
startTransition,
StrictMode,
use,
useEffect,
useLayoutEffect,
Expand Down Expand Up @@ -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<typeof URLSearchParams>[0];
type DevErrorOverlayModule = typeof import("../client/dev-error-overlay.js");
Expand Down Expand Up @@ -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
Expand Down
5 changes: 1 addition & 4 deletions tests/check.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
// `<React.StrictMode>` 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", () => {
Expand Down
62 changes: 62 additions & 0 deletions tests/e2e/app-router/react-strict-mode.spec.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
10 changes: 10 additions & 0 deletions tests/fixtures/app-basic/app/react-strict-mode/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { RenderProbe } from "./render-probe";

export default function ReactStrictModePage() {
return (
<main>
<h1>React Strict Mode</h1>
<RenderProbe />
</main>
);
}
20 changes: 20 additions & 0 deletions tests/fixtures/app-basic/app/react-strict-mode/render-probe.tsx
Original file line number Diff line number Diff line change
@@ -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 <p data-testid="strict-mode-render-count">0</p>;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import type { ReactNode } from "react";

export default function RootLayout({ children }: { children: ReactNode }) {
return (
<html lang="en">
<body>{children}</body>
</html>
);
}
20 changes: 20 additions & 0 deletions tests/fixtures/app-basic/react-strict-mode-disabled/app/page.tsx
Original file line number Diff line number Diff line change
@@ -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 <p data-testid="strict-mode-render-count">0</p>;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import type { NextConfig } from "vinext";

const nextConfig: NextConfig = {
reactStrictMode: false,
};

export default nextConfig;
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"private": true,
"type": "module"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { defineConfig } from "vite";
import vinext from "vinext";

export default defineConfig({
plugins: [vinext({ appDir: import.meta.dirname })],
});
60 changes: 60 additions & 0 deletions tests/react-strict-mode.test.ts
Original file line number Diff line number Diff line change
@@ -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<string | undefined> {
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 <html><body>{children}</body></html>; }",
);
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<string, string> };

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",
);
});
});
Loading