Skip to content

fix(build): apply configured webpack loaders#2348

Open
james-elicx wants to merge 8 commits into
mainfrom
codex/fix-webpack-loader-transform-parity
Open

fix(build): apply configured webpack loaders#2348
james-elicx wants to merge 8 commits into
mainfrom
codex/fix-webpack-loader-transform-parity

Conversation

@james-elicx

Copy link
Copy Markdown
Member

Summary

  • retain user webpack loader rules discovered during next.config probing
  • run matching loader chains before vinext static-image handling
  • cover the Next.js webpack-loader-set-environment-variable App Router fixture in a focused production build test

Validation

  • vp check packages/vinext/src/plugins/webpack-loader-compat.ts packages/vinext/src/config/next-config.ts packages/vinext/src/index.ts tests/webpack-loader-compat.test.ts
  • vp test run tests/webpack-loader-compat.test.ts
  • vp test run tests/next-config.test.ts -t "webpack loader callbacks|webpack config"
  • targeted Next.js v16.2.6 suite attempted with concurrency 1, but the machine hit ENOSPC before deployment and did not reach assertions; exact-head CI will classify end-to-end

Fixes the baseline failure in test/e2e/app-dir/webpack-loader-set-environment-variable/webpack-loader-set-environment-variable.test.ts from run 28143992598.

@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

@pkg-pr-new

pkg-pr-new Bot commented Jun 25, 2026

Copy link
Copy Markdown

Open in StackBlitz

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

commit: ff1d943

@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

@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: apply configured webpack loaders

Solid, well-scoped approach. The shim runs matching loader chains via a \0-prefixed virtual module and correctly defers to it ahead of static-image handling. I verified the new test passes and ran a few probes. A few correctness/perf items worth addressing before merge.

1. Loaders run multiple times per resource (probe + once per environment) — non-idempotent side effects will fire repeatedly

The loader pipeline now runs once during the config probe (invokeLoaderSideEffects) and again in load() once per Vite environment (RSC, SSR, client). I confirmed this with an instrumented loader: a single .svg import imported from a server component invoked the loader 3 times (1 probe + RSC + SSR). For a module imported from a client component it would be 4 (probe + RSC + SSR + client).

INVOCATIONS:
rp=<none>                 # invokeLoaderSideEffects (probe)
rp=.../app/next.svg       # load() in RSC env
rp=.../app/next.svg       # load() in SSR env

