fix(builders): externalize Angular's synthesized imports into shared mappings - #134
Merged
Merged
Conversation
Aukevanoost
force-pushed
the
issues/128
branch
from
September 15, 2026 17:47
f76f53f to
54036f0
Compare
…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
force-pushed
the
issues/128
branch
from
September 16, 2026 06:46
54036f0 to
b979cdd
Compare
Aukevanoost
marked this pull request as ready for review
September 16, 2026 09:41
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #128. Same mechanism as #119.
The bug
A workspace lib shared via
sharedMappingsends up in the output twice: once in its federated chunk, once inlined into the app. Two evaluations, twoInjectionTokenidentities,NG0201: No provider found. The same duplication hitsprovidedIn: 'root'services and pipes.The app author's import is fine. ngtsc synthesizes a second, deep one:
getExternalsreturns bare specifiers and esbuild'sexternalmatches the unresolved specifier, so the synthesized path — which never went through thepathsmapping — 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.
createSharedMappingsPlugincovered this until73f5c69commented out both call sites anda6aa619deleted 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, theimport-statementcheck, aplatform === 'node'bail — Angular applies code plugins to the server bundle too — andreset()ononStart.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 asundefinedat runtime.src/utils/reexported-files.tsis 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 whatns.Xresolves to afterwards —export { BadgeComponent as Badge }leaves the file reachable whilei1.BadgeComponentis undefined. Thetypescriptpeer dependency goes with it. That file was this package's only runtimetypescriptimport — the sole remaining reference is animport typein a spec, erased at compile time — so there is nothing left here for a peer to satisfy. Core declares its owntypescriptas a regular dependency, so it arrives transitively rather than through peer resolution and does not need one declared here either.angular-bundler.tsis deliberately untouched, though both issue reporters patched it. Core builds every mapping and expose as entry points of one esbuild context withsplittingon 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
ngcprobe: 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 pointi1.Xat 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.tsruns 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 betweenonStartcalls changes the answer.End to end in
angular-examples/angular/nx, two probe libs used fromapps/hostviaimports: [InternalUiModule, InternalFacadeModule]:PROBE_BADGE_TOKENPROBE_CHIP_TOKEN@angular/corePROBE_HIDDEN_TOKENThe host chunk ends up with
import * as c from "@internal/ui",import * as d from "@internal/uifacade"anddependencies:[M,E,c.BadgeComponent,r,_,d.ChipComponent]—rthe inlined hidden component, whoseselectors:[["internal-hidden"]]is the only component selector left in the bundle besidesapp-root.Under
nx build host --watch, appendingexport * from './hidden.component'to the barrel mid-session movedPROBE_HIDDEN_TOKENinto 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
typescriptdependency 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.jsonasks for@softarc/native-federation ~4.5.0-next.1, which resolves to the published4.5.0— that version does not exportcreateMappingImportResolver, sopnpm typecheckfails. This is the dependency, not a defect in the change.Before this can leave draft:
@softarc/native-federationrange to the published version, refreshpnpm-lock.yamlBackports to
21.x.xand20.3.xare outstanding and deliberately deferred; the bug is present on both.