Skip to content

ng serve fails with "No matching export" for named CommonJS imports in shared deps (ng build works) #83

Description

@theoryeffects

Summary

On the Angular 22 line, ng serve (the Native Federation dev server) fails while bundling external npm packages when a shared library imports a named export from a CommonJS package (e.g. import dayjs, { isDayjs } from 'dayjs'). esbuild reports No matching export. The production build (ng build) of the same workspace succeeds.

The root cause is that the dev build skips the JavaScript transform that provides CommonJS→ESM interop (named-export synthesis). That interop is currently coupled to the advancedOptimizations flag, which is false in dev — so correctness (named-export resolution) is gated on an optimization level.

Environment

Package | Version(s) reproduced on -- | -- @angular-architects/native-federation | 22.0.3 and 22.0.4 (node-modules-bundler.js is byte-identical between them) @softarc/native-federation | 4.2.1 (also current 4.3.0) @softarc/native-federation-orchestrator | 4.4.1 esbuild | 0.28.x Angular | 22.0.x Node | 20.x / 22.x

Not observed on the v20 line (@angular-architects/native-federation@20.3.x), so this appears to be a behavior change in the v22 line.

Steps to reproduce

  1. In a Native Federation host on v22, share a package whose main entry is CommonJS and import a named binding from it in a shared library. Minimal case with dayjs (its main entry is CJS):

    // in any shared library that Native Federation bundles as an external
    import dayjs, { isDayjs } from 'dayjs';
    

    federation.config:

    shared: share({
      '<the shared lib that does the import above>': { singleton: true, /* ... */ },
    })
    
  2. Run the production build:

    ng build          # ✅ succeeds
    
  3. Run the dev server:

    ng serve          # ❌ fails while "Bundling external npm packages"
    

Actual result

X [ERROR] No matching export in "node_modules/dayjs/dayjs.min.js" for import "isDayjs"
node_modules/&lt;shared-lib&gt;/.../file.mjs:1:16:
  1 │ import dayjs, { isDayjs, extend } from 'dayjs';
    ╵                 ~~~~~~~~

(Reproduces identically with any CJS dependency that is named-imported, e.g. quill importing { Op, AttributeMap } from 'quill-delta'.)

Expected result

Named imports from CommonJS dependencies should resolve in the dev server the same way they resolve in the production build. Whether CJS→ESM interop is applied should not depend on the optimization level.

Root cause

File: src/tools/esbuild/node-modules-bundler.ts (shipped as node_modules/@angular-architects/native-federation/src/tools/esbuild/node-modules-bundler.js)

In createNodeModulesEsbuildContext, the optimization flag is derived from dev:

const advancedOptimizations = !dev;   // => false during ng serve

createAngularLinkerPlugin(jsTransformer, advancedOptimizations) then registers an esbuild onLoad that early-returns for any file that is not an Angular partial-compilation artifact whenever advancedOptimizations is off:

build.onLoad({ filter: /.m?js$/ }, async (args) => {
const contents = await fs.promises.readFile(args.path, 'utf-8');
const needsLinking = requiresLinking(args.path, contents); // true only if source contains 'ɵɵngDeclare'
if (!needsLinking && !advancedOptimizations) {
return null; // <-- dev fast-path: vendor deps skip the transform entirely
}
const result = await jsTransformer.transformData(args.path, contents, !needsLinking, undefined);
return { contents: Buffer.from(result).toString('utf-8'), loader: 'js' };
});
  • In dev (advancedOptimizations === false), any file without Angular's ɵɵngDeclare marker — i.e. ordinary vendor deps such as dayjs or quill-delta — hits the return null and is never passed to jsTransformer.transformData. That transform is what produces esbuild-resolvable output including the CJS→ESM interop that synthesizes named exports. Skipped → esbuild sees raw CommonJS → No matching export for { named } imports.
  • In production (advancedOptimizations === true), the condition is false, every file is transformed, and CJS named exports resolve — which is why ng build works and ng serve does not.

The defect: CJS-interop correctness is coupled to the advancedOptimizations optimization level. Named-export resolution from CommonJS should not depend on whether optimizations are enabled.

Note on @chialab/esbuild-plugin-commonjs

commonjsPlugin() is already registered unconditionally in the same plugin list (dev and prod):

plugins: [
createAngularLinkerPlugin(jsTransformer, advancedOptimizations),
commonjsPlugin(),
...customPlugins,
],

Despite that, named CommonJS imports are not resolved in dev. It may be worth confirming whether the commonjs plugin is expected to cover the import-side named-export synthesis here, and if so why it does not engage for these modules when the linker plugin early-returns.

Suggested fix

Decouple the CJS→ESM interop from advancedOptimizations. Preferred: keep the dev fast-path for genuine ESM vendor files, but do not skip CommonJS files — they still need the interop transform. This preserves fast dev builds (ESM vendor files keep the fast-path; only CJS modules pay the transform cost) rather than forcing advancedOptimizations = true in dev, which would run the full optimization pass over all vendor bundles.

A minimal illustration of that approach (CJS detection heuristic; a more robust detector would be preferable upstream):

const maybeCommonjs = /\bmodule.exports\b|\bexports.[A-Za-z_$]/.test(contents);
if (!needsLinking && !advancedOptimizations && !maybeCommonjs) {
return null;
}

Workaround for affected users

Until this is fixed upstream, a patch-package patch of node-modules-bundler.js applying the change above resolves it, or the offending named CommonJS import can be rewritten in the shared library as a default import plus property access (import dayjs from 'dayjs'; const { isDayjs } = dayjs;), which needs no named-export resolution.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions