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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 25 additions & 8 deletions packages/vinext/src/entries/app-rsc-entry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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;
}
`
Expand Down Expand Up @@ -323,27 +327,40 @@ 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 {
applyRouteHandlerMiddlewareContext as __applyRouteHandlerMiddlewareContext,
} 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);
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -870,7 +887,7 @@ export default createAppRscHandler({
isrRscKey: __isrRscKey,
isrSet: __isrSet,
loadSsrHandler() {
return import.meta.viteRsc.loadModule("ssr", "index");
return __loadSsrModule();
},
middlewareContext,
mountedSlotsHeader,
Expand Down Expand Up @@ -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,
Expand Down
9 changes: 8 additions & 1 deletion packages/vinext/src/server/app-page-dispatch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,13 @@ import {
type AppLayoutParamAccessTracker,
} from "./app-layout-param-observation.js";

let loadAppPageCachePromise: Promise<typeof import("./app-page-cache.js")> | undefined;
const loadAppPageCache = () =>
(loadAppPageCachePromise ??= import("./app-page-cache.js").catch((error) => {
loadAppPageCachePromise = undefined;
throw error;
}));

type AppPageParams = Record<string, string | string[]>;
type AppPageElement = ReactNode | Readonly<Record<string, ReactNode>>;
export type AppPageRenderableElement = ReactNode | AppOutgoingElements;
Expand Down Expand Up @@ -709,7 +716,7 @@ async function dispatchAppPageInner<TRoute extends AppPageDispatchRoute>(
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,
Expand Down
28 changes: 22 additions & 6 deletions packages/vinext/src/server/app-rsc-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof import("../config/config-matchers.js")> | undefined;
const loadConfigMatchers = () =>
(loadConfigMatchersPromise ??= import("../config/config-matchers.js").catch((error) => {
loadConfigMatchersPromise = undefined;
throw error;
}));
let loadConfigHeadersPromise: Promise<typeof import("./config-headers.js")> | undefined;
const loadConfigHeaders = () =>
(loadConfigHeadersPromise ??= import("./config-headers.js").catch((error) => {
loadConfigHeadersPromise = undefined;
throw error;
}));
type StaticParamsMap = AppPrerenderStaticParamsMap;
type RootParamNamesMap = AppPrerenderRootParamNamesMap;

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -599,9 +617,7 @@ async function handleAppRscRequest<TRoute extends AppRscHandlerRoute>(
// 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,
Expand Down Expand Up @@ -816,7 +832,7 @@ async function handleAppRscRequest<TRoute extends AppRscHandlerRoute>(
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,
Expand Down
9 changes: 8 additions & 1 deletion packages/vinext/src/server/app-rsc-response-finalizer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,13 @@ type FinalizeAppRscResponseOptions = {

const HAS_CONFIG_HEADERS = process.env.__VINEXT_HAS_CONFIG_HEADERS !== "false";

let loadConfigHeadersPromise: Promise<typeof import("./config-headers.js")> | 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.
Expand Down Expand Up @@ -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,
Expand Down
30 changes: 26 additions & 4 deletions packages/vinext/src/server/app-ssr-entry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -111,7 +112,7 @@ function isStaticPrerenderModule(value: unknown): value is { prerender: StaticPr
);
}

async function loadStaticPrerender(): Promise<StaticPrerender> {
async function loadStaticPrerenderImpl(): Promise<StaticPrerender> {
// 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");
Expand Down Expand Up @@ -157,6 +158,8 @@ async function loadStaticPrerender(): Promise<StaticPrerender> {
throw new Error("[vinext] react-dom/static.edge did not expose prerender().");
}

const loadStaticPrerender = memoizeModuleLoader(loadStaticPrerenderImpl);

function createUtf8Stream(html: string): ReadableStream<Uint8Array> {
const encoder = new TextEncoder();
return new ReadableStream<Uint8Array>({
Expand Down Expand Up @@ -754,6 +757,27 @@ export async function handleSsr(
}) as Promise<AppSsrRenderResult>;
}

type RscEntryModule = {
default(request: Request): Promise<Response | string | null | undefined>;
};

const loadRscModuleInProduction = memoizeModuleLoader(() =>
import.meta.viteRsc.loadModule<RscEntryModule>("rsc", "index"),
);

function loadRscModule(): Promise<RscEntryModule> {
// 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<RscEntryModule>("rsc", "index");
}
return loadRscModuleInProduction();
}

export default {
async fetch(request: Request): Promise<Response> {
const url = new URL(request.url);
Expand All @@ -763,9 +787,7 @@ export default {
return notFoundResponse();
}

const rscModule = await import.meta.viteRsc.loadModule<{
default(request: Request): Promise<Response | string | null | undefined>;
}>("rsc", "index");
const rscModule = await loadRscModule();
const result = await rscModule.default(request);

if (result instanceof Response) {
Expand Down
7 changes: 5 additions & 2 deletions packages/vinext/src/shims/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"];
Expand Down
9 changes: 8 additions & 1 deletion packages/vinext/src/shims/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1258,14 +1258,21 @@ export function after<T>(task: Promise<T> | (() => T | Promise<T>)): 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<typeof import("./headers.js")> | undefined;
const loadHeadersShim = () =>
(loadHeadersShimPromise ??= import("./headers.js").catch((error) => {
loadHeadersShimPromise = undefined;
throw error;
}));

export async function connection(): Promise<void> {
const {
getHeadersContext,
markDynamicUsage,
markRenderRequestApiUsage,
suspendConnectionProbe,
throwIfInsideCacheScope,
} = await import("./headers.js");
} = await loadHeadersShim();
if (getHeadersContext()?.forceStatic) {
return;
}
Expand Down
21 changes: 21 additions & 0 deletions packages/vinext/src/utils/memoize-module-loader.ts
Original file line number Diff line number Diff line change
@@ -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<T>(load: () => Promise<T>): () => Promise<T> {
let promise: Promise<T> | undefined;

return () => {
if (!promise) {
const loading = load();
promise = loading;
void loading.catch(() => {
if (promise === loading) promise = undefined;
});
}
return promise;
};
}
48 changes: 48 additions & 0 deletions tests/constants-shim.test.ts
Original file line number Diff line number Diff line change
@@ -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<string[]> {
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<string, unknown> = {};
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",
]);
});
});
14 changes: 14 additions & 0 deletions tests/e2e/app-router/server-client-only.spec.ts
Original file line number Diff line number Diff line change
@@ -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`);

Expand Down
Loading