Orchestrator 4.8.0: one build per package (no more "stitching") #176
Replies: 1 comment 2 replies
|
Our case, after trying 4.8.0 on a monorepo with ~20 remotes: Our workspace libraries share name prefixes without belonging together, e.g. With identical builds, going from 4.7.0 to 4.8.0 gave us:
We looked at the fix you suggest and it doesn't fit us:
For now we stay on orchestrator 4.7.0. Would you consider a way to declare which mappings form one package, or to opt out of the name-based grouping for mappings? And a direct question: should we prepare for a mass rename? It would hurt us. The slashes in our mapping names show a library's domain ( |
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
We will stop supporting unstable dependency entrypoint "stitching"!
Orchestrator 4.8.0 changes how a shared package is assembled in the browser. Most pages won't notice. Some pages
will download a few more files, and pages built with flat externals (still the default) can lose more of their
sharing than before. This post explains what changed, why, what it costs, and how to grow your setup to a more robust architecture with us!
But we want to know what you think, post your thoughts, ideas and comments.
TL;DR
@angular/core,@angular/core/rxjs-interop, …)from one build (read: the remoteEntry.json). The orchestrator no longer "borrows" a missing entrypoint from another remote's (read: micro frontend) build if the remote from the shared external happens to not export it.
NG0203, which could show up long after the page loaded.build. That means more downloads and, for singletons like Angular, potentially a second instance on the page.
denseExternalsand share coupled frameworks withincludeSecondaries: { keepAll: true }. Then every build ships the same entrypoints and nothing has to fall back.Until then, flat externals stay fully supported. But they will degrade performance (more stitching/resolving magic necessary from the orchestrator to make flat and dense work together).
What is "stitching"?
A shared package usually has more than one entrypoint:
@angular/corehas@angular/core/rxjs-interop,@angular/core/testingand others. By default, Native Federation only publishes the entrypoints a build actuallyimports (
ignoreUnusedDepsfeature). So two micro frontends on the same Angular version can publish different sets:@angular/core@angular/core,@angular/core/rxjs-interopThe orchestrator generates one import map for the page, with one shared version of
@angular/core. Up to 4.7.x, whenthe shared build (here: the host's) didn't have
@angular/core/rxjs-interop, the orchestrator took it from mfe1'sbuild instead, since it's the same version anyway. One package, assembled from two builds: that's what we call stitching.
It looks harmless: same package, same version. It isn't, for two reasons.
Problem 1: chunks bring their own copy of the internals
Shared packages are code-split. An entrypoint doesn't contain all its code; it imports internal chunks from its own
build. A borrowed entrypoint therefore brings a second copy of the package's internals along:
flowchart LR subgraph code["1 · code on the page"] hostApp["host app<br/>bootstraps Angular"] cmp["mfe1 component<br/>inject() + toSignal()"] end subgraph map["2 · import map"] kCore["@angular/core"] kInterop["@angular/core/rxjs-interop"] end subgraph files["3 · entrypoint files"] subgraph hostB["host build"] core["core.js"] end subgraph mfeB["mfe1 build (borrowed)"] interop["rxjs-interop.js"] end end subgraph chunks["4 · internal chunks"] chA["host: chunk-A.js<br/>Angular runtime #1<br/>holds the injection context"] chB["mfe1: chunk-B.js<br/>Angular runtime #2<br/>knows no injection context"] end err["NG0203<br/>inject() must be called<br/>from an injection context"] hostApp --> kCore cmp --> kCore cmp --> kInterop kCore --> core kInterop --> interop core --> chA interop --> chB chB -. "toSignal() calls inject()<br/>on runtime #2" .-> err classDef borrowed fill:#fde2e1,stroke:#c0392b,color:#000 classDef error fill:#c0392b,stroke:#c0392b,color:#fff class kInterop,interop,chB borrowed class err errortoSignal()now runs against a different Angular runtime than the rest of the component:NG0203. Worse, the pagerenders fine at first. It only breaks once the code path that uses the borrowed entrypoint runs, so it can easily
slip past testing.
Problem 2: a borrowed file uses the lender's dependencies
The import map resolves a bare import by the URL of the file that imports it, not by the micro frontend that started
the import chain. A borrowed file therefore resolves its own imports through the lender's scope:
flowchart LR subgraph code["1 · code on the page"] cmp["mfe2 component<br/>imports @my-org/lib/feature<br/>and rxjs"] end subgraph map["2 · import map"] subgraph global["imports (global)"] gFeature["@my-org/lib/feature"] gRx["rxjs"] end subgraph scope["scope: mfe1's URL"] sRx["rxjs"] end end subgraph files["3 · files"] subgraph hostB["host build"] rx7file["rxjs.js (7.8)"] end subgraph mfeB["mfe1 build (borrowed)"] feature["feature.js"] rx6file["rxjs.js (6.6)"] end end subgraph runs["4 · what runs"] rx7["rxjs 7<br/>what mfe2 asked for"] rx6["rxjs 6<br/>what mfe2 gets via the feature"] end err["mfe2 mixes rxjs 6 and 7:<br/>operators and Observables<br/>from two instances"] cmp --> gFeature cmp --> gRx gFeature --> feature gRx --> rx7file feature -. "its import of rxjs resolves<br/>by its own URL" .-> sRx sRx --> rx6file rx7file --> rx7 rx6file --> rx6 rx6 -.-> err rx7 -.-> err classDef borrowed fill:#fde2e1,stroke:#c0392b,color:#000 classDef error fill:#c0392b,stroke:#c0392b,color:#fff class gFeature,feature,sRx,rx6file,rx6 borrowed class err errormfe2 asked for rxjs 7, but everything it reaches through the borrowed file runs on rxjs 6.
Stitching relied on "same version = same files". In a real portfolio we measured, 7 of 24 (package, version) pairs
shipped different file sets across builds. Same version doesn't mean same artefact.
The rule in 4.8.0
causes the fewest extra downloads and the fewest fallbacks.
poollabel),that's the whole pool: e.g. all of
@angular/*.initRemoteEntry) andgetSharedfollow the same rule.flowchart LR subgraph host["host build"] core["@angular/core"] end subgraph mfe1["mfe1 build"] core1["@angular/core"] interop1["@angular/core/rxjs-interop"] end h["host"] --> core m2["mfe2: only imports @angular/core"] --> core m1["mfe1: imports rxjs-interop too"] --> core1 m1 --> interop1The trade-off: coherence over downloads
Every remote now runs each package coherently, from one build. The price is paid where the shared build doesn't cover
a remote:
over 340 generated portfolios: remotes running a package from two builds went from 40 to 0, for about +0.4%
downloads. On a real eleven-remote Angular portfolio, the
@angular/*files loaded at init went from 54 (stitched)to 63; still fewer than the 75 of 4.7.0.
host's. That's coherent for the remote itself, but its components can't be rendered inside the host's component tree
(which shares DI with the host's instance). Before, the page looked like it shared one Angular and then broke on a
code path; now the duplicate is real and visible in the record and logs.
fall back, the import map can grow up to twice as large and a cold init at 100 remotes takes about 1.3–1.4× as
long. Without fallbacks, the import map is unchanged and init is about 5 ms slower at 100 remotes.
All three costs come from the same thing: builds that publish different entrypoint sets of the same package.
Remove that, and nothing has to fall back.
Why flat externals make this worse
Native Federation can describe shared packages in
remoteEntry.jsonin two ways:flags.
@angular/coreand@angular/core/rxjs-interopare two unrelated entries; the orchestrator has to piece thepackage together from the specifier names.
features.denseExternals: true): one object per package, with anentriesmap of all itsentrypoints and one set of metadata.
Under stitching, flat entries were mostly harmless: each entrypoint was negotiated on its own and gaps were patched
by borrowing. Under "one build per package", any disagreement between the loose parts splits the package, and a
split package falls back as a whole:
strictVersionrange rejects the shared version moves the whole package to the remote'sown build, even where the serving build ships both;
makes the build look like it ships the package at two versions, so it can't serve anyone else and runs the package
from its own build;
poollabel on one flat entry has to be extended to its siblings by name;With dense externals a package is one unit with one version and one range, so the orchestrator decides it once.
How to avoid fallbacks
Two settings in your
federation.config.mjs, applied to every build in the portfolio, the host first:denseExternals: truepublishes every package as one dense object (see above).includeSecondaries: { keepAll: true }on@angular/*publishes every secondary entrypoint of each Angularpackage the build uses, not only the ones it happens to import. Every build on the same Angular version then ships
the same entrypoints, so the host's build covers every remote and nobody has to fall back. Use it for tightly
coupled framework packages (Angular, your design system); leave it off for utility libraries whose secondaries are
independent.
build: 'package'bundles each package with its own secondaries, so a package's chunks belong to that packageonly.
Note
Wildcard patterns like
'@angular/*'in.patch()are supported since Native Federation 4.8.0. They match thedependency names in your
package.json, and a secondary entrypoint inherits its package's patch. On older versions,.patch()only takes exact package names, so list every package individually:Remotes that render inside the host's component tree should stay on the host's build: align their versions and
ranges with the host's.
Can't rebuild every remote yet?
convertFlatSharedInfoFor remotes that still publish flat externals (an older build, or one owned by another team), the orchestrator can
densify their
remoteEntry.jsonwhen it fetches it:It groups each flat secondary entrypoint under its parent package, so
@angular/core/rxjs-interopjoins@angular/coreas one dense external, just like adenseExternalsbuild would publish it. Remotes that arealready dense pass through unchanged, so you can switch it on while you migrate your builds one at a time.
It only regroups what the build declared:
version,requiredVersion,strictVersion,singleton,shareScope,pool, …). An entrypoint stamped with another version than its root stays a separateexternal and still splits the package;
keepAllin the builds to avoid fallbacks.Rebuilding with
denseExternalsremains the real fix;convertFlatSharedInfobridges the remotes you can't rebuildyet.
Shared mappings: name them like packages
The same rule applies to your own monorepo libraries (
sharedMappings). Neither Native Federation nor theorchestrator has a
package.jsonto tell them which entrypoints belong together, so they go by the name:@my-org/shared/uiand@my-org/shared/utilare two entrypoints of one package,@my-org/shared. That decideswhat
build: 'package'bundles together, whatdenseExternalsgroups, and, since 4.8.0, what the orchestratorserves from one build.
This matters most when your libraries carry their own versions (
mappingVersion, on by default, stamps every mappingwith the version of its nearest
package.json):@my-org/shared/ui1.0.0@my-org/shared@my-org/shared/util2.0.0@my-org/sharedTo the orchestrator, that build ships one package at two versions. It can't serve that as one package to anyone else,
so the remote runs the whole package from its own build. Name independently versioned libraries as separate packages
instead (
@my-org/ui,@my-org/util), and use subpaths only for entrypoints of one library that are versionedtogether.
Names that don't follow npm package naming (
@shared,~app/utils,@My-Org/UI) have no package to be groupedunder. Native Federation 4.8.0 now warns about them at build time:
With
features.strictImports: true(see below) this warning fails the build.New in Native Federation 4.8.0:
strictImportsWith stitching gone, an entrypoint a build imports but doesn't declare in
remoteEntry.jsonis never filled in fromanother build: the import simply doesn't resolve at runtime. This happens when
ignoreUnusedDepsmisses a dynamicimport(). Angular'sprovideAnimationsAsync()is the known case: it loads@angular/animations/browserdynamically,the default build prunes it, and the host's animations silently never run.
Native Federation 4.8.0 now checks this at build time. After writing
remoteEntry.json, it scans every publishedfile (shared bundles, mappings, exposed modules, chunks) and warns about every bare import that
remoteEntry.jsondoesn't declare. Turn the warning into a build error with:
Fix a reported import with
includeSecondaries: { keepAll: true }on its package, or by removing it fromskip.Roadmap
Until v5, flat externals keep working as they do in 4.8.0. We recommend switching to
denseExternalsnow: it'sbackwards compatible (the orchestrator reads both shapes, so you can migrate one build at a time) and it's what v5
will expect.
Feedback
We'd like to hear from you:
enough?
debug state dump) so we can see why.
Background and measurements: native-federation/orchestrator#102.
All reactions