Skip to content

fix(build): preserve require export conditions - #2881

Open
james-elicx wants to merge 7 commits into
mainfrom
codex/fix-client-module-package-type-31439707085
Open

fix(build): preserve require export conditions#2881
james-elicx wants to merge 7 commits into
mainfrom
codex/fix-client-module-package-type-31439707085

Conversation

@james-elicx

@james-elicx james-elicx commented Aug 11, 2026

Copy link
Copy Markdown
Member

Summary

  • preserve the require export condition when vite-plugin-commonjs rewrites package require calls
  • give divergent require targets a synthetic identity so CJS conversion and plugin-RSC handling still run
  • preserve source module types, normalize query-only resolution differences, and keep stale-load failures contextual
  • cover aliases, package exports, shadowed require bindings, nested requires, dev resolution, and production builds

Failure mapping

This fixes the two non-cache failures in Next.js deploy run 31439707085, Test report job 93624401572:

  • esm-client-module-without-exports > type: commonjs > should render without errors: require cjs
  • esm-client-module-without-exports > type: module > should render without errors: require esm

Validation

  • Next.js v16.2.6 targeted deploy E2E: test/e2e/app-dir/client-module-with-package-type/index.test.ts — 4/4 passed
  • real Vite dev integration: import-cjs, require-cjs, import-esm, require-esm — 4/4 passed
  • production build/server integration: import-cjs, require-cjs, import-esm, require-esm — 4/4 passed
  • focused vinext tests — 20/20 passed
  • independent cumulative re-review at 8c128b8 — no findings
  • vp check
  • git diff --check

Existing PR #2334 was not adopted because it conflicts with main.

@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

@pkg-pr-new

pkg-pr-new Bot commented Aug 11, 2026

Copy link
Copy Markdown

Open in StackBlitz

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

commit: a5fc4d1

@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(build): preserve require export conditions

Solid, well-factored fix. The core approach — pre-resolving literal package require() calls with Vite's explicit isRequire: true resolver before vite-plugin-commonjs hoists them into static imports — is correct, and the plugin-ordering rationale (pre-phase rewrite → normal-phase commonjs hoist) holds. The scope-aware require binding detection reuses the existing ast-scope/ast-utils helpers cleanly, and the unit tests cover the important cases (package vs relative/external/unresolved, shadowed require, independent import/require branches, and the synthetic-identity load path). Nothing here blocks merge, but a few things are worth addressing.

1. No production-build coverage (dev/prod parity)

Per AGENTS.md, request/build behavior must be verified in both dev and prod. The new tests/cjs.test.ts "conditional package exports" suite only drives startFixtureServer (dev). The whole point of this plugin is a build-time transform ordering fix, so a production build is the higher-risk path — createBuilder().buildApp() runs the multi-environment RSC/SSR/client pipeline where the \0-prefixed virtual-id re-import quirks and plugin-rsc client-reference registration behave differently than dev.

tests/helpers.ts already exposes buildAppFixture. Please add a prod-build assertion for at least the require-cjs / require-esm cases so a future regression in build mode is caught. The PR's "targeted Next.js deploy E2E" is reassuring but isn't a repo test that CI will re-run.

2. load() has no error handling and assumes moduleType: "js"

async load(id) {
  const target = virtualTargets.get(id);
  if (!target) return;
  const file = target.split("?", 1)[0];
  this.addWatchFile(file);
  return { code: await readFile(file, "utf8"), moduleType: "js" };
}

Two edge cases:

  • The gate only checks path.isAbsolute(...) on the require resolution. If a resolver returns an absolute id that is a virtual/proxy id (or a ?commonjs-proxy-style id), readFile throws an unhandled error, and stripping the query (target.split("?", 1)[0]) reads the base file — which may not be the content the resolver actually selected. Worth either validating the target is a real file before registering it in transform, or wrapping the read and falling back to leaving the require untouched.
  • A package's require condition can legitimately point at a .json entry, in which case forcing moduleType: "js" is wrong. Narrow, but consider deriving the module type from the target extension.

3. virtualTargets grows unbounded

The Map is module-scoped and never pruned across the dev server lifetime. In practice it's bounded by the number of distinct divergent packages so it's not a real leak, but it's shared across all environments (RSC/SSR/client) via the single plugin instance. That sharing is actually fine (keys are absolute-path-derived, so all environments resolve to the same target), but a one-line comment noting the intentional cross-environment sharing would help the next reader, since it looks surprising next to the "RSC and SSR are separate module graphs" gotcha in AGENTS.md.

4. Divergence check compares full resolutions including query

requireResolution === importResolution

If require/import resolve to the same file but with differing query suffixes (e.g. ?v=), this treats them as divergent and mints an unnecessary synthetic module. Comparing on the query-stripped path (as you already do for the path.isAbsolute check) would avoid that. Minor, but cheap to tighten.

Nits

  • TRANSFORMABLE_ID_RE + LITERAL_REQUIRE_RE will re-run this transform on the loaded synthetic module itself, recursively handling nested require()s. That looks intentional/correct, but it's implicit — a brief comment would confirm it's by design rather than accidental.

Overall: correct fix, good tests for the unit surface. The main thing I'd want before merge is a production-build assertion (#1); the rest are hardening.

@github-actions

github-actions Bot commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

Performance benchmarks

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

0 improved · 0 regressed · 6 within ±1.5%

Scenario Framework Baseline Current Change
Client bundle size (gzip) vinext 136.2 KB 136.2 KB ⚫ -0.0%
Client entry size (gzip) vinext 123.6 KB 123.6 KB ⚫ -0.0%
Dev server cold start vinext 2.49 s 2.50 s ⚫ +0.7%
Production build time vinext 2.61 s 2.63 s ⚫ +0.4%
RSC entry closure size (gzip) vinext 114.9 KB 114.9 KB ⚫ -0.0%
Server bundle size (gzip) vinext 195.4 KB 195.3 KB ⚫ -0.0%

View detailed results and traces

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

@ask-bonk

ask-bonk Bot commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

Review posted to PR #2881.

Summary

I reviewed PR #2881 (fix(build): preserve require export conditions). The core approach is correct and well-factored: pre-resolving literal package require() calls with Vite's explicit isRequire: true resolver, then giving divergent require targets a synthetic .vinext-require.js identity so vite-plugin-commonjs and plugin-rsc still process them. Plugin ordering (pre-phase rewrite → normal-phase commonjs hoist) holds, and the scope-aware require binding detection reuses existing AST helpers cleanly. Unit tests pass locally (6/6) and vp check is clean on the changed files.

