Skip to content

fix: client-safe next/constants shim and memoized hot-path dynamic imports#2663

Open
jlucaso1 wants to merge 11 commits into
cloudflare:mainfrom
jlucaso1:fix/client-constants-and-per-request-imports
Open

fix: client-safe next/constants shim and memoized hot-path dynamic imports#2663
jlucaso1 wants to merge 11 commits into
cloudflare:mainfrom
jlucaso1:fix/client-constants-and-per-request-imports

Conversation

@jlucaso1

Copy link
Copy Markdown

Closes #2661
Closes #2662

Two related production fixes, one commit each. Happy to split into separate PRs if you prefer.

1. fix(shims): guard the process reference in next/constants

The shim is a valid client import (it is aliased for every environment, and @sentry/nextjs for example reaches it from client code), but CONFIG_FILES referenced process at module top level. Browsers have no process and optional chaining does not guard an undeclared identifier, so evaluating the module threw ReferenceError: process is not defined and took down the shared vinext-* client chunk. Real Next.js has the same expression but is saved by webpack's process polyfill, which Vite does not inject. Fixed with a typeof process check, identical behavior on any runtime that has process.

Verified with a "use client" page importing PHASE_PRODUCTION_BUILD in the app-basic fixture, production build, real Chrome: 3 page errors before, 0 after, same rendered output.

2. perf(server): memoize hot-path dynamic imports in production

Several import() calls run on every request. The modules are cached by Node, but each call re-runs ESM resolution, and with loader hooks registered (Sentry, OpenTelemetry via module.register()) every resolution is a synchronous round trip to the hooks thread. On a production app with Sentry instrumentation this path alone was about 17% of process CPU.

Counted with diagnostics_channel.tracingChannel("module.import") inside the prod server of app-basic, 120 requests across 4 routes:

module before after
config-matchers 720 0
config-headers 150 0
metadata-route-response thunk 120 0
app-page-cache 60 0
file-based-metadata thunk 60 0
loadModule("ssr", "index") 60 0
app-route-handler-dispatch thunk 30 0
headers.js via connection() 30 0
total 1230 (~10 per request) 0

Internal framework modules are memoized unconditionally (safe in dev: when the importer is invalidated its module scope cache resets with it). The import.meta.viteRsc.loadModule("ssr" | "rsc", "index") sites bundle user code, so those are memoized only when process.env.NODE_ENV === "production", keeping the per call runner import that dev HMR relies on. headers.js stays a dynamic import on purpose (the existing use cache constraint), only the promise is cached.

Tests

entry-templates (codegen assert updated accordingly), shims, app-router-production-server, app-router-production-build, app-flight-framing-production, app-router-dev-server, dev-route-discovery, vite-hmr-websocket, app-page-dispatch, app-page-render, metadata-route-build-data and pages-router suites pass, and tsc is clean.

@pkg-pr-new

pkg-pr-new Bot commented Jul 21, 2026

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/@vinext/cloudflare@2663
npm i https://pkg.pr.new/create-vinext-app@2663
npm i https://pkg.pr.new/@vinext/types@2663
npm i https://pkg.pr.new/vinext@2663

commit: de072a0

@github-actions

Copy link
Copy Markdown
Contributor

Performance benchmarks

Compared de072a0 against base 2824b76 using alternating same-runner rounds. Next.js was unchanged and skipped.

0 improved · 3 regressed · 3 within ±1.5%

Scenario Framework Baseline Current Change
Client bundle size (gzip) vinext 130.6 KB 130.6 KB ⚫ +0.0%
Client entry size (gzip) vinext 118.1 KB 118.1 KB ⚫ +0.0%
Dev server cold start vinext 2.27 s 2.29 s ⚫ +0.8%
Production build time vinext 2.46 s 2.50 s 🔴 +1.7%
RSC entry closure size (gzip) vinext 101.7 KB 105.6 KB 🔴 +3.8%
Server bundle size (gzip) vinext 169.0 KB 181.3 KB 🔴 +7.3%

View detailed results and traces

🟢 improvement · 🔴 regression · ⚫ change below 1.5% · paired base/head

@james-elicx

Copy link
Copy Markdown
Member

Looks like there's a regression in bundle size.

@james-elicx
james-elicx marked this pull request as draft July 21, 2026 21:05
jlucaso1 and others added 5 commits July 21, 2026 19:48
…ng 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).
…ng 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).
@jlucaso1
jlucaso1 force-pushed the fix/client-constants-and-per-request-imports branch from 0c77071 to c796f39 Compare July 21, 2026 22:50
@jlucaso1
jlucaso1 marked this pull request as ready for review July 21, 2026 22:50
@jlucaso1

Copy link
Copy Markdown
Author

Looks like there's a regression in bundle size.

Nice catch, I've submitted this commit to mitigate that: c796f39

Also rebased the PR. Thank you

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: c796f39890

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread packages/vinext/src/entries/app-rsc-entry.ts Outdated
jlucaso1 added 2 commits July 21, 2026 22:53
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

2 participants