invokeLoaderSideEffects exists specifically to capture process.env mutations at config time (issue #1500). Now the same loader's side effects run a second/third time during load(). For the env-mutation case this happens to be idempotent, but any loader that:

  • appends to a file / increments a counter / emits an artifact,
  • posts telemetry, or
  • is simply expensive,

will do so once per environment plus once for the probe. Next.js runs each loader once per asset. Worth either deduping the work across environments (cache the transformed output by resourcePath) or at minimum documenting that loaders must be pure. The probe-then-transform double-run is the part most likely to surprise users.

2. resolveId runs this.resolve() for every import in every module when any loader rule is configured

async resolveId(source, importer) {
  const configuredRules = getRules();
  if (configuredRules.length === 0) return null;
  if (source.startsWith(WEBPACK_LOADER_PREFIX) || source.startsWith("\0")) return null;
  const resolved = await this.resolve(source, importer, { skipSelf: true });
  ...

Once a single loader rule exists, this performs a full this.resolve() (an async resolver round-trip) for every non-virtual import across RSC/SSR/client graphs, then collectMatchingWebpackLoaderRules on each. Per AGENTS.md "Performance Best Practices" (filter hooks before JS runs, keep the common path light), consider a native hook filter so the JS handler only fires for candidate ids. If the configured rules' test conditions are extension-based (the overwhelmingly common case — /\.svg$/, etc.), you can derive an include regex from the rule tests and attach it as a resolveId.filter.id, avoiding the resolver call for the bulk of imports. At minimum, gate on a cheap extension check of source before calling this.resolve().

3. emitError rejects the whole load — diverges from webpack (non-fatal)

emitError: reject,

In webpack, this.emitError() records a diagnostic but does not abort the loader; compilation continues. Here it rejects the runLoader promise, which fails the build. A loader that calls emitError for a recoverable condition (and still returns output) would break under vinext but succeed under Next.js. Consider treating emitError like emitWarning (log) rather than reject, reserving rejection for thrown errors / callback(err).

4. Return value ignored vs. honored when this.async() is used

const result = loader.call(context, input);
if (result && typeof result.then === "function") { ... }
else if (result !== undefined) { complete(null, result); }   // <-- races async()

If a loader calls this.async() and then returns a non-undefined value (some loaders return undefined explicitly, but not all), line 166 completes immediately with the return value, racing the async callback. Webpack ignores the sync return value once this.async() has been requested. Guard with else if (!asyncRequested && result !== undefined).

5. Minimal loader context — common APIs missing

The LoaderContext omits frequently-used members: addDependency, addContextDependency, resourceQuery, getResolve, fs, sourceMap, data, loaders/loaderIndex, utils, target. A loader calling this.addDependency(...) (very common) throws this.addDependency is not a function, which under item #3's reject semantics fails the build. Even no-op stubs for the common ones would broaden compatibility for "narrow loader support." Worth a note in the doc comment listing what is/isn't provided.

6. Nested rules conditions are not ANDed with the parent

In collectMatchingWebpackLoaderRules, for a parent rule with rules: [...], children are visited with only their own conditions checked — the parent's test/include/exclude are not combined. Webpack ANDs ancestor conditions with descendant conditions. Edge case (most configs use flat rules or oneOf), but a latent divergence worth a comment or follow-up.

Test coverage suggestions

The single fixture test is a good port of the Next.js env-mutation case. Consider adding focused unit tests for the pure helpers (no build needed, fast):

  • collectMatchingWebpackLoaderRules with oneOf, exclude, include, nested rules, and array use ordering
  • matchesCondition for RegExp / string-prefix / function / array / { and, or, not } objects
  • A multi-loader chain asserting right-to-left execution order

These would lock in the matching semantics independently of the (slow) buildApp() path and guard against regressions in the condition logic.


None of these block the core fix (the env-var parity case works). Items #1, #2, and #3 are the ones I'd prioritize: #1 because non-idempotent loaders will misbehave, #2 for the per-import resolve cost on every app that configures any loader, and #3 for Next.js parity on emitError.

@ask-bonk

ask-bonk Bot commented Jun 25, 2026

Copy link
Copy Markdown
Contributor

Review posted to PR #2348. Summary of findings:

Review summary for PR #2348

The core fix works — I ran the new test (passes) and instrumented the loader to verify runtime behavior. I flagged 6 issues, 3 of them prioritized:

Prioritized:

  1. Loaders run multiple times per resource — confirmed via instrumentation: a single .svg import fired the loader (1 probe + RSC + SSR; 4× if used in a client component). Non-idempotent loaders (file writes, telemetry, counters) or expensive transforms will misbehave. The probe (invokeLoaderSideEffects) plus the per-environment load() re-run is the surprising part.
  2. resolveId perf hot path — once any loader rule exists, every import in every env triggers a full this.resolve() + rule scan. Suggested a native hook filter (derive an include regex from rule tests) per the repo's performance guidance.
  3. emitError: reject diverges from webpack — webpack's emitError is non-fatal; here it aborts the build.

Lower priority:
4. Sync return value races this.async() (should ignore return when asyncRequested).
5. Minimal LoaderContext — missing addDependency, resourceQuery, getResolve, etc.; common loaders would throw.
6. Nested rules conditions aren't ANDed with the parent (webpack semantics).

Plus a suggestion to add fast unit tests for the pure helpers (collectMatchingWebpackLoaderRules, matchesCondition, loader ordering) instead of relying only on the slow buildApp() fixture test.

None block the core env-var parity fix.

github run

@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

@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: apply configured webpack loaders (follow-up)

The core env-var parity fix works and the new tests pass locally (tests/webpack-loader-compat.test.ts, 4/4). The prior review already covered the big items (multiple invocations per resource, resolveId resolve-on-every-import cost, emitError: reject parity, async-return race, minimal LoaderContext, nested-rule AND semantics). I won't repeat those. Below are distinct findings I confirmed by instrumenting the helpers.

1. resolveId strips the query string, then force-routes asset imports through the loader (?url / ?raw regression)

const resourcePath = resolved.id.split("?", 1)[0];      // query dropped
...
return `${WEBPACK_LOADER_PREFIX}${resourcePath}`;        // query never restored
let source: string | Buffer = await fs.readFile(resourcePath);  // load() has no query

With enforce: "pre", this hook runs ahead of Vite's asset pipeline. Any import like import url from "./icon.svg?url" or import raw from "./icon.svg?raw" whose path matches a configured rule is rewritten to \0vinext-webpack-loader:/abs/icon.svg, the ?url/?raw suffix is discarded, and the file is fed straight through the loader chain. The user loses Vite's normal ?url/?raw handling for that resource, and load() reads the file with no knowledge of the original query. This is a real behavioral divergence the moment a project has any extension-based rule (e.g. /\.svg$/) and also uses query-suffixed imports of the same extension. At minimum the query should be preserved through the prefixed id and load() should split it back off before fs.readFile.

2. resourceQuery / issuer rule conditions are ignored entirely

matchesRule only checks test / include / exclude. Webpack rules commonly key off resourceQuery (this is the idiomatic way to do ?url/?inline/?react variants). Confirmed:

collectMatchingWebpackLoaderRules([{ test: /\.svg$/, resourceQuery: /url/, use: ["l"] }], "/x/a.svg")
  -> matched (resourceQuery ignored)

So a rule that should only apply to *.svg?react (e.g. SVGR's common config) is applied to every .svg, and a rule scoped to ?url is applied to bare imports. Combined with #1 (query dropped before matching), resourceQuery-scoped rules can't work at all. Worth either honoring resourceQuery or explicitly documenting that only path-based conditions are supported and bailing out when a matched rule carries a resourceQuery/issuer condition (so we don't silently mis-apply it).

3. isFrameworkLoader matches "mdx" as a bare substring — over-broad

loaderPath.includes("mdx")

This skips any loader whose resolved path contains mdx anywhere, including a legitimate user loader at e.g. /app/loaders/mdx-frontmatter.cjs or a package like remark-mdx-foo. The next-babel-loader and next/dist/build/webpack checks are appropriately specific; the mdx check should be tightened (e.g. match @next/mdx / @mdx-js/loader specifically) so user loaders with "mdx" in the path aren't silently dropped. Note this same over-broad check is duplicated in next-config.ts's invokeLoaderSideEffects (lines ~1946-1948).

4. {} / empty-object conditions return false (webpack treats them as match)

collectMatchingWebpackLoaderRules([{ test: {}, use: ["l"] }], "/x/a.svg")  -> no match

In matchesCondition, an object with none of and/or/not falls through to return record.and !== undefined || record.or !== undefined || record.not !== undefinedfalse. Webpack treats an empty condition object as matching. Minor/edge, but a latent divergence worth a guard or comment.

5. oneOf parent with its own use reverses loader order

parent = { test: /\.(svg|png)$/, use: ["parent-loader"], oneOf: [ {test:/\.svg$/, use:["a-loader"]}, ... ] }
collectMatchingWebpackLoaderRules([parent], "/x/icon.svg")
  -> [ ["a-loader"], ["parent-loader"] ]

The matched oneOf child is pushed before the parent (which still contributes its own use). After loaders.reverse() in load(), parent-loader runs before a-loader. Webpack's own normalization flattens these differently. This overlaps the prior review's nested-rules note (#6 there), but the concrete symptom here is wrong execution order when a oneOf parent also carries loaders, not just un-ANDed conditions. Low priority (unusual config), but worth a unit test pinning the intended order.


Priorities, from my list: #1 (query drop + force-routing) and #2 (resourceQuery ignored) are the ones most likely to bite real apps the moment any extension rule is configured alongside ?url/?raw/?react imports — exactly the SVGR-style setups this feature targets. #3 can silently drop a valid user loader. #4/#5 are edge cases / follow-ups.

None block the env-var parity fix that motivated the PR.

@ask-bonk

ask-bonk Bot commented Jun 25, 2026

Copy link
Copy Markdown
Contributor

Review posted to PR #2348.

Summary

I reviewed PR #2348 (fix(build): apply configured webpack loaders). The core env-var parity fix works and the 4 tests in tests/webpack-loader-compat.test.ts pass locally. A prior review already flagged the major items, so I focused on distinct findings that I confirmed by instrumenting the helpers:

Prioritized (new):

  1. Query string dropped + asset imports force-routedresolveId strips ?url/?raw/etc. at webpack-loader-compat.ts:232 and never restores it, so with enforce: "pre" any query-suffixed import whose path matches a rule (e.g. icon.svg?url) bypasses Vite's asset pipeline and is fed raw through the loader chain.
  2. resourceQuery/issuer conditions ignoredmatchesRule only checks test/include/exclude. Confirmed a { test: /\.svg$/, resourceQuery: /url/ } rule matches every .svg. Breaks idiomatic SVGR-style ?react/?url configs — exactly what this feature targets.

Lower priority:
3. isFrameworkLoader matches "mdx" as a bare substring, silently dropping valid user loaders with "mdx" in the path (duplicated in next-config.ts).
4. {} empty-object conditions return false (webpack treats them as match).
5. oneOf parent that also carries use reverses loader execution order (confirmed via instrumentation).

I explicitly avoided repeating the prior review's items (multiple invocations, resolveId perf, emitError, async race, minimal context, nested-rule AND). None of my findings block the env-var parity fix.

github run

@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

@james-elicx

Copy link
Copy Markdown
Member Author

Targeted independent review for the baseline webpack-loader-set-environment-variable failure:

The PR does cover the exact run-28143992598 failure and the fixture is faithful to the Next.js v16.2.6 setup. Please keep this draft for one remaining compatibility blocker before graduation:

  • resolveId() strips the resolved query at packages/vinext/src/plugins/webpack-loader-compat.ts:240 and returns a path-only virtual ID at line 244, while matchesRule() only evaluates path conditions. Therefore a rule such as { test: /\.svg$/ } force-routes icon.svg?url / ?raw through the webpack loader instead of preserving Vite's query semantics. Rules with resourceQuery are also currently applied to every matching path because that condition is ignored. Next.js/webpack uses resourceQuery extensively, so this can silently change unrelated asset imports as soon as one custom loader rule is configured.

Suggested focused remediation: carry the full resource query into matching/context and honor resourceQuery; if full query compatibility is intentionally out of scope, conservatively leave query-qualified imports to Vite unless a supported resourceQuery condition explicitly matches. Add a plugin-level regression covering bare .svg, .svg?url, and a resourceQuery: /react/ rule.

I also confirmed the async callback race reported earlier is fixed at current head 688beaa3. I did not duplicate the PR or modify its branch. Local focused execution was attempted, but the isolated worktree could not resolve linked workspace peer dependencies; CI is the authoritative run for the current head.

@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

@james-elicx

Copy link
Copy Markdown
Member Author

Exact-head CI blocker at 569c492f is in-scope, not ambient:

Vitest (unit 1/3) has 14 failures in tests/image-imports.test.ts with:

TypeError: Cannot read properties of undefined (reading 'webpackLoaderRules')
  packages/vinext/src/index.ts:4811

The image transform unit harness invokes the plugin before nextConfig is populated, while the new custom-loader bypass dereferences nextConfig.webpackLoaderRules directly. Please use the same defensive fallback as the compat plugin registration (for example nextConfig?.webpackLoaderRules ?? { client: [], server: [] }) and add/retain focused image-import coverage proving ordinary static images still transform when config has not resolved and custom-loader matches bypass only when rules exist.

Failed job: https://github.com/cloudflare/vinext/actions/runs/28185426521/job/83485969127

@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 25, 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

@github-actions

Copy link
Copy Markdown
Contributor

Performance benchmarks

Compared ff1d943 against base de014d4 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 117.1 KB 117.1 KB ⚫ +0.0%
Client entry size (gzip) vinext 112.5 KB 112.5 KB ⚫ +0.0%
Dev server cold start vinext 2.60 s 2.59 s ⚫ -0.2%
Production build time vinext 3.14 s 3.18 s ⚫ +1.3%
RSC entry closure size (gzip) vinext 93.0 KB 93.0 KB ⚫ +0.0%
Server bundle size (gzip) vinext 156.9 KB 157.0 KB ⚫ +0.0%

View detailed results and traces

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

@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: apply configured webpack loaders (current head ff1d943)

I re-reviewed at the current head, ran the suite, and confirmed the prior review items have been addressed across the 8 commits:

  • Query strings are now preserved end-to-end (resource + issuer base64url-encoded into the virtual id, decoded in load(); fs.readFile reads the path, resourceQuery flows to the loader context). Verified by the new "preserves resource queries for matching loaders" test.
  • resourceQuery / issuer conditions are now honored in matchesRule (webpack-loader-compat.ts:78-91), with a focused unit test.
  • The async-loader race is fixed — sync return is ignored once this.async() is requested (webpack-loader-compat.ts:232), covered by "waits for async loader callbacks".
  • isFrameworkLoader / isMdxLoader no longer match a bare "mdx" substring; next-config.ts now reuses isMdxLoader. Covered by "runs custom loaders whose paths contain mdx".
  • emitError logs instead of rejecting (webpack-loader-compat.ts:207), matching webpack's non-fatal semantics.
  • Nested rules now AND the parent conditions via parentMatches (webpack-loader-compat.ts:100-122), with a regression test.
  • The exact-head image-imports.test.ts crash (Cannot read properties of undefined (reading 'webpackLoaderRules')) is fixed via nextConfig?.webpackLoaderRules... optional chaining (index.ts:4810-4811) — all 27 tests pass.
  • Fast unit tests for the pure helpers were added (the file is now 9 tests, not 4).

Local verification (all green):

  • tests/webpack-loader-compat.test.ts — 9/9
  • tests/image-imports.test.ts — 27/27
  • tests/next-config.test.ts -t webpack — 11/11
  • tests/build-optimization.test.ts — 149/149
  • vp check on the four changed source/test files — clean

Remaining (non-blocking) items

  1. resolveId still runs this.resolve() for every import once any rule is configured (webpack-loader-compat.ts:268-286). There is no native hook filter and no cheap extension pre-check before the resolver round-trip. Per AGENTS.md "Filter Vite hooks before JS runs," deriving an include regex from the rules' tests (the common extension case) and attaching it as resolveId.filter.id would keep the common path light. Only bites apps that configure a loader rule, so low urgency.

  2. Loaders still run once per environment plus once in the config probe; there's no per-resourcePath output cache, and invokeLoaderSideEffects runs the chain again at config time. The env-mutation case is idempotent, but non-idempotent loaders (file writes, telemetry, counters) will fire 2-4x. Worth a doc note that loaders must be pure, or a transform cache keyed by resourcePath.

  3. Edge-case condition semantics (minor, worth a comment or follow-up unit test):

    • matchesCondition({}) returns false; webpack treats an empty condition object as match-all (webpack-loader-compat.ts:73).
    • A oneOf parent that also carries its own use would emit [child, parent], reversing relative loader order. Webpack rejects use+oneOf on the same rule, so this is invalid-config territory, but a unit test pinning intent would be cheap.

None of these block the env-var parity fix that motivates the PR. The core fix is correct and well tested.

@ask-bonk

ask-bonk Bot commented Jun 25, 2026

Copy link
Copy Markdown
Contributor

Review posted to PR #2348.

Summary

I re-reviewed PR #2348 at the current head (ff1d943), ran the relevant test suites, and ran vp check. The PR has matured substantially since the earlier reviews — all of the previously-flagged correctness items have been addressed across the 8 commits:

Confirmed fixed (with tests):

  • Query strings preserved end-to-end (base64url-encoded resource+issuer in the virtual id)
  • resourceQuery / issuer rule conditions now honored
  • Async-loader sync-return race fixed (!asyncRequested guard)
  • isFrameworkLoader/isMdxLoader no longer match bare "mdx" substring
  • emitError logs instead of rejecting (matches webpack)
  • Nested rules AND parent conditions
  • The exact-head image-imports.test.ts crash fixed via optional chaining
  • Fast unit tests added for the pure helpers (4 → 9 tests)

Local verification (all green): webpack-loader-compat 9/9, image-imports 27/27, next-config webpack 11/11, build-optimization 149/149, vp check clean.

Remaining non-blocking items I flagged:

  1. resolveId still does a full this.resolve() per import once any rule exists (no native hook filter) — perf nit per AGENTS.md guidance
  2. Loaders run once per environment + once in the probe (non-idempotent loaders fire 2-4×) — worth a doc note or per-resourcePath cache
  3. Two minor condition edge cases ({} empty-object match-all, oneOf+use ordering)

None block the env-var parity fix. The core fix is correct and well tested.

github run

@james-elicx james-elicx marked this pull request as ready for review June 25, 2026 16:49
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