Findings I posted (none blocking, but #1 is the main ask before merge):

  1. No production-build coverage — the new tests/cjs.test.ts suite only exercises dev via startFixtureServer, yet this is fundamentally a build-time transform-ordering fix. AGENTS.md requires dev/prod parity, and buildAppFixture already exists to add a prod assertion for require-cjs/require-esm.
  2. load() lacks error handling and hardcodes moduleType: "js" — an absolute-but-virtual/proxy resolution would throw on readFile, and a .json require entry would be mistyped.
  3. virtualTargets grows unbounded and is shared across environments — fine in practice, but worth a comment given the "separate module graphs" gotcha.
  4. Divergence check compares full resolutions including query strings, which can mint unnecessary synthetic modules; comparing query-stripped paths would tighten it.

Plus a nit about the transform recursively re-processing the synthetic module (looks intentional, deserves a comment).

github run

@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

@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.

Re-review: fix(build): preserve require export conditions

Re-reviewed at 8c128b8. The four findings from the previous review plus the nit have all been addressed, and I did an independent pass over the new hardening. No blocking issues; the fix is correct and well-tested.

Prior findings — resolved

  1. Prod-build coverage (was the main ask):tests/cjs.test.ts now runs buildAppFixture(root) + startProdServer(...) and asserts all four conditional-export cases (import-cjs/require-cjs/import-esm/require-esm) in both dev and production (CONDITIONAL_EXPORT_CASES × two it.each blocks). This is the higher-risk path for a build-time transform-ordering fix, so this closes the dev/prod-parity gap. Verified locally: 11/11 pass.
  2. load() error handling + module type:load() now wraps readFile and defers ENOENT/EISDIR to Vite's contextual loader (rethrowing anything else), and moduleType is derived from the target extension via syntheticModuleType (js/jsx/json/ts/tsx). New unit tests cover the JSON-typing and stale-target-deferral paths.
  3. virtualTargets cross-env sharing: ✅ Comment added (lines 219–222) explaining the map is intentionally shared across RSC/SSR/client and bounded by distinct conditional targets.
  4. Divergence check ignoring query: ✅ Now compares stripViteModuleQuery(requireResolution) vs the query-stripped import path, with a dedicated "ignores query-only differences" test.

Nit (recursive re-processing of the synthetic module) is also now documented at lines 265–266.

Independent verification of the new code

  • Nested requires in the synthetic module resolve correctly. When the .vinext-require.js module re-enters transform, the importer id is the synthetic path, but it shares the real target's directory, so node_modules resolution walks the same tree. .vinext-require.json synthetic ids are correctly excluded from re-transform by TRANSFORMABLE_ID_RE (no .json), so JSON is never parsed as JS.
  • commonjsTransformFilter gating is sound. It returns true only for .vinext-require.{js,jsx,ts,tsx} ids (forcing CJS conversion of the synthetic module) and preserves the existing false skip for project-local .cjs/.cts. JSON synthetic ids fall through to undefined, which is correct.
  • Shadowed-require scope detection is correct for the CJS-wrapper case: a free require in a CJS module has no lexical binding, so it's rewritten; a const require = ... at module scope is captured by collectDirectScopeBindings and left alone — matching webpack/Next.js static-analysis behavior.
  • Wrapper preservation: unwrapExpression + overwrite(argument.start, argument.end, ...) replaces only the inner literal, so require(("lib")) / require("lib" as string) keep their wrappers intact.

vp check is clean on the changed files; full CI (Check, all Vitest shards, all E2E) is green.

Optional follow-ups (non-blocking)

  • The recursive-re-processing behavior is covered indirectly by integration tests but has no direct unit test for a nested divergent require inside a synthetic module. If you want to lock in that contract, a focused unit test (synthetic module whose CJS body itself require()s a second divergent package) would be cheap insurance.
  • virtualTargets is still never pruned across a long-lived dev server. As noted, it's bounded by distinct divergent packages so it's not a practical leak — leaving as-is is fine.

LGTM.

@ask-bonk

ask-bonk Bot commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

Review posted to PR #2881.

Summary

I re-reviewed PR #2881 (fix(build): preserve require export conditions) at commit 8c128b8. The core approach — pre-resolving literal package require() calls with Vite's isRequire: true resolver before vite-plugin-commonjs hoists them into static imports, then minting a synthetic .vinext-require.* identity for divergent require/import targets — is correct and well-factored.

All four prior-review findings + the nit are resolved:

  1. Production-build coverage added (buildAppFixture + startProdServer, all 4 conditional-export cases in both dev and prod) — the key ask.
  2. load() now has error handling (defers ENOENT/EISDIR to Vite) and derives moduleType from the target extension (JSON test added).
  3. Comment documenting intentional cross-environment virtualTargets sharing.
  4. Divergence check now strips queries before comparing.

My independent checks (no issues):

  • Nested requires inside the synthetic module resolve correctly (shared directory → same node_modules walk); .json synthetics are correctly excluded from re-transform.
  • commonjsTransformFilter gating is sound (forces CJS conversion only for script synthetics, preserves local .cjs skip).
  • Shadowed-require scope detection matches webpack/Next.js behavior.
  • Wrapper preservation (as/parens) is correct.

Verified locally: unit tests 9/9, cjs integration 11/11, vp check clean. Full CI is green.

Two optional non-blocking follow-ups noted (a direct unit test for nested divergent requires; unbounded-but-harmless virtualTargets). LGTM.

github run

@james-elicx
james-elicx marked this pull request as ready for review August 11, 2026 02:01
@james-elicx

Copy link
Copy Markdown
Member Author

Independent targeted Next.js E2E verification on immutable head 8c128b87ddc7cbf6da9a3177dd5b26570f243b82:

  • Suite: test/e2e/app-dir/client-module-with-package-type/index.test.ts
  • Command: required scripts/run-targeted-nextjs-e2e.sh wrapper
  • Result: PASS — 4 passed, 0 skipped, 0 failed (4 total)
  • Retries: 0/0

The checkout remained clean and the local/remote PR head SHA matched after the run.

@james-elicx

Copy link
Copy Markdown
Member Author

Independent full-diff review of immutable head 8c128b87ddc7cbf6da9a3177dd5b26570f243b82: NO FINDINGS.

I reviewed all four changed files independently of Big Bonk, covering literal package require() detection, Vite require/import condition resolution, synthetic module identity and loading, CommonJS/plugin-RSC ordering, package-type behavior, and dev/production tests.

Correctness/parity checks:

  • the pre-plugin resolves literal package requires with Vite isRequire: true before vite-plugin-commonjs hoists them into imports;
  • rewriting is limited to unbound literal package requires whose query-free require and import targets genuinely diverge;
  • the scope walk accounts for module, function, block, loop, switch, catch, class-expression, and TypeScript wrapper scopes;
  • the synthetic identity keeps the selected target in the same directory for relative/nested resolution, preserves JS/JSX/TS/TSX/JSON module typing, watches the real file, and lets Vite contextualize stale-load errors;
  • only synthetic script targets override the CommonJS filter; ordinary local and dependency CJS behavior remains unchanged;
  • nested package requires are reprocessed deliberately, preserving their own require conditions across RSC, SSR, and client graphs.

Independent validation on this exact SHA:

  • focused unit and real Vite dev/production integration suites: 20/20 passed
  • previously posted exact Next.js test/e2e/app-dir/client-module-with-package-type/index.test.ts proof: 4/4 passed, retry 0/0 — fix(build): preserve require export conditions #2881 (comment)
  • scoped format/lint/types and git diff --check: clean

The local and remote heads still match, the worktree is clean, all checks are green, and the PR is mergeable.

@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

@ask-bonk

ask-bonk Bot commented Aug 16, 2026

Copy link
Copy Markdown
Contributor

UnknownError: ProviderInitError

github run

@ask-bonk

ask-bonk Bot commented Aug 16, 2026

Copy link
Copy Markdown
Contributor

@james-elicx Bonk workflow failed. Check the logs for details.

View workflow run · To retry, trigger Bonk again.

@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

@ask-bonk

ask-bonk Bot commented Aug 16, 2026

Copy link
Copy Markdown
Contributor

UnknownError: ProviderInitError

github run

@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

@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(build): preserve require export conditions

Reviewed at merged head 78f5a5a (PR base main). The core mechanism — pre-resolving literal package require() calls with Vite's explicit isRequire: true resolver before vite-plugin-commonjs hoists them into static imports, then minting a synthetic .vinext-require.* identity for divergent require/import targets so CJS conversion and plugin-rsc still run — is correct and well-factored. Plugin ordering holds: createRequireConditionResolutionPlugin() is enforce: "pre" and registered before commonjs(...), while @vitejs/plugin-rsc is pushed last, so the pre-resolution runs first, the commonjs hoist next, and plugin-rsc's ESM proxy injection last.

I independently re-verified the four prior findings and the nit are resolved, and did a fresh pass over the current code. No blocking issues; LGTM.

Verification

  • Unit tests: tests/require-condition-resolution.test.ts — 9/9 pass.
  • Integration (dev + prod): tests/cjs.test.ts — 11/11 pass, all four conditional-export cases (import-cjs/require-cjs/import-esm/require-esm) in both dev and production.
  • vp check on both changed source/test files — clean (format, lint, types).

Independent correctness checks (all sound)

  • moduleType typing. load() returns "js" | "jsx" | "ts" | "tsx" | "json". Rolldown's SourceDescription.moduleType (the load-hook type) is "js" | "jsx" | "ts" | "tsx" | "json" | ..., so all five are valid — vp check confirms. (Note this differs from the napi ResolveResult.ModuleType union, which is the narrower 'module' | 'commonjs' | 'json' | 'wasm' | 'addon'; the load hook uses the wider one.)
  • Builtin/self requires are skipped. require("fs") resolves to "fs" and is caught by requireResolution === specifier; require("node:fs") is excluded by the protocol regex in isPackageSpecifier. Windows drive-absolute (C:\...) is also excluded by the same regex.
  • Nested require resolution. When the synthetic <abs>.vinext-require.js re-enters transform, the importer id shares the real target's directory, so node_modules resolution walks the correct tree; the code filter (\brequire\s*\() only re-triggers when the synthetic body actually contains a require(. .json synthetics are excluded from re-transform by TRANSFORMABLE_ID_RE.
  • commonjsTransformFilter gating. Returns true only for .vinext-require.{js,jsx,ts,tsx} ids, preserves the existing false skip for project-local .cjs/.cts, and lets everything else fall through to undefined.
  • createIdResolver container. Only runs alias + Vite's own resolver, so it cannot emit ?commonjs-proxy/virtual ids — the theoretical "readFile reads the base file, not the resolved proxy content" concern from the first review is not reachable here.
  • Scope-aware require detection reuses the existing ast-scope helpers and correctly leaves lexically-bound require untouched (unit-tested).

Non-blocking observations (optional)

  1. .mjs/.mts require targets are forced through the CJS filter. syntheticModuleType maps .mts"ts", so an ESM require-condition target yields a .vinext-require.ts id that commonjsTransformFilter forces to true (CJS conversion). In practice divergent-require targets are CJS, and vite-plugin-commonjs passes already-ESM source through, so this is harmless — but if you ever hit a package whose require condition points at genuine ESM, worth a note that the filter forces conversion regardless of the target's actual module format.
  2. virtualTargets still unbounded across a long-lived dev server (bounded by distinct divergent packages; already documented as intentional — fine to leave).
  3. No direct unit test for a nested divergent require inside a synthetic module. Covered indirectly by integration, but a focused unit test would lock in the contract cheaply (already noted as an optional follow-up in the prior review).

Overall: correct fix, good dev/prod coverage, prior findings resolved.

@ask-bonk

ask-bonk Bot commented Aug 16, 2026

Copy link
Copy Markdown
Contributor

Review posted to PR #2881.

Summary

I reviewed PR #2881 (fix(build): preserve require export conditions) at merged head 78f5a5a. The core mechanism — pre-resolving literal package require() calls with Vite's isRequire: true resolver before vite-plugin-commonjs hoists them into static imports, then minting a synthetic .vinext-require.* identity for divergent require/import targets — is correct and well-factored. Plugin ordering holds (enforce: "pre"commonjs()@vitejs/plugin-rsc last).

No blocking issues; LGTM. All four prior review findings plus the nit are resolved, and my independent pass found no defects.

Verification:

  • Unit tests 9/9, dev+prod integration 11/11, vp check clean on changed files.

Independent correctness checks (all sound):

  • moduleType values (js/jsx/ts/tsx/json) are valid for the load-hook SourceDescription.moduleType type (verified against Rolldown's type defs — the wider union, not the narrower napi one).
  • Builtin/node:/Windows-drive specifiers are correctly excluded.
  • Nested require resolution works (synthetic id shares the real target's directory).
  • commonjsTransformFilter gating is sound; createIdResolver can't emit proxy ids so the theoretical stale-proxy read isn't reachable.

Non-blocking observations noted: .mjs/.mts require targets get forced through the CJS filter (harmless in practice), unbounded-but-bounded virtualTargets, and the optional direct unit test for nested divergent requires.

github run

@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

@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(build): preserve require export conditions

Reviewed at head ce61c6e. The core mechanism — pre-resolving literal package require() calls with Vite's explicit isRequire: true resolver before vite-plugin-commonjs hoists them into static imports, then minting a synthetic .vinext-require.* identity for divergent require/import targets — is correct and well-factored. Plugin ordering holds (enforce: "pre" require-condition plugin → commonjs() → plugin-rsc last), and the scope-aware require binding detection reuses the existing ast-scope/ast-utils helpers cleanly.

I independently re-verified that the four prior findings + the nit are resolved, and did a fresh pass over the current code. No blocking issues.

Verification

  • Unit: tests/require-condition-resolution.test.ts — 9/9 pass.
  • Integration (dev + prod): tests/cjs.test.ts — 11/11 pass, all four conditional-export cases in both dev and production.
  • vp check clean on all three changed files (format, lint, types).

Independent correctness checks (all sound)

  • Divergence gating. importResolution === undefined with a resolved requireResolution correctly mints a synthetic (they genuinely diverge). Query-only differences are stripped before comparison, so ?import/?require suffixes on the same file don't mint a spurious module.
  • Nested resolution. The synthetic id ${requirePath}.vinext-require.js shares the real target's directory, so re-entrant node_modules walks resolve correctly; .json synthetics are excluded from re-transform by TRANSFORMABLE_ID_RE.
  • require.resolve(...) / member-callee and lexically-bound require are correctly left untouched (member callee isn't Identifier "require"; scope walk covers function/block/loop/switch/catch/class-expression/TS wrappers).
  • Specifier classification. #imports, scoped (@scope/pkg), subpath (lib/sub), node:-protocol, and Windows drive specifiers are all handled/excluded correctly by isPackageSpecifier.
  • Graceful parse degradation. A .js/.cjs file with TS-generic-call-shaped syntax (foo<Bar>()) fails parseAst({ lang: "jsx" }), but collectLiteralRequires swallows the throw and returns [], leaving the require untouched (falls back to Vite default) rather than crashing.

Non-blocking observations

  1. Transform has no node_modules guard (performance). The transform hook filters on id: TRANSFORMABLE_ID_RE + code: /\brequire\s*\(/, but does not exclude node_modules. Many transpiled dependencies contain the require( substring, so this adds a full parseAst + scope walk to a potentially large number of dependency modules on every dev request and every build. This matches the AGENTS.md performance guidance ("avoid broad compatibility transforms over node_modules… and modules that cannot contain the syntax being rewritten") and is a plausible contributor to the +5.2% production build-time regression flagged in the benchmark comment (single-run, so partly noise, but directionally consistent). The transform genuinely must run on some node_modules files (that's where conditional-export require()s live), so a blanket exclusion isn't right — but it's worth confirming the added parse cost is acceptable, and considering whether prebundled/optimized deps can be skipped.

  2. ESM require-condition target forced through the CJS filter. syntheticModuleType maps .mjsjs/.mtsts, so a require condition that points at genuine ESM yields a .vinext-require.{js,ts} id that commonjsTransformFilter forces to true (CJS conversion). Harmless in practice — divergent-require targets are CJS, and vite-plugin-commonjs passes already-ESM source through — but the filter forces conversion regardless of the target's actual module format.

  3. virtualTargets is unbounded across a long-lived dev server (bounded by distinct divergent packages; already documented as intentional — fine to leave).

  4. No direct unit test for a nested divergent require inside a synthetic module. Covered indirectly by the dev/prod integration cases; a focused unit test would lock in the recursive-reprocessing contract cheaply.

Overall: correct fix, good dev/prod coverage, prior findings resolved. The only item I'd flag for a look before merge is the transform's performance scope (#1); the rest are optional.

@ask-bonk

ask-bonk Bot commented Aug 16, 2026

Copy link
Copy Markdown
Contributor

Review posted to PR #2881.

Summary

I reviewed PR #2881 (fix(build): preserve require export conditions) at head ce61c6e. The core mechanism — pre-resolving literal package require() calls with Vite's isRequire: true resolver before vite-plugin-commonjs hoists them into static imports, then minting a synthetic .vinext-require.* identity for divergent require/import targets — is correct and well-factored. Plugin ordering holds and prior review findings are all resolved.

No blocking issues. Verified locally: unit 9/9, dev+prod integration 11/11, vp check clean.

The one new observation worth a look before merge:

  1. Transform has no node_modules guard (performance). The transform hook gates on id/code filters but doesn't exclude node_modules, so it AST-parses + scope-walks every dependency module containing the require( substring. This matches the AGENTS.md warning against broad node_modules transforms and is directionally consistent with the +5.2% prod build-time regression in the benchmark comment. A blanket exclusion isn't correct (conditional-export requires live in node_modules), but the added parse cost is worth confirming.

Plus three optional non-blocking items: ESM require-targets forced through the CJS filter (harmless), unbounded virtualTargets (documented as intentional), and a missing direct unit test for nested divergent requires.

My independent correctness checks — divergence gating, nested resolution, require.resolve/member-callee/lexical-binding exclusion, specifier classification, and graceful parse degradation — all came back sound.

github run

@james-elicx

Copy link
Copy Markdown
Member Author

Final exact-head refresh validation:

  • Head: ce61c6ed97995878950bb2cf0cb51b35f84610e4 (merge commit with exact current main a5f0bb44cb0d7590430924c9a6ba949e8082bd01)
  • Targeted Next.js E2E: test/e2e/app-dir/client-module-with-package-type/index.test.ts4/4 passed
  • Local require-condition/CJS dev+production coverage: 20/20 passed; related server-condition/ESM-external coverage clean; package build and vp check clean
  • Fresh Big Bonk: no blocking issues
  • Deploy suite: run 31956545731, report vinextRef matches exact head, complete 3,435 denominator — 2,680 passed / 124 failed / 631 skipped

Against baseline run 31915451405, both intended client-module-with-package-type conditional-require failures are fixed. The only pass→fail identity is navigation > hash-with-scroll-offset > should scroll to the specified hash, which is established cross-PR flake noise (also failed unrelated #2876/#2879 deploys while passing other baseline/stack runs). No regression is attributable to this PR.

@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

@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(build): preserve require export conditions

Re-reviewed at head a5fc4d1, which adds one new commit since the last pass — perf(build): skip ineligible require condition scans. That commit directly addresses the performance finding (#1) from the prior review (the transform having no node_modules guard). No blocking issues; LGTM.

The new guard is correct, not just faster

The new commit threads commonjsTransformFilter into the require-condition plugin and gates the transform:

const commonjsDisposition = commonjsTransformFilter?.(cleanId);
if (
  commonjsDisposition === false ||
  (commonjsDisposition !== true && cleanId.includes("node_modules"))
) {
  return null;
}

I verified this is a faithful mirror of vite-plugin-commonjs@0.10.4's own transform gate (dist/index.js:410-413):

if (userCondition === false) return;
if (userCondition !== true && id.includes("node_modules")) return;

This is the key correctness point: there is no value in pre-resolving a require's export condition unless vite-plugin-commonjs is actually going to hoist that require() into a static import (which is what flips the resolution from require-kind to import-kind). By gating on the exact same predicate, the plugin now rewrites requires in precisely the set of files the commonjs pass will process — no more, no less. Ordinary node_modules files fall through to vite-plugin-commonjs's own skip, where Vite/Rolldown already preserves require conditions natively. So this is a safe narrowing, not a behavioral change to the fix.

The recursive path is preserved: a synthetic .vinext-require.js id returns true from the filter (isConditionalRequireScriptModuleId), so the commonjsDisposition !== true clause short-circuits and nested divergent requires inside the synthetic still recurse — even though the synthetic id itself lives under node_modules. The new "rewrites nested package requires in synthetic targets" unit test locks this contract in, which also closes the prior optional follow-up (#4) about missing direct coverage for nested synthetic requires.

The top-level fixture requires (app/require-cjs/page.tsx, app/require-esm/page.tsx) are first-party files (not in node_modules), so the guard does not touch them — confirmed by the still-passing dev+prod integration cases.

Windows / slash handling

cleanId here is stripViteModuleQuery(id) without toSlash, but that's fine: .includes("node_modules") is slash-direction-agnostic (the substring survives either separator), and vite-plugin-commonjs's upstream gate is likewise unnormalized. commonjsTransformFilter internally toSlashes before its own regex checks, so the disposition value is consistent across platforms.

Verification (at a5fc4d1)

  • tests/require-condition-resolution.test.ts13/13 pass (was 9; +4 for the node_modules skip, project-.cjs/.cts skip, and nested-synthetic cases).
  • tests/cjs.test.ts11/11 pass, all four conditional-export cases in both dev and production.
  • vp check on all four changed files — clean (format, lint, types).

Non-blocking observations (unchanged, optional)

  1. ESM require-condition target forced through the CJS filter. syntheticModuleType maps .mjsjs/.mtsts, so a require condition pointing at genuine ESM yields a .vinext-require.{js,ts} id that commonjsTransformFilter forces to true. Harmless in practice (divergent-require targets are CJS, and vite-plugin-commonjs passes already-ESM source through), but the filter forces conversion regardless of the target's actual module format.
  2. virtualTargets unbounded across a long-lived dev server (bounded by distinct divergent packages; documented as intentional — fine to leave).

Overall: the new commit resolves the last flagged item cleanly by aligning the pre-resolution scope with the downstream commonjs gate. Correct fix, good dev/prod coverage, all prior findings resolved.

@ask-bonk

ask-bonk Bot commented Aug 16, 2026

Copy link
Copy Markdown
Contributor

Review posted to PR #2881.

Summary

I reviewed PR #2881 (fix(build): preserve require export conditions) at head a5fc4d1, which adds one new commit since the last review — perf(build): skip ineligible require condition scans. That commit directly addresses the outstanding performance finding (#1) from the prior review. No blocking issues; LGTM.

Key verification I did this pass:

  • The new node_modules guard is correct, not just faster. I diffed the new gate against vite-plugin-commonjs@0.10.4's own transform gate (dist/index.js:410-413) and confirmed it's a faithful mirror (userCondition === false / userCondition !== true && id.includes("node_modules")). The pre-resolution now rewrites requires in exactly the set of files the commonjs pass will actually hoist — a safe narrowing, not a behavioral change. Ordinary node_modules files fall through to vite-plugin-commonjs's own skip where Vite/Rolldown already preserves require conditions.
  • Recursion is preserved. Synthetic .vinext-require.js ids return true from the filter, short-circuiting the node_modules clause, so nested divergent requires still recurse. The new "rewrites nested package requires in synthetic targets" unit test locks this in — which also closes the prior optional follow-up about missing direct coverage.
  • Windows/slash handling is sound (.includes("node_modules") is separator-agnostic and matches upstream).

Verified locally: unit tests 13/13 (up from 9), cjs dev+prod integration 11/11, vp check clean on all four files.

Two unchanged non-blocking observations noted (.mjs/.mts targets forced through the CJS filter; unbounded-but-bounded virtualTargets). The last flagged item is resolved cleanly.

github run

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