Skip to content

fix(build): expand require.context into import.meta.glob map (#1501)#1836

Merged
james-elicx merged 5 commits into
mainfrom
fix/issue-1501-require-context
Jun 8, 2026
Merged

fix(build): expand require.context into import.meta.glob map (#1501)#1836
james-elicx merged 5 commits into
mainfrom
fix/issue-1501-require-context

Conversation

@james-elicx

Copy link
Copy Markdown
Member

Problem

require.context(dir, recursive, regexp) is a Webpack-only build-time API that builds a static module map. Next.js apps still use it — typically written (require as any).context(...) so it type-checks. Under vinext (Vite/Rolldown) there is no such API, so the call throws require is not defined at runtime and the page fails to render.

Failing upstream suite: test/e2e/app-dir/require-context.

Fix

Adds a small build-time transform plugin (packages/vinext/src/plugins/require-context.ts, registered in index.ts) that rewrites each genuine require.context(...) call into an IIFE backed by Vite's import.meta.glob(..., { eager: true }). The IIFE exposes the subset of the Webpack context interface used in practice:

  • ctx.keys() — sorted keys relative to the base dir (e.g. ./parent/file1.js)
  • ctx(key) — the module namespace object (throws MODULE_NOT_FOUND for unknown keys)
  • ctx.resolve(key) / ctx.id — best-effort

Scope / safety

  • Runs enforce: "pre" so the original (require as any).context(...) form is still visible (the callee object is a TSAsExpression). The matcher unwraps TSAsExpression / TSSatisfiesExpression / TSNonNullExpression / parenthesized wrappers down to the bare require identifier.
  • Only the literal form is rewritten: a static relative string dir (./ or ../), an optional literal boolean recursive, and an optional regexp literal. Anything dynamic or an unrelated .context(...) call is left untouched.
  • Cheap substring gate (require + .context) before parsing.

Test

Ported the upstream regex-filtering case into tests/nextjs-compat/require-context.test.ts with a minimal fixture under tests/fixtures/app-basic/app/nextjs-compat/require-context/. Asserts keys() returns ['./parent/file1.js', './parent/file2.js', './parent2/file3.js'].

Closes #1501

@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

@pkg-pr-new

pkg-pr-new Bot commented Jun 8, 2026

Copy link
Copy Markdown

Open in StackBlitz

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

commit: 19320d0

@github-actions

github-actions Bot commented Jun 8, 2026

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

@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

@ask-bonk

ask-bonk Bot commented Jun 8, 2026

Copy link
Copy Markdown
Contributor

@james-elicx Bonk workflow was cancelled.

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 Jun 8, 2026

Copy link
Copy Markdown
Contributor

/bigbonk review

Reviewed the require.context -> import.meta.glob transform. The approach is sound, the matcher is appropriately conservative, and the ported upstream test passes. One real correctness bug and a couple of smaller notes below.

Bug: stateful regex flags (g/y) drop files non-deterministically

In buildReplacement, the filter regexp is reconstructed verbatim from the literal's pattern + flags:

const __re = new RegExp("\\.js", "g");
...
if (__re && !__re.test(__key)) continue;

If the source regexp carries the global (g) or sticky (y) flag, RegExp.prototype.test becomes stateful via lastIndex, so consecutive test() calls on the sorted keys alternate true/false and silently drop matching modules:

const re = new RegExp("\\.js", "g");
re.test("./parent/file1.js"); // true
re.test("./parent/file2.js"); // false  <-- dropped
re.test("./parent2/file3.js"); // true

Webpack's require.context doesn't have this problem because the filter is applied per-path without relying on a persistent lastIndex. A user writing (require as any).context("./x", true, /\.js$/g) would get a corrupted key set under vinext but a correct one under Next.js — a silent parity divergence, which is exactly the failure mode the AGENTS guide warns about.

The current fixture regex /\.js/ has no flags, so the test passes and masks this. Suggested fix: strip g/y when reconstructing the RegExp (they're meaningless for a .test() membership check), e.g.:

const __re = new RegExp(pattern, flags.replace(/[gy]/g, ""));

and add a fixture/test case exercising a g-flagged regexp to lock it in.

Note: recursive default diverges from Webpack

The comment at lines 158-160 acknowledges this, but worth calling out explicitly: Webpack's require.context defaults recursive to true when omitted, while this plugin defaults recursive to false (let recursive = false; with no second arg). If a Next.js app relies on the implicit-recursive default (require.context("./dir", undefined, /re/) is not the only form — require.context("./dir") recurses in Webpack), vinext will silently produce a shallower key set. Recommend either defaulting to true to match Webpack, or documenting the deliberate divergence with a link to Webpack's behavior per the AGENTS.md "deliberate, documented decision" rule.

Minor: dead descent comment

In collectRequireContextCalls (lines 121-124), the comment says "Still descend into the arguments..." but the code returns immediately, so it does not descend. The behavior is correct (args are literals), but the comment is misleading — either drop the comment or actually recurse for the rare nested case.

Confirmed working / good

  • Matcher correctly unwraps TSAsExpression / TSSatisfiesExpression / TSNonNullExpression / parenthesized wrappers down to the bare require identifier, and rejects computed/optional member access.
  • Relative-path guard (./ / ../) is correct; ../-based dirs normalize properly.
  • Regex filter is applied against the dir-relative key (./parent/file1.js), matching Webpack.
  • MODULE_NOT_FOUND error shape and sorted-keys behavior match expectations.
  • Ported upstream test (test/e2e/app-dir/require-context) passes locally.

@ask-bonk

ask-bonk Bot commented Jun 8, 2026

Copy link
Copy Markdown
Contributor

Posted the review on PR #1836. Summary of findings:

Bug found — stateful regex flags (g/y)
The reconstructed filter new RegExp(pattern, flags) preserves the original flags. If a user's regexp carries g or y, RegExp.test() becomes stateful via lastIndex and alternately drops matching modules — a silent divergence from Next.js/Webpack. The current fixture regex /\.js/ has no flags, so the test passes and masks this. Suggested fix: strip g/y when reconstructing.

Note — recursive default divergence
Webpack defaults recursive to true when omitted; this plugin defaults to false. Should either match Webpack or document the deliberate divergence per AGENTS.md.

Minor — misleading comment in collectRequireContextCalls: comment says it descends into arguments, but the code returns.

Confirmed correct: TS-wrapper unwrapping, relative-path guard (incl. ../), dir-relative regex filtering, MODULE_NOT_FOUND shape, sorted keys, and the ported upstream test passing locally.

github run

…ve to true

Address ask-bonk review on #1836:
- Strip global/sticky flags when reconstructing the filter regexp; a
  stateful g/y RegExp.test() drops every other matching module.
- Default the omitted recursive arg to true to match Webpack's
  useSubdirectories default (was silently false).
- Add a g-flagged fixture/test case locking in the flag-stripping fix.
- Clarify the misleading descent comment in collectRequireContextCalls.
@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

1 similar comment
@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

@ask-bonk

ask-bonk Bot commented Jun 8, 2026

Copy link
Copy Markdown
Contributor

/bigbonk review

Re-reviewed after ef48bce ("strip g/y regex flags and default require.context recursive to true"). Both correctness bugs from the previous review are now fixed and locked in with tests. The transform is in good shape. Details below.

Resolved since last review

  • Stateful regex flags (g/y)buildReplacement now strips them via call.flags.replace(/[gy]/g, "") (require-context.ts:264), and a new fixture context with a g-flagged regexp + assertion (require-context-keys-global) locks it in. The g-flag test would fail if a stateful lastIndex ever dropped ./parent/file2.js. Verified both tests pass locally.
  • recursive default — now defaults to true to match Webpack (require-context.ts:162), with a comment explaining the Webpack useSubdirectories default. Good — this closes the silent shallower-key-set divergence.
  • Misleading descent commentcollectRequireContextCalls comment now correctly explains why it stops descending (args are all literals), matching the return (require-context.ts:121-123).

Remaining notes (non-blocking)

  1. Test coverage gap: ctx(key) retrieval and MODULE_NOT_FOUND. The fixture only exercises .keys(). The generated IIFE also implements __ctx(key) (returns the module namespace) and the MODULE_NOT_FOUND throw path (require-context.ts:285-290), neither of which is covered by a test. Webpack's require.context is most often used as ctx(ctx.keys()[i]).default, so adding one assertion that translationsContext('./parent/file1.js').default === 'file1' and one that an unknown key throws with code === 'MODULE_NOT_FOUND' would lock in the runtime contract the plugin claims to support.

  2. Unanchored regex semantics match Webpack — worth a note. The fixture uses /\.js/ (unanchored), so the filter matches any key containing .js (e.g. would also match foo.js.map). This is faithful to Webpack (which also applies the user's regexp verbatim against the relative key), so it's correct — just flagging that the regex is intentionally not anchored.

  3. recursive: false non-transformable files. With no regexp arg, import.meta.glob('<dir>/*', { eager: true }) matches every file in the directory (.json, .css, assets, etc.), which Vite will try to eagerly import. Webpack's require.context has the same broad default, so this matches behavior — but in practice real usage almost always passes a regexp, so the risk is low. No change needed; noting for awareness.

Confirmed correct

  • Plugin registered with enforce: "pre" (require-context.ts:57) and wired into the main plugin array (index.ts:4304), so it runs before TS/JSX stripping and still sees the (require as any).context(...) form.
  • Matcher unwraps TSAsExpression / TSSatisfiesExpression / TSNonNullExpression / ParenthesizedExpression down to the bare require identifier, and rejects computed/optional member access (require-context.ts:140-149, 191-213).
  • OXC regex node shape ({ pattern, flags }, no type field) is handled correctly and round-trips through new RegExp(pattern, flags) (verified against the parser).
  • Relative-path guard (./ / ../) and dir-relative key derivation (./parent/file1.js) match Webpack; keys sorted for determinism.
  • Cheap substring gate before parsing; parse failures fall through to null rather than throwing.

LGTM once the ctx(key) / MODULE_NOT_FOUND test gap is considered. The remaining items are coverage/documentation, not correctness regressions.

@ask-bonk

ask-bonk Bot commented Jun 8, 2026

Copy link
Copy Markdown
Contributor

Posted the review on PR #1836.

Summary: The two correctness bugs from the prior /bigbonk review were both fixed in commit ef48bce and are now locked in with tests:

  • Stateful regex flags (g/y) — now stripped in buildReplacement (require-context.ts:264), with a new g-flagged fixture context (require-context-keys-global) asserting no modules are dropped. Both tests pass locally.
  • recursive default — now defaults to true to match Webpack's useSubdirectories (require-context.ts:162).
  • Misleading descent comment — corrected.

Remaining notes (non-blocking, coverage/docs only):

  1. The fixture only tests .keys(); ctx(key) retrieval and the MODULE_NOT_FOUND throw path are implemented but untested. Suggested adding assertions for both.
  2. Unanchored regex /\.js/ faithfully matches Webpack — flagged for awareness.
  3. recursive: false with no regexp matches all files, same broad default as Webpack.

No correctness regressions found in the current revision. The transform is conservative and matches Next.js/Webpack behavior.

github run

Address ask-bonk follow-up: lock in the runtime contract beyond .keys().
- Assert ctx('./parent/file1.js').default resolves the module namespace.
- Assert an unknown key throws with code 'MODULE_NOT_FOUND'.
@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

@ask-bonk

ask-bonk Bot commented Jun 8, 2026

Copy link
Copy Markdown
Contributor

/bigbonk review

Re-reviewed at the current tip (48913ff, "cover ctx(key) retrieval and MODULE_NOT_FOUND"). All three items from the prior /bigbonk rounds are addressed, and the new commit closes the test gap the last review flagged. I independently re-verified the transform end-to-end and found no correctness regressions. vp check is clean and all 4 fixture tests pass locally.

Verified resolved

  • Stateful g/y regex flags — stripped in buildReplacement via call.flags.replace(/[gy]/g, "") (require-context.ts:264). Locked in by the require-context-keys-global assertion, which would fail if a stateful lastIndex dropped ./parent/file2.js.
  • recursive default — now defaults to true to match Webpack's useSubdirectories (require-context.ts:162), with a comment documenting the rationale.
  • Misleading descent comment — corrected (require-context.ts:121-123); the return now matches the comment.
  • ctx(key) / MODULE_NOT_FOUND coverage48913ff adds both assertions to the fixture (require-context-file1 returns .default, require-context-missing-code asserts code === 'MODULE_NOT_FOUND'). Confirms the runtime contract beyond .keys().

Independent verification

  • Prefix matching is collision-safe. __prefix = __base + '/' means ./parent2/file3.js is not falsely matched by base ./parent — the trailing slash guards the startsWith check. Confirmed against the parent / parent2 sibling dirs in the fixture.
  • ../-relative dirs normalize correctly. Upstream uses (require as any).context('../grandparent', ...); glob keys come back as ../grandparent/..., base is ../grandparent, prefix ../grandparent/, key derives to ./parent/file1.js. Matches Webpack.
  • import.meta.glob keys are always .//../-relative (never absolute) in Vite, so the __prefix.startsWith derivation holds. The pre-emitted import.meta.glob is correctly picked up by Vite core's glob plugin in the normal phase — verified by the SSR test rendering real keys.
  • { eager: true } with no import key yields the full module namespace, so ctx(key).default works — confirmed by the file1 assertion.
  • Ported upstream require-context-with-regex case matches test/e2e/app-dir/require-context expectations exactly (['./parent/file1.js', './parent/file2.js', './parent2/file3.js']).

Non-blocking notes (parity, low-risk)

  1. No-regex case drops Webpack's extensionless keys. The upstream (currently commented-out, Turbopack-pending) require-context-with-no-regex test expects both ./parent/file1 and ./parent/file1.js. vinext's import.meta.glob only ever produces the extension form, so a no-regex require.context would yield a different key set than Webpack. Since the upstream test is disabled and real usage almost always passes a regexp, this is fine to leave as a documented gap — worth a one-line comment near globPatternFor so it's not rediscovered later.
  2. recursive: false with no regexp globs every file type (.json, .css, assets), which Vite eagerly imports and could error on non-importable files. Matches Webpack's broad default; low risk in practice. Awareness only.

LGTM. The transform is appropriately conservative (literal-only matching, TS-wrapper unwrapping, relative-path guard, cheap substring gate, parse-failure fall-through), and the two real bugs surfaced earlier are fixed and regression-tested.

@ask-bonk

ask-bonk Bot commented Jun 8, 2026

Copy link
Copy Markdown
Contributor

Posted the review on PR #1836.

Summary: I re-reviewed at the current tip (48913ff). All findings from the prior /bigbonk rounds are resolved, and the latest commit closes the test-coverage gap the last review requested. I independently verified the transform and found no correctness regressions — vp check is clean and all 4 fixture tests pass locally.

Verified resolved:

  • Stateful g/y regex flags stripped (require-context.ts:264), locked in by the require-context-keys-global test.
  • recursive defaults to true to match Webpack's useSubdirectories (require-context.ts:162).
  • Misleading descent comment corrected.
  • ctx(key) retrieval + MODULE_NOT_FOUND now tested in 48913ff.

Independent verification:

  • Prefix matching is collision-safe (./parent vs ./parent2 guarded by trailing slash).
  • ../-relative dirs normalize correctly; matches the upstream ../grandparent form.
  • import.meta.glob keys are always relative, and the pre-emitted glob is picked up by Vite core later (confirmed by SSR test).
  • Ported regex case matches upstream expectations exactly.

Non-blocking notes (parity, low-risk):

  1. No-regex case drops Webpack's extensionless keys (upstream test for this is disabled/Turbopack-pending). Worth a doc comment.
  2. recursive: false with no regexp globs every file type — matches Webpack's broad default, low risk.

No correctness regressions. LGTM.

github run

Address ask-bonk note: with no filter regexp, import.meta.glob only
surfaces resolvable modules, so extensionless keys Webpack would include
can be dropped. Documented as a low-risk divergence (upstream Next.js's
extensionless test is disabled/Turbopack-pending).
@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

@ask-bonk

ask-bonk Bot commented Jun 8, 2026

Copy link
Copy Markdown
Contributor

/bigbonk review

Re-reviewed at the current tip (19320d0, which adds the no-regex extensionless parity comment requested last round). All prior /bigbonk findings are resolved. I independently re-verified the transform end-to-end — vp check is clean, all 4 fixture tests pass, and tests/build-optimization.test.ts (82 tests) is green. No correctness regressions found.

Independently verified this round

  • Regex pattern round-trips correctly through new RegExp(JSON.stringify(pattern), flags) — confirmed \.js, \.(js|ts)$, and patterns with embedded / and unicode escapes all reconstruct and .test() correctly. JSON.stringify of the OXC pattern source is the right escaping for emitting into a runtime new RegExp(...).
  • g/y stripping is the only flag mutation (require-context.ts:270). i/m/s/u/v are preserved, which is correct since they affect membership semantics; only the stateful lastIndex flags are dropped. Locked in by the require-context-keys-global assertion.
  • MODULE_NOT_FOUND error generation — replicated the exact emitted IIFE error string ('Cannot find module \'' + __key + '\''); it parses, runs, and yields code === 'MODULE_NOT_FOUND' with a correct message.
  • Object.create(null) for __map is prototype-pollution-safe and avoids collision between context keys (e.g. a ./keys.js file) and the __ctx.keys / .resolve / .id methods — lookups go through __map, methods live on the function. No collision possible.
  • Member-access chaining stays validrequire.context(...).keys() rewrites to (()=>{...})().keys(), which is well-formed because only the inner CallExpression range is overwritten.
  • Prefix matching collision-safety, ../ normalization, and dir-relative key derivation all reconfirmed against the parent/parent2 sibling fixture dirs.

Non-blocking notes (low severity, no change required)

  1. Cheap substring gate is broad. mayContainRequireContext only requires the substrings "require" and ".context" to both appear anywhere in a file (require-context.ts:86-90). Many bundled CJS modules contain require(...) and also reference .context / .contextType somewhere, so they pass the gate and incur a full parseAst. This is a minor perf cost only — the AST matcher is precise so there are no false rewrites, and parse failures fall through to null. If profiling ever shows this hot, a tighter gate (e.g. a /\brequire\b[\s\S]{0,40}\.context\s*\(/ regex) would prune most of these without losing real matches.

  2. Local require shadowing would false-match. The matcher keys on any identifier named require (require-context.ts:197-218), so a user-defined const require = ...; require.context(...) would be rewritten. This is vanishingly unlikely in real Next.js code (and shadowing require is itself unusual), and the conservative literal-args guard limits blast radius, so it's acceptable. Noting only for completeness.

  3. No-regex / extensionless and recursive:false-globs-everything caveats are now documented in-source (require-context.ts:169-175) and match Webpack's broad default. Good — these are the deliberate, documented divergences AGENTS.md calls for.

LGTM. The transform is appropriately conservative (literal-only matching, TS-wrapper unwrapping, relative-path guard, parse-failure fall-through), the two real bugs from earlier rounds are fixed and regression-tested, and the runtime contract (keys(), ctx(key), MODULE_NOT_FOUND) is now covered by tests.

@ask-bonk

ask-bonk Bot commented Jun 8, 2026

Copy link
Copy Markdown
Contributor

Posted the review on PR #1836.

Summary

I re-reviewed at the current tip (19320d0). All findings from the prior /bigbonk rounds are resolved, and I independently verified the transform end-to-end with no correctness regressions found. vp check is clean, all 4 fixture tests pass, and build-optimization.test.ts (82 tests) is green.

Independently verified this round:

  • Regex pattern round-trips correctly through JSON.stringify + runtime new RegExp(...) (tested \.js, \.(js|ts)$, embedded /, unicode escapes).
  • g/y flag stripping is the only flag mutation; i/m/s/u/v correctly preserved.
  • MODULE_NOT_FOUND error string parses/runs and yields the right code.
  • Object.create(null) map is prototype-pollution-safe and collision-free vs .keys/.resolve/.id methods.
  • Member-access chaining (require.context(...).keys()) rewrites to valid JS.

Non-blocking notes (no change required):

  1. The cheap substring gate (require + .context) is broad and will trigger redundant parseAst on many CJS node_modules files — perf only, no false rewrites.
  2. A user-defined local const require would false-match — vanishingly unlikely, acceptable.
  3. The no-regex extensionless and recursive:false globs-everything caveats are now documented in-source, matching Webpack's broad default.

Verdict: LGTM. The transform is conservative and correct; both real bugs from earlier rounds are fixed and regression-tested, and the runtime contract is now fully covered by tests.

github run

@james-elicx james-elicx marked this pull request as ready for review June 8, 2026 14:16
@james-elicx james-elicx merged commit 6958dfd into main Jun 8, 2026
51 checks passed
@james-elicx james-elicx deleted the fix/issue-1501-require-context branch June 8, 2026 18:33
@github-actions github-actions Bot mentioned this pull request Jun 8, 2026
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.

Build: require.context returns empty result (Webpack-only API)

1 participant