diff --git a/packages/vinext/src/entries/app-rsc-entry.ts b/packages/vinext/src/entries/app-rsc-entry.ts index fa318178fd..db8c765442 100644 --- a/packages/vinext/src/entries/app-rsc-entry.ts +++ b/packages/vinext/src/entries/app-rsc-entry.ts @@ -112,6 +112,10 @@ const appHookWarningSuppressionPath = resolveEntryPath( ); const serverGlobalsPath = resolveEntryPath("../server/server-globals.js", import.meta.url); const appPagesBridgePath = resolveEntryPath("../server/app-pages-bridge.js", import.meta.url); +const memoizeModuleLoaderPath = resolveEntryPath( + "../utils/memoize-module-loader.js", + import.meta.url, +); /** * Resolved config options relevant to App Router request handling. @@ -270,7 +274,7 @@ export function generateRscEntry( const loadPrerenderPagesRoutesCode = hasPagesDir ? ` async function __loadPrerenderPagesRoutes() { - const __gspSsrEntry = await import.meta.viteRsc.loadModule("ssr", "index"); + const __gspSsrEntry = await __loadSsrModule(); return __gspSsrEntry.pageRoutes; } ` @@ -323,6 +327,7 @@ import __pagesClientAssets from "virtual:vinext-pages-client-assets"; import { setPagesClientAssets as __setPagesClientAssets } from "vinext/server/pages-client-assets"; import { decodePathParams as __decodePathParams } from ${JSON.stringify(normalizePathModulePath)}; import { buildRequestHeadersFromMiddlewareResponse as __buildRequestHeadersFromMiddlewareResponse } from ${JSON.stringify(middlewareRequestHeadersPath)}; +import { memoizeModuleLoader as __memoizeModuleLoader } from ${JSON.stringify(memoizeModuleLoaderPath)}; ${ hasPagesDir ? `import { @@ -330,20 +335,32 @@ ${ } from ${JSON.stringify(appRouteHandlerResponsePath)};` : "" } -const __loadAppRouteHandlerDispatch = () => import(${JSON.stringify(appRouteHandlerDispatchPath)}); +const __loadSsrModuleInProduction = __memoizeModuleLoader(() => + import.meta.viteRsc.loadModule("ssr", "index"), +); +function __loadSsrModule() { + // Gate on the Vite serve/build mode rather than NODE_ENV: projects may + // define NODE_ENV as "production" while running vite dev, where the + // module runner must still re-import so HMR invalidations are picked up. + if (!import.meta.env.PROD) { + return import.meta.viteRsc.loadModule("ssr", "index"); + } + return __loadSsrModuleInProduction(); +} +const __loadAppRouteHandlerDispatch = __memoizeModuleLoader(() => import(${JSON.stringify(appRouteHandlerDispatchPath)})); ${ hasServerActions - ? `const __loadAppServerActionExecution = () => import(${JSON.stringify(appServerActionExecutionPath)});` + ? `const __loadAppServerActionExecution = __memoizeModuleLoader(() => import(${JSON.stringify(appServerActionExecutionPath)}));` : "" } ${ (metadataRoutes?.length ?? 0) > 0 - ? `const __loadMetadataRouteResponse = () => import(${JSON.stringify(metadataRouteResponsePath)});` + ? `const __loadMetadataRouteResponse = __memoizeModuleLoader(() => import(${JSON.stringify(metadataRouteResponsePath)}));` : "" } ${ (metadataRoutes?.length ?? 0) > 0 - ? `const __loadFileBasedMetadata = () => import(${JSON.stringify(fileBasedMetadataPath)}); + ? `const __loadFileBasedMetadata = __memoizeModuleLoader(() => import(${JSON.stringify(fileBasedMetadataPath)})); async function __applyFileBasedMetadata(...args) { const { applyFileBasedMetadata } = await __loadFileBasedMetadata(); return applyFileBasedMetadata(...args); @@ -592,7 +609,7 @@ const __fallbackRenderer = __createAppFallbackRenderer({ globalNotFoundEnabled: ${config?.globalNotFound === true}, metadataRoutes, ssrLoader() { - return import.meta.viteRsc.loadModule("ssr", "index"); + return __loadSsrModule(); }, fontProviders: { buildFontLinkHeader: __buildAppPageFontLinkHeader, @@ -870,7 +887,7 @@ export default createAppRscHandler({ isrRscKey: __isrRscKey, isrSet: __isrSet, loadSsrHandler() { - return import.meta.viteRsc.loadModule("ssr", "index"); + return __loadSsrModule(); }, middlewareContext, mountedSlotsHeader, @@ -1247,7 +1264,7 @@ export default createAppRscHandler({ { allowRscDocumentFallback, appRouteMatch, isDataRequest, isRscRequest, matchKind, middlewareContext, pathname, pagesDataRequest, request, url }, { loadPagesEntry() { - return import.meta.viteRsc.loadModule("ssr", "index"); + return __loadSsrModule(); }, buildRequestHeaders: __buildRequestHeadersFromMiddlewareResponse, decodePathParams: __decodePathParams, diff --git a/packages/vinext/src/server/app-page-dispatch.ts b/packages/vinext/src/server/app-page-dispatch.ts index ee42dc89d1..145bb8d6a5 100644 --- a/packages/vinext/src/server/app-page-dispatch.ts +++ b/packages/vinext/src/server/app-page-dispatch.ts @@ -96,6 +96,13 @@ import { type AppLayoutParamAccessTracker, } from "./app-layout-param-observation.js"; +let loadAppPageCachePromise: Promise | undefined; +const loadAppPageCache = () => + (loadAppPageCachePromise ??= import("./app-page-cache.js").catch((error) => { + loadAppPageCachePromise = undefined; + throw error; + })); + type AppPageParams = Record; type AppPageElement = ReactNode | Readonly>; export type AppPageRenderableElement = ReactNode | AppOutgoingElements; @@ -709,7 +716,7 @@ async function dispatchAppPageInner( scriptNonce: options.scriptNonce, }) ) { - const { readAppPageCacheResponse } = await import("./app-page-cache.js"); + const { readAppPageCacheResponse } = await loadAppPageCache(); const cachedPageResponse = await readAppPageCacheResponse({ cleanPathname: options.cleanPathname, clearRequestContext: options.clearRequestContext, diff --git a/packages/vinext/src/server/app-rsc-handler.ts b/packages/vinext/src/server/app-rsc-handler.ts index 13967424f4..fde9318d42 100644 --- a/packages/vinext/src/server/app-rsc-handler.ts +++ b/packages/vinext/src/server/app-rsc-handler.ts @@ -102,6 +102,24 @@ const STATIC_METADATA_CONFIG_HEADER_OVERRIDES = new Set(["cache-control"]); const HAS_CONFIG_HEADERS = process.env.__VINEXT_HAS_CONFIG_HEADERS !== "false"; const HAS_CONFIG_REDIRECTS = process.env.__VINEXT_HAS_CONFIG_REDIRECTS !== "false"; const HAS_CONFIG_REWRITES = process.env.__VINEXT_HAS_CONFIG_REWRITES !== "false"; + +// Memoized lazy loaders. The dynamic imports keep these chunks out of the +// startup graph when the matching config is absent, but the module identity +// never changes at runtime, so re-running import() per request only pays +// resolution overhead (amplified to a synchronous hooks-thread round-trip +// when ESM loader hooks are registered, e.g. by OTel/Sentry). +let loadConfigMatchersPromise: Promise | undefined; +const loadConfigMatchers = () => + (loadConfigMatchersPromise ??= import("../config/config-matchers.js").catch((error) => { + loadConfigMatchersPromise = undefined; + throw error; + })); +let loadConfigHeadersPromise: Promise | undefined; +const loadConfigHeaders = () => + (loadConfigHeadersPromise ??= import("./config-headers.js").catch((error) => { + loadConfigHeadersPromise = undefined; + throw error; + })); type StaticParamsMap = AppPrerenderStaticParamsMap; type RootParamNamesMap = AppPrerenderRootParamNamesMap; @@ -398,7 +416,7 @@ async function applyRewrite( if (!HAS_CONFIG_REWRITES || !options.rewrites.length) return null; const sourcePathname = options.paramsPathname ?? cleanPathname; - const configMatchers = await import("../config/config-matchers.js"); + const configMatchers = await loadConfigMatchers(); const rewritten = configMatchers.matchRewrite( sourcePathname, options.rewrites, @@ -448,7 +466,7 @@ async function applyConfigHeadersToMiddlewareRedirect( if (response.status < 300 || response.status >= 400) return response; if (!HAS_CONFIG_HEADERS || !options.configHeaders.length) return response; - const { applyConfigHeadersToResponse } = await import("./config-headers.js"); + const { applyConfigHeadersToResponse } = await loadConfigHeaders(); const headers = new Headers(); applyConfigHeadersToResponse(headers, { configHeaders: options.configHeaders, @@ -599,9 +617,7 @@ async function handleAppRscRequest( // original percent-encoding for Location substitution. const redirectPathname = matchPathname(requestCleanPathname); const configMatchers = - HAS_CONFIG_REDIRECTS && options.configRedirects.length - ? await import("../config/config-matchers.js") - : null; + HAS_CONFIG_REDIRECTS && options.configRedirects.length ? await loadConfigMatchers() : null; const redirect = configMatchers ? configMatchers.matchRedirect( redirectPathname, @@ -816,7 +832,7 @@ async function handleAppRscRequest( if (filesystemRouteEligible && options.handleMetadataRouteRequest) { const metadataRouteResponse = await options.handleMetadataRouteRequest(cleanPathname); if (metadataRouteResponse && HAS_CONFIG_HEADERS && options.configHeaders.length) { - const { applyConfigHeadersToResponse } = await import("./config-headers.js"); + const { applyConfigHeadersToResponse } = await loadConfigHeaders(); applyConfigHeadersToResponse(metadataRouteResponse.headers, { basePathState, configHeaders: options.configHeaders, diff --git a/packages/vinext/src/server/app-rsc-response-finalizer.ts b/packages/vinext/src/server/app-rsc-response-finalizer.ts index 43021c29b7..543e4a79f1 100644 --- a/packages/vinext/src/server/app-rsc-response-finalizer.ts +++ b/packages/vinext/src/server/app-rsc-response-finalizer.ts @@ -28,6 +28,13 @@ type FinalizeAppRscResponseOptions = { const HAS_CONFIG_HEADERS = process.env.__VINEXT_HAS_CONFIG_HEADERS !== "false"; +let loadConfigHeadersPromise: Promise | undefined; +const loadConfigHeaders = () => + (loadConfigHeadersPromise ??= import("./config-headers.js").catch((error) => { + loadConfigHeadersPromise = undefined; + throw error; + })); + /** * Apply App Router response finalization that must happen outside individual * route dispatchers. @@ -94,7 +101,7 @@ export async function finalizeAppRscResponse( ? normalizeDefaultLocalePathname(pathname, options.i18nConfig, { hostname: url.hostname }) : pathname; - const { applyConfigHeadersToResponse } = await import("./config-headers.js"); + const { applyConfigHeadersToResponse } = await loadConfigHeaders(); applyConfigHeadersToResponse(response.headers, { configHeaders: options.configHeaders, pathname: matchPathname, diff --git a/packages/vinext/src/server/app-ssr-entry.ts b/packages/vinext/src/server/app-ssr-entry.ts index 591b8c0650..50f5c2d654 100644 --- a/packages/vinext/src/server/app-ssr-entry.ts +++ b/packages/vinext/src/server/app-ssr-entry.ts @@ -58,6 +58,7 @@ import { appendAssetDeploymentIdQuery } from "../utils/deployment-id.js"; import { ssrAppRouterInstance } from "./app-ssr-router-instance.js"; // @ts-expect-error — resolved by the vinext build plugin in SSR environments. import pagesClientAssets from "virtual:vinext-pages-client-assets"; +import { memoizeModuleLoader } from "../utils/memoize-module-loader.js"; import { setPagesClientAssets, type PagesClientAssets } from "./pages-client-assets.js"; setPagesClientAssets(pagesClientAssets as PagesClientAssets); @@ -111,7 +112,7 @@ function isStaticPrerenderModule(value: unknown): value is { prerender: StaticPr ); } -async function loadStaticPrerender(): Promise { +async function loadStaticPrerenderImpl(): Promise { // Prefer the stable ESM entry in all environments. Future React dev builds // may export prerender() here, so this is the first path we attempt. const staticRenderer: unknown = await import("react-dom/static.edge"); @@ -157,6 +158,8 @@ async function loadStaticPrerender(): Promise { throw new Error("[vinext] react-dom/static.edge did not expose prerender()."); } +const loadStaticPrerender = memoizeModuleLoader(loadStaticPrerenderImpl); + function createUtf8Stream(html: string): ReadableStream { const encoder = new TextEncoder(); return new ReadableStream({ @@ -754,6 +757,27 @@ export async function handleSsr( }) as Promise; } +type RscEntryModule = { + default(request: Request): Promise; +}; + +const loadRscModuleInProduction = memoizeModuleLoader(() => + import.meta.viteRsc.loadModule("rsc", "index"), +); + +function loadRscModule(): Promise { + // In dev, the Vite module runner must re-import so HMR invalidations of the + // RSC entry (which bundles user code) are picked up. In production the + // target module is immutable, so memoize to avoid a dynamic import() on + // every request (each one round-trips through registered ESM loader hooks). + // Gated on the Vite serve/build mode rather than NODE_ENV, which projects + // may define as "production" while still running the dev server. + if (!import.meta.env.PROD) { + return import.meta.viteRsc.loadModule("rsc", "index"); + } + return loadRscModuleInProduction(); +} + export default { async fetch(request: Request): Promise { const url = new URL(request.url); @@ -763,9 +787,7 @@ export default { return notFoundResponse(); } - const rscModule = await import.meta.viteRsc.loadModule<{ - default(request: Request): Promise; - }>("rsc", "index"); + const rscModule = await loadRscModule(); const result = await rscModule.default(request); if (result instanceof Response) { diff --git a/packages/vinext/src/shims/constants.ts b/packages/vinext/src/shims/constants.ts index 70bba3174f..d600ffbe05 100644 --- a/packages/vinext/src/shims/constants.ts +++ b/packages/vinext/src/shims/constants.ts @@ -112,8 +112,11 @@ export const CONFIG_FILES = [ "next.config.js", "next.config.mjs", "next.config.ts", - // process.features can be undefined on Edge runtime - ...(process?.features?.typescript ? ["next.config.mts"] : []), + // `process` is not defined in browser bundles (this shim is a valid client + // import via `next/constants`), and process.features can be undefined on + // Edge runtime. Optional chaining does not guard an undeclared identifier, + // so the typeof check is required for the client. + ...(typeof process !== "undefined" && process.features?.typescript ? ["next.config.mts"] : []), ]; export const BUILD_ID_FILE = "BUILD_ID"; export const BLOCKED_PAGES = ["/_document", "/_app", "/_error"]; diff --git a/packages/vinext/src/shims/server.ts b/packages/vinext/src/shims/server.ts index 1896f4f0a2..3dbe65c2f9 100644 --- a/packages/vinext/src/shims/server.ts +++ b/packages/vinext/src/shims/server.ts @@ -1258,6 +1258,13 @@ export function after(task: Promise | (() => T | Promise)): void { * (not a static/cached response). Opts the page out of ISR caching * and sets Cache-Control: no-store on the response. */ +let loadHeadersShimPromise: Promise | undefined; +const loadHeadersShim = () => + (loadHeadersShimPromise ??= import("./headers.js").catch((error) => { + loadHeadersShimPromise = undefined; + throw error; + })); + export async function connection(): Promise { const { getHeadersContext, @@ -1265,7 +1272,7 @@ export async function connection(): Promise { markRenderRequestApiUsage, suspendConnectionProbe, throwIfInsideCacheScope, - } = await import("./headers.js"); + } = await loadHeadersShim(); if (getHeadersContext()?.forceStatic) { return; } diff --git a/packages/vinext/src/utils/memoize-module-loader.ts b/packages/vinext/src/utils/memoize-module-loader.ts new file mode 100644 index 0000000000..d2607552ac --- /dev/null +++ b/packages/vinext/src/utils/memoize-module-loader.ts @@ -0,0 +1,21 @@ +/** + * Memoize a lazy module loader without permanently caching a failed load. + * + * Concurrent callers share the same in-flight promise. Once it resolves, the + * module remains cached for the lifetime of this importer. A rejection clears + * the cache so a later request retains the retry behavior of a direct import. + */ +export function memoizeModuleLoader(load: () => Promise): () => Promise { + let promise: Promise | undefined; + + return () => { + if (!promise) { + const loading = load(); + promise = loading; + void loading.catch(() => { + if (promise === loading) promise = undefined; + }); + } + return promise; + }; +} diff --git a/tests/constants-shim.test.ts b/tests/constants-shim.test.ts new file mode 100644 index 0000000000..a19cd8f180 --- /dev/null +++ b/tests/constants-shim.test.ts @@ -0,0 +1,48 @@ +import fs from "node:fs/promises"; +import vm from "node:vm"; +import { describe, expect, it } from "vite-plus/test"; +import { transformWithOxc } from "vite"; + +async function evaluateConfigFiles(processValue?: object): Promise { + const source = await fs.readFile( + new URL("../packages/vinext/src/shims/constants.ts", import.meta.url), + "utf8", + ); + const transformed = await transformWithOxc(source, "constants.ts", { + target: "es2022", + }); + const context: Record = {}; + if (processValue !== undefined) context.process = processValue; + vm.runInNewContext( + `${transformed.code.replace(/^export /gm, "")}\nglobalThis.__configFiles = CONFIG_FILES;`, + context, + ); + return context.__configFiles as string[]; +} + +describe("next/constants process feature detection", () => { + // Next.js reads this feature in its shared constants module. Its webpack + // client runtime supplies `process`; vinext's Vite client runtime does not. + // https://github.com/vercel/next.js/blob/canary/packages/next/src/shared/lib/constants.ts + it("evaluates without a process global in browser and Edge-like runtimes", async () => { + await expect(evaluateConfigFiles()).resolves.toEqual([ + "next.config.js", + "next.config.mjs", + "next.config.ts", + ]); + await expect(evaluateConfigFiles({})).resolves.toEqual([ + "next.config.js", + "next.config.mjs", + "next.config.ts", + ]); + }); + + it("retains Node's native TypeScript config detection", async () => { + await expect(evaluateConfigFiles({ features: { typescript: true } })).resolves.toEqual([ + "next.config.js", + "next.config.mjs", + "next.config.ts", + "next.config.mts", + ]); + }); +}); diff --git a/tests/e2e/app-router/server-client-only.spec.ts b/tests/e2e/app-router/server-client-only.spec.ts index e684d4b50d..1ba9aeb29c 100644 --- a/tests/e2e/app-router/server-client-only.spec.ts +++ b/tests/e2e/app-router/server-client-only.spec.ts @@ -1,8 +1,22 @@ import { test, expect } from "@playwright/test"; +import { waitForAppRouterHydration } from "../helpers"; const BASE = "http://localhost:4174"; test.describe("server-only and client-only package shims", () => { + test("client next/constants import evaluates and hydrates without process", async ({ page }) => { + const pageErrors: string[] = []; + page.on("pageerror", (error) => pageErrors.push(error.message)); + + await page.goto(`${BASE}/client-constants-test`); + await waitForAppRouterHydration(page); + const probe = page.getByTestId("client-constants"); + await expect(probe).toHaveText("phase-production-build:0"); + await probe.click(); + await expect(probe).toHaveText("phase-production-build:1"); + expect(pageErrors).toEqual([]); + }); + test("server component with `import server-only` renders correctly", async ({ page }) => { await page.goto(`${BASE}/server-only-test`); diff --git a/tests/entry-templates.test.ts b/tests/entry-templates.test.ts index e2e4e1b11d..021643308e 100644 --- a/tests/entry-templates.test.ts +++ b/tests/entry-templates.test.ts @@ -1173,8 +1173,12 @@ describe("App Router entry templates", () => { it("generateRscEntry defers route-handler and server-action runtimes", () => { const code = generateRscEntry("/tmp/test/app", minimalAppRoutes, null, [], null, "", false); - expect(code).toContain('const __loadAppRouteHandlerDispatch = () => import("'); - expect(code).toContain('const __loadAppServerActionExecution = () => import("'); + expect(code).toContain( + 'const __loadAppRouteHandlerDispatch = __memoizeModuleLoader(() => import("', + ); + expect(code).toContain( + 'const __loadAppServerActionExecution = __memoizeModuleLoader(() => import("', + ); expect(code).toContain("await __loadAppRouteHandlerDispatch()"); expect(code).toContain("await __loadAppServerActionExecution()"); expect(code).not.toMatch(/import \{\s*dispatchAppRouteHandler as __dispatchAppRouteHandler,/); diff --git a/tests/fixtures/app-basic/app/client-constants-test/client-constants.tsx b/tests/fixtures/app-basic/app/client-constants-test/client-constants.tsx new file mode 100644 index 0000000000..0ede5221f5 --- /dev/null +++ b/tests/fixtures/app-basic/app/client-constants-test/client-constants.tsx @@ -0,0 +1,14 @@ +"use client"; + +import { useState } from "react"; +import { PHASE_PRODUCTION_BUILD } from "next/constants"; + +export function ClientConstants() { + const [clicks, setClicks] = useState(0); + + return ( + + ); +} diff --git a/tests/fixtures/app-basic/app/client-constants-test/page.tsx b/tests/fixtures/app-basic/app/client-constants-test/page.tsx new file mode 100644 index 0000000000..4fb71d120b --- /dev/null +++ b/tests/fixtures/app-basic/app/client-constants-test/page.tsx @@ -0,0 +1,5 @@ +import { ClientConstants } from "./client-constants"; + +export default function ClientConstantsPage() { + return ; +} diff --git a/tests/memoize-module-loader.test.ts b/tests/memoize-module-loader.test.ts new file mode 100644 index 0000000000..6da190be05 --- /dev/null +++ b/tests/memoize-module-loader.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it, vi } from "vite-plus/test"; +import { memoizeModuleLoader } from "../packages/vinext/src/utils/memoize-module-loader.js"; + +describe("memoizeModuleLoader", () => { + it("shares one in-flight load across concurrent callers", async () => { + let resolveLoad!: (value: { value: number }) => void; + const load = vi.fn( + () => + new Promise<{ value: number }>((resolve) => { + resolveLoad = resolve; + }), + ); + const memoized = memoizeModuleLoader(load); + + const first = memoized(); + const second = memoized(); + expect(second).toBe(first); + expect(load).toHaveBeenCalledTimes(1); + + resolveLoad({ value: 42 }); + await expect(first).resolves.toEqual({ value: 42 }); + await expect(memoized()).resolves.toEqual({ value: 42 }); + expect(load).toHaveBeenCalledTimes(1); + }); + + it("retries after a rejected load", async () => { + const load = vi + .fn<() => Promise<{ value: number }>>() + .mockRejectedValueOnce(new Error("temporary module load failure")) + .mockResolvedValueOnce({ value: 42 }); + const memoized = memoizeModuleLoader(load); + + await expect(memoized()).rejects.toThrow("temporary module load failure"); + await expect(memoized()).resolves.toEqual({ value: 42 }); + expect(load).toHaveBeenCalledTimes(2); + }); +});