Skip to content

fix(pages): match ESM externals behavior#2237

Open
james-elicx wants to merge 2 commits into
mainfrom
codex/fix-esm-externals-parity-27858251903
Open

fix(pages): match ESM externals behavior#2237
james-elicx wants to merge 2 commits into
mainfrom
codex/fix-esm-externals-parity-27858251903

Conversation

@james-elicx

Copy link
Copy Markdown
Member

Summary

  • externalize native-loadable ESM dependencies in Pages production builds
  • preserve nested package ownership and import/require condition semantics
  • honor Next.js Pages bundling controls and transpile precedence

Validation

  • vp test run tests/esm-externals.test.ts tests/next-config.test.ts — 214 passed
  • scoped vp check and git diff --check
  • independent cumulative review: clean

Next.js parity

Targets the failures in test/e2e/esm-externals/esm-externals.test.ts from deploy-suite run 27858251903. Investigation and reproduction used vinext-owned fixtures; no raw Next.js E2E was run locally.

@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

@pkg-pr-new

pkg-pr-new Bot commented Jun 21, 2026

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/@vinext/cloudflare@2237
npm i https://pkg.pr.new/vinext@2237

commit: a3b3b2d

@github-actions

Copy link
Copy Markdown
Contributor
Example Preview Production Original
app-router-cloudflare preview production
pages-router-cloudflare preview production
app-router-playground preview production original
realworld-api-rest preview production
nextra-docs-template preview production
benchmarks preview production
hackernews preview production original
workers-cache preview production
web preview production

@ask-bonk ask-bonk Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Review: fix(pages): match ESM externals behavior

Reviewed the new pages-node-externals plugin against the Next.js reference (build/handle-externals.ts, build/webpack-config.ts). The approach faithfully ports Next.js's resolveExternal semantics (import/require condition selection, base-resolve check for nested versions, strict-require throw, transpile/bundle opt-outs). Tests pass locally (tests/esm-externals.test.ts + tests/next-config.test.ts, 214 passed) and vp check is clean on the changed files. The transpilePackagesserverExternalPackages conflict error string matches Next.js verbatim. Nice work.

A few issues worth considering before merge — none are blockers, but the first is a real parity gap.

1. Strict-require throw is bypassed by bundlePagesRouterDependencies / transpilePackages (parity gap)

In Next.js, the "ESM packages need to be imported" error is thrown before the bundling decision, so require() of an ESM-only package throws even when bundlePagesRouterDependencies: true or the package is in transpilePackages:

// handle-externals.ts
// ESM externals can only be imported (and not required).
if (!isEsmRequested && isEsm && !looseEsmExternals && !isLocal) {
  throw new Error(`ESM packages (${request}) need to be imported. ...`)
}
// ... bundlePagesRouterDependencies / transpilePackages handling happens AFTER this

In pages-node-externals.ts, the early return at line 167 short-circuits the require-request handling (lines 187–199), so vinext silently bundles instead of throwing:

if (options.getEsmExternals() === false || options.getBundlePagesRouterDependencies()) {
  return null;   // <-- returns before the strict-require throw
}
// ... transpilePackages also returns null below, before the throw

So require("esm-only-pkg") throws in vinext only when none of esmExternals:false / bundlePagesRouterDependencies:true / transpilePackages apply, whereas Next.js throws regardless (except in loose mode / for local requests). Worth either matching Next.js (evaluate the strict-require throw first) or documenting this as a deliberate divergence per AGENTS.md.

2. transform hook has no native filter (perf)

AGENTS.md (Performance Best Practices) calls out filtering Vite hooks before JS runs. The transform handler runs for every non-client module during build and does a code.includes("require") scan on each:

transform: {
  order: "pre",
  handler(code, id) {
    if (!isBuild || !options.isEnabled() || this.environment?.name === "client") return null;
    if (!code.includes("require")) return null;
    ...

Consider a native filter (e.g. on code: { include: /\brequire\s*\(/ } and/or excluding node_modules that can't contain bare require to packages) so the handler doesn't run on every module. Minor, but it's exactly the pattern the repo guidelines flag.

3. hasNodeUnsupportedRelativeImport only scans the entry file (heuristic limitation)

canNodeImport(entry) && !hasNodeUnsupportedRelativeImport(entry) only inspects the resolved entry module, not its transitive relative imports. A native-ESM package whose entry imports ./foo.js (with extension) but where ./foo.js imports ./bar (no extension) would be externalized and then fail at Node runtime. This matches the practical risk surface for most packages, but it's a shallow check — fine to leave, worth a comment noting the limitation.

4. MODULE_SPECIFIER_RE misses minified export{...}from"..." (edge)

The regex requires whitespace before from, so export{a}from"./rel" (no space, as emitted by some minifiers) isn't matched:

"export {a}from\"./nospace\"" => []   // not detected

Consequence: a minified native-ESM package using extensionless re-exports could be falsely externalized. Very narrow, but the \s+...from\s* requirement could be relaxed to \s*from\s* after a }/identifier.

Minor

  • resolveExternal's base-resolve check compares both baseRes !== res and isEsm !== baseIsEsm; the root-resolution check here (lines 218–228) only compares the resolved path, not ESM-ness. Unlikely to matter in practice.
  • getTranspilePackages().includes(packageName) matches only by package name, whereas Next.js's isResourceInPackages resolves package dirs (catches transitively-located resources). Acceptable simplification.

Overall the change is well-tested and closely tracks Next.js. Item 1 is the one I'd most want addressed (or explicitly documented) before merge.

@ask-bonk

ask-bonk Bot commented Jun 21, 2026

Copy link
Copy Markdown
Contributor

Review posted on PR #2237.

Summary

I reviewed PR #2237 (fix(pages): match ESM externals behavior) against the Next.js reference implementation (build/handle-externals.ts, build/webpack-config.ts), ran the PR's tests (214 passed), and ran vp check (clean).

The implementation is solid and closely ports Next.js's resolveExternal semantics. I flagged the following:

  1. Parity gap (main concern): The strict "ESM packages need to be imported" throw is bypassed when bundlePagesRouterDependencies: true, esmExternals: false, or the package is in transpilePackages — because the early return null at line 167 short-circuits the require-request handling. In Next.js the throw happens before the bundling decision, so require() of an ESM-only package throws regardless. Should be matched or documented as deliberate.

  2. Perf: The transform hook lacks a native filter and runs code.includes("require") on every non-client module — exactly the pattern AGENTS.md flags.

  3. Heuristic limitation: hasNodeUnsupportedRelativeImport only scans the resolved entry, not transitive relative imports.

  4. Regex edge: MODULE_SPECIFIER_RE misses minified export{...}from"..." (no space before from), which could falsely externalize a minified native-ESM package.

Plus two minor notes (ESM-ness not compared in the root-resolution check; transpilePackages matched by name only). The transpilePackages/serverExternalPackages conflict error matches Next.js verbatim, and config-level test coverage is good.

github run

@github-actions

Copy link
Copy Markdown
Contributor

Performance benchmarks

Compared a3b3b2d against base cf9d601 using alternating same-runner rounds. Next.js was unchanged and skipped.

1 improved · 1 regressed · 4 within ±1.5%

Scenario Framework Baseline Current Change
Client bundle size (gzip) vinext 132.8 KB 132.8 KB ⚫ -0.0%
Client entry size (gzip) vinext 118.7 KB 118.7 KB ⚫ -0.0%
Dev server cold start vinext 2.35 s 2.31 s 🟢 -1.9%
Production build time vinext 2.80 s 2.88 s 🔴 +2.7%
RSC entry closure size (gzip) vinext 92.2 KB 92.2 KB ⚫ +0.0%
Server bundle size (gzip) vinext 166.9 KB 166.9 KB ⚫ +0.0%

View detailed results and traces

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

@james-elicx james-elicx marked this pull request as ready for review June 21, 2026 19:29
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant