Skip to content

fix(builders): externalize Angular's synthesized imports into shared mappings - #134

Merged
Aukevanoost merged 3 commits into
mainfrom
issues/128
Sep 16, 2026
Merged

Aukevanoost merged 3 commits into
mainfrom
issues/128

Conversation

@Aukevanoost

@Aukevanoost Aukevanoost commented Sep 15, 2026

Copy link
Copy Markdown
Contributor

Closes #128. Same mechanism as #119.

The bug

A workspace lib shared via sharedMappings ends up in the output twice: once in its federated chunk, once inlined into the app. Two evaluations, two InjectionToken identities, NG0201: No provider found. The same duplication hits providedIn: 'root' services and pipes.

The app author's import is fine. ngtsc synthesizes a second, deep one:

import { UiModule } from '@myorg/ui';                        // bare -> matches externals
import * as i1 from "../../../libs/ui/src/badge.component";  // synthesized -> inlined
dependencies: [UiModule, i1.BadgeComponent]

getExternals returns bare specifiers and esbuild's external matches the unresolved specifier, so the synthesized path — which never went through the paths mapping — is never externalized.

Only references ngtsc has to synthesize are affected, which is why it looks intermittent: a standalone component named in imports: [] and an injected service both emit bare specifiers and are fine; a template dependency reached through an NgModule emits the deep relative path and breaks. NgModule-based shared UI libs in Nx workspaces are hit hardest.

Not a v22 regression, despite how both issues are written. createSharedMappingsPlugin covered this until 73f5c69 commented out both call sites and a6aa619 deleted them, leaving the plugin orphaned. It has shipped disabled since v20.3.8 / v21.1.8, which is why #128 reproduces on 21.2.9.

What changed

The rule lives in core and this package keeps only the bundler hook.

