Skip to content

fix: keep CJS named exports intact when bundling node_modules in dev - #108

Merged
Aukevanoost merged 4 commits into
mainfrom
issues/83
Aug 1, 2026
Merged

fix: keep CJS named exports intact when bundling node_modules in dev#108
Aukevanoost merged 4 commits into
mainfrom
issues/83

Conversation

@Aukevanoost

@Aukevanoost Aukevanoost commented Aug 1, 2026

Copy link
Copy Markdown
Contributor

Refs #83. Supersedes #89 (closed) — same branch, much smaller approach.

Problem

ng serve fails while bundling shared externals; ng build succeeds:

✘ [ERROR] No matching export in "node_modules/quill-delta/dist/Delta.js" for import "AttributeMap"

    node_modules/quill/core/editor.js:3:16:
      3 │ import Delta, { AttributeMap, Op } from 'quill-delta';
        ╵                 ~~~~~~~~~~~~

11 errors in total, also hitting quill/blots/scroll.js and quill/core.js for Op / OpIterator.
Reproduction: https://github.com/leo2823/quill-issue (clean checkout, no changes needed).

Root cause

quill-delta/dist/Delta.js is plain, statically analysable CJS — esbuild handles it correctly
unaided. The breakage comes from @chialab/esbuild-plugin-commonjs, which is registered
unconditionally. The file ends in the classic UMD sniff:

exports.default = Delta;
if (typeof module === 'object') {
    module.exports = Delta;
    module.exports.default = Delta;
}

That trips the plugin's UMD detector. It wraps the body in an IIFE invoked with module and
exports passed as void 0, so every exports.X = … assignment is lost and the emitted ESM has
only a default export. Because the plugin converted the file to real ESM, esbuild now
static-checks the named imports and errors.

Confirmed by ablation: strip only that trailing if block and the same plugin emits
export { __export0 as AttributeMap, __export2 as Op, __export1 as OpIterator, … }.

Why production was unaffected

Both createAngularLinkerPlugin and commonjsPlugin() register onLoad on /\.m?js$/, and
esbuild uses the first hook that returns non-null. With advancedOptimizations (= !dev) the
linker claims every .m?js file, so the chialab plugin never sees Delta.js and esbuild's native
CJS interop applies. In dev the linker early-returned null for anything without ɵɵngDeclare,
handing the file to the broken plugin.

So prod is not correct because it transforms — it is correct because it accidentally shadows a
broken plugin. This inverts the original diagnosis in #83, which assumed dev was missing a
needed CJS→ESM transform. It is not; it is exposing one that corrupts the module.

This is also why #83 could not be fixed in core. quill-delta is a transitive dep of quill,
quill is in skip, and neither appears in the app's package.json, so shareAll never
produces a PackageInfo for it — it is an inlined module inside primeng's bundle, with no entry
point for core's synthesizeCjsExports to hook.

The fix

Make the dev fast-path claim the file instead of declining it:

 if (!needsLinking && !advancedOptimizations) {
-    return null;
+    return { contents, loader: 'js' };
 }

contents is already read on the line above, so this costs nothing extra — no jsTransformer
run, dev stays fast. It makes dev take the same path prod already takes, which is the dev/prod
consistency #83 asked for.

Also carried on this branch: @softarc/native-federation ^4.3.2^4.4.0, which brings core's
synthesizeCjsExports for the shared entry point case (the original dayjs report, where the
CJS package genuinely is shared). The two changes are complementary: core handles shared entry
points, this handles inlined transitive deps.

Verification

Reprong serve builds clean. Loaded the served app in headless Chrome with a <p-editor>
rendered: .ql-toolbar, .ql-container and <div class="ql-editor ql-blank" contenteditable="true">
all present, zero console errors. ql-blank is set by Quill's own scroll logic, which goes through
Delta/Op/AttributeMap — so the named bindings resolve at runtime, not just at build time.

Regression testnode-modules-bundler.spec.ts builds a fixture CJS module with the UMD tail
through the real [angularLinker, commonjsPlugin] pair in dev mode, then dynamically imports the
output so the assertion is on runtime values. Reverting the one-liner reproduces the exact
production error message.

No behavioural drift on existing dependency trees. Byte-diffed the dev-mode vendor bundles
before/after across angular-examples/angular/simple (host + 3 remotes):

55 emitted *-dev.js chunks × before/after → diff -rq: identical

Blast radius on the primeng/quill tree, measured by running chialab's own
maybeMixedModule / maybeCommonjsModule / transform over all .m?js files in the 17 packages
that contribute to the shared bundles:

outcome files
plugin changes nothing today 3710
rewritten via wrapDynamicRequire — the only capability esbuild lacks 0
rewritten CJS→ESM, now handled by esbuild's native __commonJS 265

The zero matters: wrapDynamicRequire only rewrites if (typeof require ===/!== …) guards. Every
file matching that pattern in the trees I scanned is Node-only build tooling (lmdb, sass,
yargs, msgpackr, typescript, compiler-cli) that is never browser-bundled. The 265 are
quill-delta, eventemitter3, fast-diff, lodash.clonedeep, lodash.isequal, tslib/tslib.js
and rxjs/dist/cjs/** — all of which prod has been running through native interop all along.

Side effect: 104 primeng files were being fully AST-parsed every dev build only for
wrapDynamicRequire to decide it had nothing to do. Dev bundling now skips that, and skips a
redundant second file read.

Known behavioural changes

  • A user-supplied plugins entry with an onLoad on .m?js is now shadowed in dev. Prod already
    shadows it, so this is prod parity, but no example exercises it and the tests cannot see it.
  • builderOptions.loader overrides for .js/.mjs are ignored in dev (forced loader: 'js').
    Same parity argument.
  • Anything relying on chialab's conversion in dev while already being broken in prod will now be
    broken in dev too — a latent prod bug becoming visible rather than a new fault.

Deliberately out of scope

.cjs files. The linker filter /\.m?js$/ does not match .cjs, yet .cjs is in
resolveExtensions, so .cjs still reaches the chialab plugin in both dev and prod and the
same UMD misdetection would hit it. That is pre-existing and already dev/prod symmetric — widening
the filter to /\.[mc]?js$/ would change production behaviour on files never tested through the
linker path, which is a different risk class. Worth its own issue.

The angular-linker plugin declined every .m?js file that does not require
linking when advancedOptimizations is off, i.e. in dev. esbuild then handed
those files to @chialab/esbuild-plugin-commonjs, which misdetects a trailing
`if (typeof module === 'object')` UMD sniff, wraps the body in an IIFE invoked
with `exports === void 0` and emits ESM with only a default export. Named
imports then fail to resolve, e.g. `No matching export in
node_modules/quill-delta/dist/Delta.js for import "AttributeMap"` when
bundling quill via primeng.

Production was unaffected only because the linker claims every .m?js there,
shadowing the broken plugin. Claiming the file untransformed in dev makes both
modes use esbuild's native CommonJS interop, which handles these packages
correctly.

Refs #83
@Aukevanoost
Aukevanoost merged commit a038386 into main Aug 1, 2026
1 check passed
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