From 6716e6f4975b2f8577bf395d63f9aedf8af27fdd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Lucas?= Date: Tue, 21 Jul 2026 10:47:53 -0300 Subject: [PATCH 1/9] fix(shims): guard process reference in next/constants for client bundles --- packages/vinext/src/shims/constants.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) 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"]; From de4955c170a98ee00814205bf844ae4c320b6ab3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Lucas?= Date: Tue, 21 Jul 2026 10:47:54 -0300 Subject: [PATCH 2/9] perf(server): memoize hot-path dynamic imports in production --- packages/vinext/src/entries/app-rsc-entry.ts | 27 +++++++++----- .../vinext/src/server/app-page-dispatch.ts | 6 +++- packages/vinext/src/server/app-rsc-handler.ts | 18 +++++++--- .../src/server/app-rsc-response-finalizer.ts | 6 +++- packages/vinext/src/server/app-ssr-entry.ts | 35 ++++++++++++++++--- packages/vinext/src/shims/server.ts | 4 ++- tests/entry-templates.test.ts | 4 +-- 7 files changed, 79 insertions(+), 21 deletions(-) diff --git a/packages/vinext/src/entries/app-rsc-entry.ts b/packages/vinext/src/entries/app-rsc-entry.ts index fa318178fd..c83a223818 100644 --- a/packages/vinext/src/entries/app-rsc-entry.ts +++ b/packages/vinext/src/entries/app-rsc-entry.ts @@ -270,7 +270,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; } ` @@ -330,20 +330,31 @@ ${ } from ${JSON.stringify(appRouteHandlerResponsePath)};` : "" } -const __loadAppRouteHandlerDispatch = () => import(${JSON.stringify(appRouteHandlerDispatchPath)}); +const __memoizeLoad = (load) => { + let promise; + return () => (promise ??= load()); +}; +let __ssrModulePromise; +function __loadSsrModule() { + if (process.env.NODE_ENV !== "production") { + return import.meta.viteRsc.loadModule("ssr", "index"); + } + return (__ssrModulePromise ??= import.meta.viteRsc.loadModule("ssr", "index")); +} +const __loadAppRouteHandlerDispatch = __memoizeLoad(() => import(${JSON.stringify(appRouteHandlerDispatchPath)})); ${ hasServerActions - ? `const __loadAppServerActionExecution = () => import(${JSON.stringify(appServerActionExecutionPath)});` + ? `const __loadAppServerActionExecution = __memoizeLoad(() => import(${JSON.stringify(appServerActionExecutionPath)}));` : "" } ${ (metadataRoutes?.length ?? 0) > 0 - ? `const __loadMetadataRouteResponse = () => import(${JSON.stringify(metadataRouteResponsePath)});` + ? `const __loadMetadataRouteResponse = __memoizeLoad(() => import(${JSON.stringify(metadataRouteResponsePath)}));` : "" } ${ (metadataRoutes?.length ?? 0) > 0 - ? `const __loadFileBasedMetadata = () => import(${JSON.stringify(fileBasedMetadataPath)}); + ? `const __loadFileBasedMetadata = __memoizeLoad(() => import(${JSON.stringify(fileBasedMetadataPath)})); async function __applyFileBasedMetadata(...args) { const { applyFileBasedMetadata } = await __loadFileBasedMetadata(); return applyFileBasedMetadata(...args); @@ -592,7 +603,7 @@ const __fallbackRenderer = __createAppFallbackRenderer({ globalNotFoundEnabled: ${config?.globalNotFound === true}, metadataRoutes, ssrLoader() { - return import.meta.viteRsc.loadModule("ssr", "index"); + return __loadSsrModule(); }, fontProviders: { buildFontLinkHeader: __buildAppPageFontLinkHeader, @@ -870,7 +881,7 @@ export default createAppRscHandler({ isrRscKey: __isrRscKey, isrSet: __isrSet, loadSsrHandler() { - return import.meta.viteRsc.loadModule("ssr", "index"); + return __loadSsrModule(); }, middlewareContext, mountedSlotsHeader, @@ -1247,7 +1258,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..59d0fb9875 100644 --- a/packages/vinext/src/server/app-page-dispatch.ts +++ b/packages/vinext/src/server/app-page-dispatch.ts @@ -96,6 +96,8 @@ import { type AppLayoutParamAccessTracker, } from "./app-layout-param-observation.js"; +let appPageCachePromise: Promise | undefined; + type AppPageParams = Record; type AppPageElement = ReactNode | Readonly>; export type AppPageRenderableElement = ReactNode | AppOutgoingElements; @@ -709,7 +711,9 @@ async function dispatchAppPageInner( scriptNonce: options.scriptNonce, }) ) { - const { readAppPageCacheResponse } = await import("./app-page-cache.js"); + const { readAppPageCacheResponse } = await (appPageCachePromise ??= import( + "./app-page-cache.js" + )); 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..6c098397c1 100644 --- a/packages/vinext/src/server/app-rsc-handler.ts +++ b/packages/vinext/src/server/app-rsc-handler.ts @@ -102,6 +102,16 @@ 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 configMatchersPromise: Promise | undefined; +const loadConfigMatchers = () => (configMatchersPromise ??= import("../config/config-matchers.js")); +let configHeadersPromise: Promise | undefined; +const loadConfigHeaders = () => (configHeadersPromise ??= import("./config-headers.js")); type StaticParamsMap = AppPrerenderStaticParamsMap; type RootParamNamesMap = AppPrerenderRootParamNamesMap; @@ -398,7 +408,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 +458,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, @@ -600,7 +610,7 @@ async function handleAppRscRequest( const redirectPathname = matchPathname(requestCleanPathname); const configMatchers = HAS_CONFIG_REDIRECTS && options.configRedirects.length - ? await import("../config/config-matchers.js") + ? await loadConfigMatchers() : null; const redirect = configMatchers ? configMatchers.matchRedirect( @@ -816,7 +826,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..81d25eb086 100644 --- a/packages/vinext/src/server/app-rsc-response-finalizer.ts +++ b/packages/vinext/src/server/app-rsc-response-finalizer.ts @@ -28,6 +28,8 @@ type FinalizeAppRscResponseOptions = { const HAS_CONFIG_HEADERS = process.env.__VINEXT_HAS_CONFIG_HEADERS !== "false"; +let configHeadersPromise: Promise | undefined; + /** * Apply App Router response finalization that must happen outside individual * route dispatchers. @@ -94,7 +96,9 @@ export async function finalizeAppRscResponse( ? normalizeDefaultLocalePathname(pathname, options.i18nConfig, { hostname: url.hostname }) : pathname; - const { applyConfigHeadersToResponse } = await import("./config-headers.js"); + const { applyConfigHeadersToResponse } = await (configHeadersPromise ??= import( + "./config-headers.js" + )); 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..4a2e723099 100644 --- a/packages/vinext/src/server/app-ssr-entry.ts +++ b/packages/vinext/src/server/app-ssr-entry.ts @@ -111,7 +111,19 @@ function isStaticPrerenderModule(value: unknown): value is { prerender: StaticPr ); } -async function loadStaticPrerender(): Promise { +let staticPrerenderPromise: Promise | undefined; + +function loadStaticPrerender(): Promise { + if (!staticPrerenderPromise) { + staticPrerenderPromise = loadStaticPrerenderImpl(); + staticPrerenderPromise.catch(() => { + staticPrerenderPromise = undefined; + }); + } + return staticPrerenderPromise; +} + +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"); @@ -754,6 +766,23 @@ export async function handleSsr( }) as Promise; } +type RscEntryModule = { + default(request: Request): Promise; +}; + +let rscModulePromise: Promise | undefined; + +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). + if (process.env.NODE_ENV !== "production") { + return import.meta.viteRsc.loadModule("rsc", "index"); + } + return (rscModulePromise ??= import.meta.viteRsc.loadModule("rsc", "index")); +} + export default { async fetch(request: Request): Promise { const url = new URL(request.url); @@ -763,9 +792,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/server.ts b/packages/vinext/src/shims/server.ts index 1896f4f0a2..988b33e4e8 100644 --- a/packages/vinext/src/shims/server.ts +++ b/packages/vinext/src/shims/server.ts @@ -1258,6 +1258,8 @@ 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 headersShimPromise: Promise | undefined; + export async function connection(): Promise { const { getHeadersContext, @@ -1265,7 +1267,7 @@ export async function connection(): Promise { markRenderRequestApiUsage, suspendConnectionProbe, throwIfInsideCacheScope, - } = await import("./headers.js"); + } = await (headersShimPromise ??= import("./headers.js")); if (getHeadersContext()?.forceStatic) { return; } diff --git a/tests/entry-templates.test.ts b/tests/entry-templates.test.ts index e2e4e1b11d..65fff62319 100644 --- a/tests/entry-templates.test.ts +++ b/tests/entry-templates.test.ts @@ -1173,8 +1173,8 @@ 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 = __memoizeLoad(() => import("'); + expect(code).toContain('const __loadAppServerActionExecution = __memoizeLoad(() => import("'); expect(code).toContain("await __loadAppRouteHandlerDispatch()"); expect(code).toContain("await __loadAppServerActionExecution()"); expect(code).not.toMatch(/import \{\s*dispatchAppRouteHandler as __dispatchAppRouteHandler,/); From e07c2476b54967ce454e1a659319a6baf3b3fd43 Mon Sep 17 00:00:00 2001 From: James Date: Tue, 21 Jul 2026 21:41:37 +0100 Subject: [PATCH 3/9] fix(server): retry failed memoized module loads --- packages/vinext/src/entries/app-rsc-entry.ts | 23 +++++---- .../vinext/src/server/app-page-dispatch.ts | 7 ++- packages/vinext/src/server/app-rsc-handler.ts | 11 ++--- .../src/server/app-rsc-response-finalizer.ts | 7 ++- packages/vinext/src/server/app-ssr-entry.ts | 21 +++----- packages/vinext/src/shims/server.ts | 5 +- .../vinext/src/utils/memoize-module-loader.ts | 21 ++++++++ tests/constants-shim.test.ts | 48 +++++++++++++++++++ .../e2e/app-router/server-client-only.spec.ts | 14 ++++++ tests/entry-templates.test.ts | 8 +++- .../client-constants.tsx | 14 ++++++ .../app/client-constants-test/page.tsx | 5 ++ tests/memoize-module-loader.test.ts | 37 ++++++++++++++ 13 files changed, 178 insertions(+), 43 deletions(-) create mode 100644 packages/vinext/src/utils/memoize-module-loader.ts create mode 100644 tests/constants-shim.test.ts create mode 100644 tests/fixtures/app-basic/app/client-constants-test/client-constants.tsx create mode 100644 tests/fixtures/app-basic/app/client-constants-test/page.tsx create mode 100644 tests/memoize-module-loader.test.ts diff --git a/packages/vinext/src/entries/app-rsc-entry.ts b/packages/vinext/src/entries/app-rsc-entry.ts index c83a223818..808612ba2a 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. @@ -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,31 +335,29 @@ ${ } from ${JSON.stringify(appRouteHandlerResponsePath)};` : "" } -const __memoizeLoad = (load) => { - let promise; - return () => (promise ??= load()); -}; -let __ssrModulePromise; +const __loadSsrModuleInProduction = __memoizeModuleLoader(() => + import.meta.viteRsc.loadModule("ssr", "index"), +); function __loadSsrModule() { if (process.env.NODE_ENV !== "production") { return import.meta.viteRsc.loadModule("ssr", "index"); } - return (__ssrModulePromise ??= import.meta.viteRsc.loadModule("ssr", "index")); + return __loadSsrModuleInProduction(); } -const __loadAppRouteHandlerDispatch = __memoizeLoad(() => import(${JSON.stringify(appRouteHandlerDispatchPath)})); +const __loadAppRouteHandlerDispatch = __memoizeModuleLoader(() => import(${JSON.stringify(appRouteHandlerDispatchPath)})); ${ hasServerActions - ? `const __loadAppServerActionExecution = __memoizeLoad(() => import(${JSON.stringify(appServerActionExecutionPath)}));` + ? `const __loadAppServerActionExecution = __memoizeModuleLoader(() => import(${JSON.stringify(appServerActionExecutionPath)}));` : "" } ${ (metadataRoutes?.length ?? 0) > 0 - ? `const __loadMetadataRouteResponse = __memoizeLoad(() => import(${JSON.stringify(metadataRouteResponsePath)}));` + ? `const __loadMetadataRouteResponse = __memoizeModuleLoader(() => import(${JSON.stringify(metadataRouteResponsePath)}));` : "" } ${ (metadataRoutes?.length ?? 0) > 0 - ? `const __loadFileBasedMetadata = __memoizeLoad(() => import(${JSON.stringify(fileBasedMetadataPath)})); + ? `const __loadFileBasedMetadata = __memoizeModuleLoader(() => import(${JSON.stringify(fileBasedMetadataPath)})); async function __applyFileBasedMetadata(...args) { const { applyFileBasedMetadata } = await __loadFileBasedMetadata(); return applyFileBasedMetadata(...args); diff --git a/packages/vinext/src/server/app-page-dispatch.ts b/packages/vinext/src/server/app-page-dispatch.ts index 59d0fb9875..54c18fe4fe 100644 --- a/packages/vinext/src/server/app-page-dispatch.ts +++ b/packages/vinext/src/server/app-page-dispatch.ts @@ -90,13 +90,14 @@ import { VINEXT_PRERENDER_SPECULATIVE_HEADER } from "./headers.js"; import type { ClientReuseManifestParseResult } from "./client-reuse-manifest.js"; import { buildAppPageTags } from "./implicit-tags.js"; import type { ISRCacheEntry } from "./isr-cache.js"; +import { memoizeModuleLoader } from "../utils/memoize-module-loader.js"; import { createAppLayoutParamAccessTracker, isAppLayoutObservationUnsafeForStaticReuse, type AppLayoutParamAccessTracker, } from "./app-layout-param-observation.js"; -let appPageCachePromise: Promise | undefined; +const loadAppPageCache = memoizeModuleLoader(() => import("./app-page-cache.js")); type AppPageParams = Record; type AppPageElement = ReactNode | Readonly>; @@ -711,9 +712,7 @@ async function dispatchAppPageInner( scriptNonce: options.scriptNonce, }) ) { - const { readAppPageCacheResponse } = await (appPageCachePromise ??= 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 6c098397c1..e1f5234401 100644 --- a/packages/vinext/src/server/app-rsc-handler.ts +++ b/packages/vinext/src/server/app-rsc-handler.ts @@ -95,6 +95,7 @@ import { type AppRouteTreePrefetchRoute, type PrefetchInliningConfig, } from "./app-route-tree-prefetch.js"; +import { memoizeModuleLoader } from "../utils/memoize-module-loader.js"; type AppPageParams = Record; type RequestContext = ReturnType; @@ -108,10 +109,8 @@ const HAS_CONFIG_REWRITES = process.env.__VINEXT_HAS_CONFIG_REWRITES !== "false" // 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 configMatchersPromise: Promise | undefined; -const loadConfigMatchers = () => (configMatchersPromise ??= import("../config/config-matchers.js")); -let configHeadersPromise: Promise | undefined; -const loadConfigHeaders = () => (configHeadersPromise ??= import("./config-headers.js")); +const loadConfigMatchers = memoizeModuleLoader(() => import("../config/config-matchers.js")); +const loadConfigHeaders = memoizeModuleLoader(() => import("./config-headers.js")); type StaticParamsMap = AppPrerenderStaticParamsMap; type RootParamNamesMap = AppPrerenderRootParamNamesMap; @@ -609,9 +608,7 @@ async function handleAppRscRequest( // original percent-encoding for Location substitution. const redirectPathname = matchPathname(requestCleanPathname); const configMatchers = - HAS_CONFIG_REDIRECTS && options.configRedirects.length - ? await loadConfigMatchers() - : null; + HAS_CONFIG_REDIRECTS && options.configRedirects.length ? await loadConfigMatchers() : null; const redirect = configMatchers ? configMatchers.matchRedirect( redirectPathname, diff --git a/packages/vinext/src/server/app-rsc-response-finalizer.ts b/packages/vinext/src/server/app-rsc-response-finalizer.ts index 81d25eb086..65e64ee85b 100644 --- a/packages/vinext/src/server/app-rsc-response-finalizer.ts +++ b/packages/vinext/src/server/app-rsc-response-finalizer.ts @@ -6,6 +6,7 @@ import { VINEXT_RSC_VARY_HEADER } from "./app-rsc-cache-busting.js"; import { mergeVaryHeader } from "./middleware-response-headers.js"; import { hasBasePath, stripBasePath } from "../utils/base-path.js"; import { normalizeDefaultLocalePathname } from "./pages-i18n.js"; +import { memoizeModuleLoader } from "../utils/memoize-module-loader.js"; type FinalizeAppRscResponseOptions = { basePath: string; @@ -28,7 +29,7 @@ type FinalizeAppRscResponseOptions = { const HAS_CONFIG_HEADERS = process.env.__VINEXT_HAS_CONFIG_HEADERS !== "false"; -let configHeadersPromise: Promise | undefined; +const loadConfigHeaders = memoizeModuleLoader(() => import("./config-headers.js")); /** * Apply App Router response finalization that must happen outside individual @@ -96,9 +97,7 @@ export async function finalizeAppRscResponse( ? normalizeDefaultLocalePathname(pathname, options.i18nConfig, { hostname: url.hostname }) : pathname; - const { applyConfigHeadersToResponse } = await (configHeadersPromise ??= 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 4a2e723099..a71e7a2daa 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,18 +112,6 @@ function isStaticPrerenderModule(value: unknown): value is { prerender: StaticPr ); } -let staticPrerenderPromise: Promise | undefined; - -function loadStaticPrerender(): Promise { - if (!staticPrerenderPromise) { - staticPrerenderPromise = loadStaticPrerenderImpl(); - staticPrerenderPromise.catch(() => { - staticPrerenderPromise = undefined; - }); - } - return staticPrerenderPromise; -} - 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. @@ -169,6 +158,8 @@ async function loadStaticPrerenderImpl(): 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({ @@ -770,7 +761,9 @@ type RscEntryModule = { default(request: Request): Promise; }; -let rscModulePromise: Promise | undefined; +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 @@ -780,7 +773,7 @@ function loadRscModule(): Promise { if (process.env.NODE_ENV !== "production") { return import.meta.viteRsc.loadModule("rsc", "index"); } - return (rscModulePromise ??= import.meta.viteRsc.loadModule("rsc", "index")); + return loadRscModuleInProduction(); } export default { diff --git a/packages/vinext/src/shims/server.ts b/packages/vinext/src/shims/server.ts index 988b33e4e8..c499943ee3 100644 --- a/packages/vinext/src/shims/server.ts +++ b/packages/vinext/src/shims/server.ts @@ -26,6 +26,7 @@ import { } from "./unified-request-context.js"; import { assertSafeNavigationUrl } from "./url-safety.js"; import { hasBasePath, stripBasePath } from "../utils/base-path.js"; +import { memoizeModuleLoader } from "../utils/memoize-module-loader.js"; /** @deprecated Import ImageResponse from `next/og` instead. */ export function ImageResponse(): never { @@ -1258,7 +1259,7 @@ 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 headersShimPromise: Promise | undefined; +const loadHeadersShim = memoizeModuleLoader(() => import("./headers.js")); export async function connection(): Promise { const { @@ -1267,7 +1268,7 @@ export async function connection(): Promise { markRenderRequestApiUsage, suspendConnectionProbe, throwIfInsideCacheScope, - } = await (headersShimPromise ??= 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 65fff62319..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 = __memoizeLoad(() => import("'); - expect(code).toContain('const __loadAppServerActionExecution = __memoizeLoad(() => 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); + }); +}); From 0c770719f06384f00de2ba661dfc77d39f69104a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Lucas?= Date: Tue, 21 Jul 2026 19:48:35 -0300 Subject: [PATCH 4/9] perf(server): keep dynamic imports inline so chunk DCE and tree-shaking survive Wrapping the flag-gated imports in memoizeModuleLoader at module scope defeated two Rolldown optimizations: dead branches behind HAS_CONFIG_* define-folding no longer eliminated their chunks before emission (config-matchers and config-headers shipped as orphan chunks nothing references), and hiding the import() inside a helper forced whole namespace preservation for the target modules (shims/headers.js went from a tree-shaken subset to a full unmangled namespace pulled eagerly into the RSC entry closure). Server bundle regressed +7.3% gzip. Keep the retry-on-failure semantics but with the import() expression inline at the call position, which Rolldown tracks through await (p ??= import(x).catch(reset)). memoizeModuleLoader remains for the generated entry thunks, whose targets are not flag-gated. Measured on the benchmark app: server bundle back to +0.5% of base (173.9k vs 173.0k gzip, was 185.6k), orphan chunks gone, RSC entry closure back to 7 files. entry-templates, shims, constants-shim and memoize-module-loader suites pass (1307 tests). --- packages/vinext/src/server/app-page-dispatch.ts | 8 ++++++-- packages/vinext/src/server/app-rsc-handler.ts | 15 ++++++++++++--- .../src/server/app-rsc-response-finalizer.ts | 8 ++++++-- packages/vinext/src/shims/server.ts | 8 ++++++-- 4 files changed, 30 insertions(+), 9 deletions(-) diff --git a/packages/vinext/src/server/app-page-dispatch.ts b/packages/vinext/src/server/app-page-dispatch.ts index 54c18fe4fe..145bb8d6a5 100644 --- a/packages/vinext/src/server/app-page-dispatch.ts +++ b/packages/vinext/src/server/app-page-dispatch.ts @@ -90,14 +90,18 @@ import { VINEXT_PRERENDER_SPECULATIVE_HEADER } from "./headers.js"; import type { ClientReuseManifestParseResult } from "./client-reuse-manifest.js"; import { buildAppPageTags } from "./implicit-tags.js"; import type { ISRCacheEntry } from "./isr-cache.js"; -import { memoizeModuleLoader } from "../utils/memoize-module-loader.js"; import { createAppLayoutParamAccessTracker, isAppLayoutObservationUnsafeForStaticReuse, type AppLayoutParamAccessTracker, } from "./app-layout-param-observation.js"; -const loadAppPageCache = memoizeModuleLoader(() => import("./app-page-cache.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>; diff --git a/packages/vinext/src/server/app-rsc-handler.ts b/packages/vinext/src/server/app-rsc-handler.ts index e1f5234401..fde9318d42 100644 --- a/packages/vinext/src/server/app-rsc-handler.ts +++ b/packages/vinext/src/server/app-rsc-handler.ts @@ -95,7 +95,6 @@ import { type AppRouteTreePrefetchRoute, type PrefetchInliningConfig, } from "./app-route-tree-prefetch.js"; -import { memoizeModuleLoader } from "../utils/memoize-module-loader.js"; type AppPageParams = Record; type RequestContext = ReturnType; @@ -109,8 +108,18 @@ const HAS_CONFIG_REWRITES = process.env.__VINEXT_HAS_CONFIG_REWRITES !== "false" // 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). -const loadConfigMatchers = memoizeModuleLoader(() => import("../config/config-matchers.js")); -const loadConfigHeaders = memoizeModuleLoader(() => import("./config-headers.js")); +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; diff --git a/packages/vinext/src/server/app-rsc-response-finalizer.ts b/packages/vinext/src/server/app-rsc-response-finalizer.ts index 65e64ee85b..543e4a79f1 100644 --- a/packages/vinext/src/server/app-rsc-response-finalizer.ts +++ b/packages/vinext/src/server/app-rsc-response-finalizer.ts @@ -6,7 +6,6 @@ import { VINEXT_RSC_VARY_HEADER } from "./app-rsc-cache-busting.js"; import { mergeVaryHeader } from "./middleware-response-headers.js"; import { hasBasePath, stripBasePath } from "../utils/base-path.js"; import { normalizeDefaultLocalePathname } from "./pages-i18n.js"; -import { memoizeModuleLoader } from "../utils/memoize-module-loader.js"; type FinalizeAppRscResponseOptions = { basePath: string; @@ -29,7 +28,12 @@ type FinalizeAppRscResponseOptions = { const HAS_CONFIG_HEADERS = process.env.__VINEXT_HAS_CONFIG_HEADERS !== "false"; -const loadConfigHeaders = memoizeModuleLoader(() => import("./config-headers.js")); +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 diff --git a/packages/vinext/src/shims/server.ts b/packages/vinext/src/shims/server.ts index c499943ee3..3dbe65c2f9 100644 --- a/packages/vinext/src/shims/server.ts +++ b/packages/vinext/src/shims/server.ts @@ -26,7 +26,6 @@ import { } from "./unified-request-context.js"; import { assertSafeNavigationUrl } from "./url-safety.js"; import { hasBasePath, stripBasePath } from "../utils/base-path.js"; -import { memoizeModuleLoader } from "../utils/memoize-module-loader.js"; /** @deprecated Import ImageResponse from `next/og` instead. */ export function ImageResponse(): never { @@ -1259,7 +1258,12 @@ 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. */ -const loadHeadersShim = memoizeModuleLoader(() => import("./headers.js")); +let loadHeadersShimPromise: Promise | undefined; +const loadHeadersShim = () => + (loadHeadersShimPromise ??= import("./headers.js").catch((error) => { + loadHeadersShimPromise = undefined; + throw error; + })); export async function connection(): Promise { const { From 101622811d451398e244de95f20f34a2fa0e1fa1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Lucas?= Date: Tue, 21 Jul 2026 10:47:53 -0300 Subject: [PATCH 5/9] fix(shims): guard process reference in next/constants for client bundles --- packages/vinext/src/shims/constants.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) 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"]; From 301964bddf0a1d136f6609e9d90467dec917de66 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Lucas?= Date: Tue, 21 Jul 2026 10:47:54 -0300 Subject: [PATCH 6/9] perf(server): memoize hot-path dynamic imports in production --- packages/vinext/src/entries/app-rsc-entry.ts | 27 +++++++++----- .../vinext/src/server/app-page-dispatch.ts | 6 +++- packages/vinext/src/server/app-rsc-handler.ts | 18 +++++++--- .../src/server/app-rsc-response-finalizer.ts | 6 +++- packages/vinext/src/server/app-ssr-entry.ts | 35 ++++++++++++++++--- packages/vinext/src/shims/server.ts | 4 ++- tests/entry-templates.test.ts | 4 +-- 7 files changed, 79 insertions(+), 21 deletions(-) diff --git a/packages/vinext/src/entries/app-rsc-entry.ts b/packages/vinext/src/entries/app-rsc-entry.ts index fa318178fd..c83a223818 100644 --- a/packages/vinext/src/entries/app-rsc-entry.ts +++ b/packages/vinext/src/entries/app-rsc-entry.ts @@ -270,7 +270,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; } ` @@ -330,20 +330,31 @@ ${ } from ${JSON.stringify(appRouteHandlerResponsePath)};` : "" } -const __loadAppRouteHandlerDispatch = () => import(${JSON.stringify(appRouteHandlerDispatchPath)}); +const __memoizeLoad = (load) => { + let promise; + return () => (promise ??= load()); +}; +let __ssrModulePromise; +function __loadSsrModule() { + if (process.env.NODE_ENV !== "production") { + return import.meta.viteRsc.loadModule("ssr", "index"); + } + return (__ssrModulePromise ??= import.meta.viteRsc.loadModule("ssr", "index")); +} +const __loadAppRouteHandlerDispatch = __memoizeLoad(() => import(${JSON.stringify(appRouteHandlerDispatchPath)})); ${ hasServerActions - ? `const __loadAppServerActionExecution = () => import(${JSON.stringify(appServerActionExecutionPath)});` + ? `const __loadAppServerActionExecution = __memoizeLoad(() => import(${JSON.stringify(appServerActionExecutionPath)}));` : "" } ${ (metadataRoutes?.length ?? 0) > 0 - ? `const __loadMetadataRouteResponse = () => import(${JSON.stringify(metadataRouteResponsePath)});` + ? `const __loadMetadataRouteResponse = __memoizeLoad(() => import(${JSON.stringify(metadataRouteResponsePath)}));` : "" } ${ (metadataRoutes?.length ?? 0) > 0 - ? `const __loadFileBasedMetadata = () => import(${JSON.stringify(fileBasedMetadataPath)}); + ? `const __loadFileBasedMetadata = __memoizeLoad(() => import(${JSON.stringify(fileBasedMetadataPath)})); async function __applyFileBasedMetadata(...args) { const { applyFileBasedMetadata } = await __loadFileBasedMetadata(); return applyFileBasedMetadata(...args); @@ -592,7 +603,7 @@ const __fallbackRenderer = __createAppFallbackRenderer({ globalNotFoundEnabled: ${config?.globalNotFound === true}, metadataRoutes, ssrLoader() { - return import.meta.viteRsc.loadModule("ssr", "index"); + return __loadSsrModule(); }, fontProviders: { buildFontLinkHeader: __buildAppPageFontLinkHeader, @@ -870,7 +881,7 @@ export default createAppRscHandler({ isrRscKey: __isrRscKey, isrSet: __isrSet, loadSsrHandler() { - return import.meta.viteRsc.loadModule("ssr", "index"); + return __loadSsrModule(); }, middlewareContext, mountedSlotsHeader, @@ -1247,7 +1258,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..59d0fb9875 100644 --- a/packages/vinext/src/server/app-page-dispatch.ts +++ b/packages/vinext/src/server/app-page-dispatch.ts @@ -96,6 +96,8 @@ import { type AppLayoutParamAccessTracker, } from "./app-layout-param-observation.js"; +let appPageCachePromise: Promise | undefined; + type AppPageParams = Record; type AppPageElement = ReactNode | Readonly>; export type AppPageRenderableElement = ReactNode | AppOutgoingElements; @@ -709,7 +711,9 @@ async function dispatchAppPageInner( scriptNonce: options.scriptNonce, }) ) { - const { readAppPageCacheResponse } = await import("./app-page-cache.js"); + const { readAppPageCacheResponse } = await (appPageCachePromise ??= import( + "./app-page-cache.js" + )); 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..6c098397c1 100644 --- a/packages/vinext/src/server/app-rsc-handler.ts +++ b/packages/vinext/src/server/app-rsc-handler.ts @@ -102,6 +102,16 @@ 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 configMatchersPromise: Promise | undefined; +const loadConfigMatchers = () => (configMatchersPromise ??= import("../config/config-matchers.js")); +let configHeadersPromise: Promise | undefined; +const loadConfigHeaders = () => (configHeadersPromise ??= import("./config-headers.js")); type StaticParamsMap = AppPrerenderStaticParamsMap; type RootParamNamesMap = AppPrerenderRootParamNamesMap; @@ -398,7 +408,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 +458,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, @@ -600,7 +610,7 @@ async function handleAppRscRequest( const redirectPathname = matchPathname(requestCleanPathname); const configMatchers = HAS_CONFIG_REDIRECTS && options.configRedirects.length - ? await import("../config/config-matchers.js") + ? await loadConfigMatchers() : null; const redirect = configMatchers ? configMatchers.matchRedirect( @@ -816,7 +826,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..81d25eb086 100644 --- a/packages/vinext/src/server/app-rsc-response-finalizer.ts +++ b/packages/vinext/src/server/app-rsc-response-finalizer.ts @@ -28,6 +28,8 @@ type FinalizeAppRscResponseOptions = { const HAS_CONFIG_HEADERS = process.env.__VINEXT_HAS_CONFIG_HEADERS !== "false"; +let configHeadersPromise: Promise | undefined; + /** * Apply App Router response finalization that must happen outside individual * route dispatchers. @@ -94,7 +96,9 @@ export async function finalizeAppRscResponse( ? normalizeDefaultLocalePathname(pathname, options.i18nConfig, { hostname: url.hostname }) : pathname; - const { applyConfigHeadersToResponse } = await import("./config-headers.js"); + const { applyConfigHeadersToResponse } = await (configHeadersPromise ??= import( + "./config-headers.js" + )); 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..4a2e723099 100644 --- a/packages/vinext/src/server/app-ssr-entry.ts +++ b/packages/vinext/src/server/app-ssr-entry.ts @@ -111,7 +111,19 @@ function isStaticPrerenderModule(value: unknown): value is { prerender: StaticPr ); } -async function loadStaticPrerender(): Promise { +let staticPrerenderPromise: Promise | undefined; + +function loadStaticPrerender(): Promise { + if (!staticPrerenderPromise) { + staticPrerenderPromise = loadStaticPrerenderImpl(); + staticPrerenderPromise.catch(() => { + staticPrerenderPromise = undefined; + }); + } + return staticPrerenderPromise; +} + +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"); @@ -754,6 +766,23 @@ export async function handleSsr( }) as Promise; } +type RscEntryModule = { + default(request: Request): Promise; +}; + +let rscModulePromise: Promise | undefined; + +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). + if (process.env.NODE_ENV !== "production") { + return import.meta.viteRsc.loadModule("rsc", "index"); + } + return (rscModulePromise ??= import.meta.viteRsc.loadModule("rsc", "index")); +} + export default { async fetch(request: Request): Promise { const url = new URL(request.url); @@ -763,9 +792,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/server.ts b/packages/vinext/src/shims/server.ts index 1896f4f0a2..988b33e4e8 100644 --- a/packages/vinext/src/shims/server.ts +++ b/packages/vinext/src/shims/server.ts @@ -1258,6 +1258,8 @@ 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 headersShimPromise: Promise | undefined; + export async function connection(): Promise { const { getHeadersContext, @@ -1265,7 +1267,7 @@ export async function connection(): Promise { markRenderRequestApiUsage, suspendConnectionProbe, throwIfInsideCacheScope, - } = await import("./headers.js"); + } = await (headersShimPromise ??= import("./headers.js")); if (getHeadersContext()?.forceStatic) { return; } diff --git a/tests/entry-templates.test.ts b/tests/entry-templates.test.ts index e2e4e1b11d..65fff62319 100644 --- a/tests/entry-templates.test.ts +++ b/tests/entry-templates.test.ts @@ -1173,8 +1173,8 @@ 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 = __memoizeLoad(() => import("'); + expect(code).toContain('const __loadAppServerActionExecution = __memoizeLoad(() => import("'); expect(code).toContain("await __loadAppRouteHandlerDispatch()"); expect(code).toContain("await __loadAppServerActionExecution()"); expect(code).not.toMatch(/import \{\s*dispatchAppRouteHandler as __dispatchAppRouteHandler,/); From 85cdcd53b75da8070cbd5c2b075df16047ded06b Mon Sep 17 00:00:00 2001 From: James Date: Tue, 21 Jul 2026 21:41:37 +0100 Subject: [PATCH 7/9] fix(server): retry failed memoized module loads --- packages/vinext/src/entries/app-rsc-entry.ts | 23 +++++---- .../vinext/src/server/app-page-dispatch.ts | 7 ++- packages/vinext/src/server/app-rsc-handler.ts | 11 ++--- .../src/server/app-rsc-response-finalizer.ts | 7 ++- packages/vinext/src/server/app-ssr-entry.ts | 21 +++----- packages/vinext/src/shims/server.ts | 5 +- .../vinext/src/utils/memoize-module-loader.ts | 21 ++++++++ tests/constants-shim.test.ts | 48 +++++++++++++++++++ .../e2e/app-router/server-client-only.spec.ts | 14 ++++++ tests/entry-templates.test.ts | 8 +++- .../client-constants.tsx | 14 ++++++ .../app/client-constants-test/page.tsx | 5 ++ tests/memoize-module-loader.test.ts | 37 ++++++++++++++ 13 files changed, 178 insertions(+), 43 deletions(-) create mode 100644 packages/vinext/src/utils/memoize-module-loader.ts create mode 100644 tests/constants-shim.test.ts create mode 100644 tests/fixtures/app-basic/app/client-constants-test/client-constants.tsx create mode 100644 tests/fixtures/app-basic/app/client-constants-test/page.tsx create mode 100644 tests/memoize-module-loader.test.ts diff --git a/packages/vinext/src/entries/app-rsc-entry.ts b/packages/vinext/src/entries/app-rsc-entry.ts index c83a223818..808612ba2a 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. @@ -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,31 +335,29 @@ ${ } from ${JSON.stringify(appRouteHandlerResponsePath)};` : "" } -const __memoizeLoad = (load) => { - let promise; - return () => (promise ??= load()); -}; -let __ssrModulePromise; +const __loadSsrModuleInProduction = __memoizeModuleLoader(() => + import.meta.viteRsc.loadModule("ssr", "index"), +); function __loadSsrModule() { if (process.env.NODE_ENV !== "production") { return import.meta.viteRsc.loadModule("ssr", "index"); } - return (__ssrModulePromise ??= import.meta.viteRsc.loadModule("ssr", "index")); + return __loadSsrModuleInProduction(); } -const __loadAppRouteHandlerDispatch = __memoizeLoad(() => import(${JSON.stringify(appRouteHandlerDispatchPath)})); +const __loadAppRouteHandlerDispatch = __memoizeModuleLoader(() => import(${JSON.stringify(appRouteHandlerDispatchPath)})); ${ hasServerActions - ? `const __loadAppServerActionExecution = __memoizeLoad(() => import(${JSON.stringify(appServerActionExecutionPath)}));` + ? `const __loadAppServerActionExecution = __memoizeModuleLoader(() => import(${JSON.stringify(appServerActionExecutionPath)}));` : "" } ${ (metadataRoutes?.length ?? 0) > 0 - ? `const __loadMetadataRouteResponse = __memoizeLoad(() => import(${JSON.stringify(metadataRouteResponsePath)}));` + ? `const __loadMetadataRouteResponse = __memoizeModuleLoader(() => import(${JSON.stringify(metadataRouteResponsePath)}));` : "" } ${ (metadataRoutes?.length ?? 0) > 0 - ? `const __loadFileBasedMetadata = __memoizeLoad(() => import(${JSON.stringify(fileBasedMetadataPath)})); + ? `const __loadFileBasedMetadata = __memoizeModuleLoader(() => import(${JSON.stringify(fileBasedMetadataPath)})); async function __applyFileBasedMetadata(...args) { const { applyFileBasedMetadata } = await __loadFileBasedMetadata(); return applyFileBasedMetadata(...args); diff --git a/packages/vinext/src/server/app-page-dispatch.ts b/packages/vinext/src/server/app-page-dispatch.ts index 59d0fb9875..54c18fe4fe 100644 --- a/packages/vinext/src/server/app-page-dispatch.ts +++ b/packages/vinext/src/server/app-page-dispatch.ts @@ -90,13 +90,14 @@ import { VINEXT_PRERENDER_SPECULATIVE_HEADER } from "./headers.js"; import type { ClientReuseManifestParseResult } from "./client-reuse-manifest.js"; import { buildAppPageTags } from "./implicit-tags.js"; import type { ISRCacheEntry } from "./isr-cache.js"; +import { memoizeModuleLoader } from "../utils/memoize-module-loader.js"; import { createAppLayoutParamAccessTracker, isAppLayoutObservationUnsafeForStaticReuse, type AppLayoutParamAccessTracker, } from "./app-layout-param-observation.js"; -let appPageCachePromise: Promise | undefined; +const loadAppPageCache = memoizeModuleLoader(() => import("./app-page-cache.js")); type AppPageParams = Record; type AppPageElement = ReactNode | Readonly>; @@ -711,9 +712,7 @@ async function dispatchAppPageInner( scriptNonce: options.scriptNonce, }) ) { - const { readAppPageCacheResponse } = await (appPageCachePromise ??= 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 6c098397c1..e1f5234401 100644 --- a/packages/vinext/src/server/app-rsc-handler.ts +++ b/packages/vinext/src/server/app-rsc-handler.ts @@ -95,6 +95,7 @@ import { type AppRouteTreePrefetchRoute, type PrefetchInliningConfig, } from "./app-route-tree-prefetch.js"; +import { memoizeModuleLoader } from "../utils/memoize-module-loader.js"; type AppPageParams = Record; type RequestContext = ReturnType; @@ -108,10 +109,8 @@ const HAS_CONFIG_REWRITES = process.env.__VINEXT_HAS_CONFIG_REWRITES !== "false" // 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 configMatchersPromise: Promise | undefined; -const loadConfigMatchers = () => (configMatchersPromise ??= import("../config/config-matchers.js")); -let configHeadersPromise: Promise | undefined; -const loadConfigHeaders = () => (configHeadersPromise ??= import("./config-headers.js")); +const loadConfigMatchers = memoizeModuleLoader(() => import("../config/config-matchers.js")); +const loadConfigHeaders = memoizeModuleLoader(() => import("./config-headers.js")); type StaticParamsMap = AppPrerenderStaticParamsMap; type RootParamNamesMap = AppPrerenderRootParamNamesMap; @@ -609,9 +608,7 @@ async function handleAppRscRequest( // original percent-encoding for Location substitution. const redirectPathname = matchPathname(requestCleanPathname); const configMatchers = - HAS_CONFIG_REDIRECTS && options.configRedirects.length - ? await loadConfigMatchers() - : null; + HAS_CONFIG_REDIRECTS && options.configRedirects.length ? await loadConfigMatchers() : null; const redirect = configMatchers ? configMatchers.matchRedirect( redirectPathname, diff --git a/packages/vinext/src/server/app-rsc-response-finalizer.ts b/packages/vinext/src/server/app-rsc-response-finalizer.ts index 81d25eb086..65e64ee85b 100644 --- a/packages/vinext/src/server/app-rsc-response-finalizer.ts +++ b/packages/vinext/src/server/app-rsc-response-finalizer.ts @@ -6,6 +6,7 @@ import { VINEXT_RSC_VARY_HEADER } from "./app-rsc-cache-busting.js"; import { mergeVaryHeader } from "./middleware-response-headers.js"; import { hasBasePath, stripBasePath } from "../utils/base-path.js"; import { normalizeDefaultLocalePathname } from "./pages-i18n.js"; +import { memoizeModuleLoader } from "../utils/memoize-module-loader.js"; type FinalizeAppRscResponseOptions = { basePath: string; @@ -28,7 +29,7 @@ type FinalizeAppRscResponseOptions = { const HAS_CONFIG_HEADERS = process.env.__VINEXT_HAS_CONFIG_HEADERS !== "false"; -let configHeadersPromise: Promise | undefined; +const loadConfigHeaders = memoizeModuleLoader(() => import("./config-headers.js")); /** * Apply App Router response finalization that must happen outside individual @@ -96,9 +97,7 @@ export async function finalizeAppRscResponse( ? normalizeDefaultLocalePathname(pathname, options.i18nConfig, { hostname: url.hostname }) : pathname; - const { applyConfigHeadersToResponse } = await (configHeadersPromise ??= 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 4a2e723099..a71e7a2daa 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,18 +112,6 @@ function isStaticPrerenderModule(value: unknown): value is { prerender: StaticPr ); } -let staticPrerenderPromise: Promise | undefined; - -function loadStaticPrerender(): Promise { - if (!staticPrerenderPromise) { - staticPrerenderPromise = loadStaticPrerenderImpl(); - staticPrerenderPromise.catch(() => { - staticPrerenderPromise = undefined; - }); - } - return staticPrerenderPromise; -} - 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. @@ -169,6 +158,8 @@ async function loadStaticPrerenderImpl(): 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({ @@ -770,7 +761,9 @@ type RscEntryModule = { default(request: Request): Promise; }; -let rscModulePromise: Promise | undefined; +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 @@ -780,7 +773,7 @@ function loadRscModule(): Promise { if (process.env.NODE_ENV !== "production") { return import.meta.viteRsc.loadModule("rsc", "index"); } - return (rscModulePromise ??= import.meta.viteRsc.loadModule("rsc", "index")); + return loadRscModuleInProduction(); } export default { diff --git a/packages/vinext/src/shims/server.ts b/packages/vinext/src/shims/server.ts index 988b33e4e8..c499943ee3 100644 --- a/packages/vinext/src/shims/server.ts +++ b/packages/vinext/src/shims/server.ts @@ -26,6 +26,7 @@ import { } from "./unified-request-context.js"; import { assertSafeNavigationUrl } from "./url-safety.js"; import { hasBasePath, stripBasePath } from "../utils/base-path.js"; +import { memoizeModuleLoader } from "../utils/memoize-module-loader.js"; /** @deprecated Import ImageResponse from `next/og` instead. */ export function ImageResponse(): never { @@ -1258,7 +1259,7 @@ 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 headersShimPromise: Promise | undefined; +const loadHeadersShim = memoizeModuleLoader(() => import("./headers.js")); export async function connection(): Promise { const { @@ -1267,7 +1268,7 @@ export async function connection(): Promise { markRenderRequestApiUsage, suspendConnectionProbe, throwIfInsideCacheScope, - } = await (headersShimPromise ??= 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 65fff62319..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 = __memoizeLoad(() => import("'); - expect(code).toContain('const __loadAppServerActionExecution = __memoizeLoad(() => 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); + }); +}); From c796f39890c22151b6da33a740c437d780699044 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Lucas?= Date: Tue, 21 Jul 2026 19:48:35 -0300 Subject: [PATCH 8/9] perf(server): keep dynamic imports inline so chunk DCE and tree-shaking survive Wrapping the flag-gated imports in memoizeModuleLoader at module scope defeated two Rolldown optimizations: dead branches behind HAS_CONFIG_* define-folding no longer eliminated their chunks before emission (config-matchers and config-headers shipped as orphan chunks nothing references), and hiding the import() inside a helper forced whole namespace preservation for the target modules (shims/headers.js went from a tree-shaken subset to a full unmangled namespace pulled eagerly into the RSC entry closure). Server bundle regressed +7.3% gzip. Keep the retry-on-failure semantics but with the import() expression inline at the call position, which Rolldown tracks through await (p ??= import(x).catch(reset)). memoizeModuleLoader remains for the generated entry thunks, whose targets are not flag-gated. Measured on the benchmark app: server bundle back to +0.5% of base (173.9k vs 173.0k gzip, was 185.6k), orphan chunks gone, RSC entry closure back to 7 files. entry-templates, shims, constants-shim and memoize-module-loader suites pass (1307 tests). --- packages/vinext/src/server/app-page-dispatch.ts | 8 ++++++-- packages/vinext/src/server/app-rsc-handler.ts | 15 ++++++++++++--- .../src/server/app-rsc-response-finalizer.ts | 8 ++++++-- packages/vinext/src/shims/server.ts | 8 ++++++-- 4 files changed, 30 insertions(+), 9 deletions(-) diff --git a/packages/vinext/src/server/app-page-dispatch.ts b/packages/vinext/src/server/app-page-dispatch.ts index 54c18fe4fe..145bb8d6a5 100644 --- a/packages/vinext/src/server/app-page-dispatch.ts +++ b/packages/vinext/src/server/app-page-dispatch.ts @@ -90,14 +90,18 @@ import { VINEXT_PRERENDER_SPECULATIVE_HEADER } from "./headers.js"; import type { ClientReuseManifestParseResult } from "./client-reuse-manifest.js"; import { buildAppPageTags } from "./implicit-tags.js"; import type { ISRCacheEntry } from "./isr-cache.js"; -import { memoizeModuleLoader } from "../utils/memoize-module-loader.js"; import { createAppLayoutParamAccessTracker, isAppLayoutObservationUnsafeForStaticReuse, type AppLayoutParamAccessTracker, } from "./app-layout-param-observation.js"; -const loadAppPageCache = memoizeModuleLoader(() => import("./app-page-cache.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>; diff --git a/packages/vinext/src/server/app-rsc-handler.ts b/packages/vinext/src/server/app-rsc-handler.ts index e1f5234401..fde9318d42 100644 --- a/packages/vinext/src/server/app-rsc-handler.ts +++ b/packages/vinext/src/server/app-rsc-handler.ts @@ -95,7 +95,6 @@ import { type AppRouteTreePrefetchRoute, type PrefetchInliningConfig, } from "./app-route-tree-prefetch.js"; -import { memoizeModuleLoader } from "../utils/memoize-module-loader.js"; type AppPageParams = Record; type RequestContext = ReturnType; @@ -109,8 +108,18 @@ const HAS_CONFIG_REWRITES = process.env.__VINEXT_HAS_CONFIG_REWRITES !== "false" // 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). -const loadConfigMatchers = memoizeModuleLoader(() => import("../config/config-matchers.js")); -const loadConfigHeaders = memoizeModuleLoader(() => import("./config-headers.js")); +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; diff --git a/packages/vinext/src/server/app-rsc-response-finalizer.ts b/packages/vinext/src/server/app-rsc-response-finalizer.ts index 65e64ee85b..543e4a79f1 100644 --- a/packages/vinext/src/server/app-rsc-response-finalizer.ts +++ b/packages/vinext/src/server/app-rsc-response-finalizer.ts @@ -6,7 +6,6 @@ import { VINEXT_RSC_VARY_HEADER } from "./app-rsc-cache-busting.js"; import { mergeVaryHeader } from "./middleware-response-headers.js"; import { hasBasePath, stripBasePath } from "../utils/base-path.js"; import { normalizeDefaultLocalePathname } from "./pages-i18n.js"; -import { memoizeModuleLoader } from "../utils/memoize-module-loader.js"; type FinalizeAppRscResponseOptions = { basePath: string; @@ -29,7 +28,12 @@ type FinalizeAppRscResponseOptions = { const HAS_CONFIG_HEADERS = process.env.__VINEXT_HAS_CONFIG_HEADERS !== "false"; -const loadConfigHeaders = memoizeModuleLoader(() => import("./config-headers.js")); +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 diff --git a/packages/vinext/src/shims/server.ts b/packages/vinext/src/shims/server.ts index c499943ee3..3dbe65c2f9 100644 --- a/packages/vinext/src/shims/server.ts +++ b/packages/vinext/src/shims/server.ts @@ -26,7 +26,6 @@ import { } from "./unified-request-context.js"; import { assertSafeNavigationUrl } from "./url-safety.js"; import { hasBasePath, stripBasePath } from "../utils/base-path.js"; -import { memoizeModuleLoader } from "../utils/memoize-module-loader.js"; /** @deprecated Import ImageResponse from `next/og` instead. */ export function ImageResponse(): never { @@ -1259,7 +1258,12 @@ 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. */ -const loadHeadersShim = memoizeModuleLoader(() => import("./headers.js")); +let loadHeadersShimPromise: Promise | undefined; +const loadHeadersShim = () => + (loadHeadersShimPromise ??= import("./headers.js").catch((error) => { + loadHeadersShimPromise = undefined; + throw error; + })); export async function connection(): Promise { const { From ffb61a504c8e1a774f7121ca308f2e704b39589e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Lucas?= Date: Tue, 21 Jul 2026 22:55:30 -0300 Subject: [PATCH 9/9] fix(server): gate loadModule memoization on the Vite mode, not NODE_ENV Projects may define NODE_ENV as production while running vite dev; the module runner still serves an HMR-enabled environment there, so the memoized SSR/RSC entry namespace would go stale after the first request. import.meta.env.PROD reflects the actual serve/build mode and is immune to user-provided NODE_ENV defines. --- packages/vinext/src/entries/app-rsc-entry.ts | 5 ++++- packages/vinext/src/server/app-ssr-entry.ts | 4 +++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/packages/vinext/src/entries/app-rsc-entry.ts b/packages/vinext/src/entries/app-rsc-entry.ts index 808612ba2a..db8c765442 100644 --- a/packages/vinext/src/entries/app-rsc-entry.ts +++ b/packages/vinext/src/entries/app-rsc-entry.ts @@ -339,7 +339,10 @@ const __loadSsrModuleInProduction = __memoizeModuleLoader(() => import.meta.viteRsc.loadModule("ssr", "index"), ); function __loadSsrModule() { - if (process.env.NODE_ENV !== "production") { + // 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(); diff --git a/packages/vinext/src/server/app-ssr-entry.ts b/packages/vinext/src/server/app-ssr-entry.ts index a71e7a2daa..50f5c2d654 100644 --- a/packages/vinext/src/server/app-ssr-entry.ts +++ b/packages/vinext/src/server/app-ssr-entry.ts @@ -770,7 +770,9 @@ function loadRscModule(): Promise { // 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). - if (process.env.NODE_ENV !== "production") { + // 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();