createMappingImportResolver (native-federation/native-federation-core#133) owns containment, entry-point resolution, the self-import check and the publication guard. The hook here is the /^[.]/ filter, the import-statement check, a platform === 'node' bail — Angular applies code plugins to the server bundle too — and reset() on onStart.

reset() rather than a caller-supplied change set: esbuild re-resolves the whole graph on every rebuild, so nothing survives from a previous build, and the only changed-path set this builder holds is the federation rebuild's buffer, which races the Angular rebuild and is drained by it. Staleness is not symmetric — a stale decline leaves an import inlined (the duplication being fixed), a stale rewrite emits a specifier no bundler validates and surfaces as undefined at runtime.

src/utils/reexported-files.ts is deleted. It answered "is this file reachable from the barrel", but the rewrite swaps the specifier and keeps the property access the compiler emitted, so the real question is what ns.X resolves to afterwards — export { BadgeComponent as Badge } leaves the file reachable while i1.BadgeComponent is undefined. The typescript peer dependency goes with it. That file was this package's only runtime typescript import — the sole remaining reference is an import type in a spec, erased at compile time — so there is nothing left here for a peer to satisfy. Core declares its own typescript as a regular dependency, so it arrives transitively rather than through peer resolution and does not need one declared here either.

angular-bundler.ts is deliberately untouched, though both issue reporters patched it. Core builds every mapping and expose as entry points of one esbuild context with splitting on by default, so cross-mapping relative imports already collapse onto a shared chunk. Adding the hook there would convert shared chunks into importmap externals — a chunk-graph change touching the ground of #12/#73 — for no benefit.

Known limitation

ngtsc emits the deep import whether or not the barrel publishes the component (confirmed with an ngc probe: a lib whose barrel re-exports the component and one whose barrel exports only the NgModule produce byte-identical output). So an NgModule library whose barrel publishes only the module still declines and stays duplicated. That is deliberate — rewriting anyway would point i1.X at a namespace with no such name, which is worse than the duplication because it fails at runtime rather than merely shipping twice.

Verification

Unit coverage in shared-mappings-plugin.spec.ts runs against real files on disk: barrel publishing module + component rewrites; barrel publishing the module only declines; export { X as Y } declines; importer inside the lib declines; a secondary entry point beats the barrel above it; a prefix-sharing sibling lib is untouched; the server bundle registers nothing; and a barrel edited between onStart calls changes the answer.

End to end in angular-examples/angular/nx, two probe libs used from apps/host via imports: [InternalUiModule, InternalFacadeModule]:

token shape pristine 22.1.0-RC1 this branch
PROBE_BADGE_TOKEN plain NgModule barrel federated and host chunk federated only
PROBE_CHIP_TOKEN barrel also re-exporting @angular/core federated and host chunk federated only
PROBE_HIDDEN_TOKEN not published by its barrel host chunk host chunk (declined, as designed)

The host chunk ends up with import * as c from "@internal/ui", import * as d from "@internal/uifacade" and dependencies:[M,E,c.BadgeComponent,r,_,d.ChipComponent]r the inlined hidden component, whose selectors:[["internal-hidden"]] is the only component selector left in the bundle besides app-root.

Under nx build host --watch, appending export * from './hidden.component' to the barrel mid-session moved PROBE_HIDDEN_TOKEN into the federated chunk on the next rebuild with no restart.

Produced from this PR's working tree, not from merged code, and not independently reproduced. The core branch has since been squashed, so the revision it ran against no longer resolves by SHA; it was the tree immediately before core's mapping-key case correction and its typescript dependency change, neither of which touches the resolver's decisions.

Draft: blocked on core

CI is red and will stay red until core#133 merges and publishes. package.json asks for @softarc/native-federation ~4.5.0-next.1, which resolves to the published 4.5.0 — that version does not export createMappingImportResolver, so pnpm typecheck fails. This is the dependency, not a defect in the change.

Before this can leave draft:

Backports to 21.x.x and 20.3.x are outstanding and deliberately deferred; the bug is present on both.

…mappings

esbuild's `external` matches the unresolved specifier, so it only caught
imports spelled `@myorg/ui`. Angular emits a deep relative path for any
reference it has to synthesize — a template dependency reached through an
imported NgModule — and those were inlined into the app alongside the
federated copy, giving the lib two module instances and NG0201. The same
duplication hits `providedIn: 'root'` services and pipes.

Only synthesized references are affected, which is why it looks
intermittent: a standalone component named in `imports: []` and an injected
service both emit bare specifiers and are fine.

createSharedMappingsPlugin covered this until 73f5c69 commented out both
call sites and a6aa619 deleted them; it has shipped disabled since v20.3.8
/ v21.1.8, so 21.x is affected too. Wire it back into the app build.

The rule itself lives in core. createMappingImportResolver owns
containment, entry-point resolution, the self-import check and the
publication guard; what is left here is the relative filter, the
import-statement check, a platform bail because Angular applies code
plugins to the server bundle too, and reset() on onStart.

reset() rather than a caller-supplied change set: esbuild re-resolves the
whole graph on every rebuild, so nothing survives from a previous build,
and the only changed-path set this builder holds is the federation
rebuild's buffer, which races the Angular rebuild and is drained by it.
Staleness is not symmetric — a stale decline leaves an import inlined,
which is the duplication being fixed, while a stale rewrite emits a
specifier no bundler validates and surfaces as `undefined` at runtime.

That asymmetry is also why a file the entry point does not publish is left
inlined rather than rewritten: Angular emits the deep import whether or not
the barrel re-exports the file, and pointing it at a namespace with no such
name fails at runtime instead of merely shipping twice.

The bail is on `ngServerMode`, not `platform`: SSR targeting an edge runtime
builds the server bundle as 'neutral', and Angular uses that platform for
browser-side global scripts too, so it does not identify the server bundle.
A rewrite there would emit a bare specifier no import map resolves.

Resolves outside the file namespace are declined as well. Angular's virtual
modules resolve against the workspace root, so the joined path names a file the
importer never asked for, and one that happens to sit under a mapping would
otherwise be rewritten.

The mapping-or-exposed build keeps its externals as they are: core bundles
every mapping and expose as entry points of one esbuild context with
splitting on by default, which already collapses cross-mapping relative
imports onto a single shared chunk.

Closes #128
@Aukevanoost
Aukevanoost marked this pull request as ready for review September 16, 2026 09:41
@Aukevanoost
Aukevanoost merged commit fc1c7e6 into main Sep 16, 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.

InjectionToken instance duplication causes NG0201 errors

1 participant