Skip to content

feat(63): Pooling all-or-nothing calculation - #64

Open
Aukevanoost wants to merge 23 commits into
mainfrom
issues/63
Open

feat(63): Pooling all-or-nothing calculation#64
Aukevanoost wants to merge 23 commits into
mainfrom
issues/63

Conversation

@Aukevanoost

@Aukevanoost Aukevanoost commented Jul 30, 2026

Copy link
Copy Markdown

Closes #63.

The defect

With useAutoExternalPooling: true, a monorepo family whose members are each individually compatible
could still be served from two different builds at two different versions. A remote consuming both then
ran a mismatched framework family and crashed at runtime.

Two reproductions, both now regression-tested:

  1. A strict pin drags one member down. mfe-b pins core to ~22.0.5, so core resolves down to
    22.0.5; router has no such pin and only mfe-a provides it, so it resolves to 22.1.0. mfe-a's
    own core@22.1.0 is deduped away ⇒ mfe-a runs router@22.1.0 against core@22.0.5.
  2. Host precedence. The host ships core@22.0.5 and no router. core pins to the host's tag while
    router resolves freely from mfe-a — same mixed family, and the most common real-world trigger.

This is a regression from #56, not a pre-existing hole: #50's anchor model kept every remote on a
single build. #56 bought back the downloads that cost, by dropping the lockstep guarantee.

The fix — an agreement gate over determine's winners

Pooling still never re-runs the compatibility search, and it now explicitly never moves a winner:
host precedence and requiredVersion acceptance stay settled by determine before pooling runs, and
pooling grants no dedup the resolver did not already grant. It only decides, per remote, whether that
remote may take the dedups it was granted. Two gates:

  1. Strict incompatibility (unchanged since fix(55): out-of-bound version were incorrectly scoped #56): a remote the resolver marked scope on any member
    serves its whole family from its own build.
  2. Agreement, at minor granularity (new): a remote may draw on several builds — patch drift inside
    a family is normal — but not on builds that disagree. Two builds agree when every member both ship
    sits on the same minor line: 22.0.6 beside 22.0.8 is fine, 22.0.5 beside 22.1.0 is not.
    Disjoint builds agree vacuously, which keeps ragged coverage cheap. Islanding is monotone, so the gate
    iterates to a fixed point, re-entered only after a round that scoped someone.

Both cases are fixed with host precedence fully intact — in case 2 the host keeps its pin and mfe-a
gives way. The same gate is mirrored on the dynamic path (additive only: the committed import map is
immutable, so the loaded remote is the only thing that can move).

Why tag distance rather than requiredVersion: the resolver has already applied every declared range per
external, and the range cannot discriminate anyway — in the crash case and in the safe ragged case it is
the same ^22.0.0, accepting both tags. The minor line is the only signal in the data that separates them.

Also in this branch — the download objective, counted per copy

Measuring the gate surfaced a second, independent defect in the resolver, filed as follow-up F-A. Its
measured cause is fixed here; a narrower residual stays open and is described further down.

A scope verdict serves every one of a version's remotes from that remote's own build, so it costs one
download per uncached copy. The resolver's extra-download objective counted scoped versions — exact
only when every version has a single remote.

Under pooling that miscount was severe. Adding one previous-major remote to the capture (eight, Angular
21.2.15, honestly pinned, in conflict with nobody) made @angular/router's single 22.0.8 version —
shared by three agreeing remotes — cost 2 against each of two patch-drifted 21-line versions costing 1.
The winner moved to the 21 line, the three modern remotes' router@22.0.8 became strictly incompatible,
and all-or-nothing islanding amplified one member's mis-election into all 24 members: 64 downloads, 5 of
8 remotes islanded
.

The objective now weights each scoped version by its uncached copy count. Since the shared version costs
one download whichever candidate wins, minimising that sum is exactly minimising total downloads for the
external — the same objective the docs always claimed, in the right unit. That portfolio now resolves to
45 downloads and 2 islands, both genuinely cross-major remotes; every other portfolio below is
byte-identical.

Behaviour change: a larger group of remotes on an older tag can now win over a smaller group on a
newer one. That is what "fewest extra downloads" asks for, and under pooling it is the coherent direction,
but it is user-visible. Ties still break toward fewest torn entrypoints and then the newest tag — they are
simply rarer than when the cost was counted per version.

Cost: none. determine medians 0.262 / 0.479 / 0.279 ms against 0.259–0.315 / 0.469–0.516 /
0.298–0.311 ms before — noise — and isCompatible calls are identical (11 / 28 / 14), so no new calls to
the version-check port. One O(versions) map per multi-version external, built outside the
O(versions²) selection loop; the single-version short-circuit (12 of 18 externals on the capture) is
untouched. Host precedence and profile.latestSharedExternal short-circuit before the objective and are
unaffected.

Measured, on a 7-remote production capture and four variants

portfolio downloads (determine → pooled) shared members islands
captured 7 36 → 36 24 → 16 1
all 11 42 → 45 24 → 16 2
7 + Ng21 sibling 42 → 45 24 → 16 2
7 + older superset 36 → 36 24 → 16 1
7 + strict pin 36 → 36 24 → 16 1

The 7 + Ng21 sibling row is the only one the copy-weighted objective moves: it read 43 → 64, 24 → 14
and 5 islands with the gate alone.

Three things worth knowing, all in the release notes below:

  • Pooling buys coherence, not downloads. On every portfolio measured it left the download count
    unchanged or increased it — never reduced it. What it removes is the incoherence: shared sets spanning
    majors {21, 22} collapse to {22}, and packages split across two tags
    (@angular/forms shared at both 21.2.18 and 22.0.8) disappear. That is the crash being fixed.
  • The new gate fires on no real portfolio. Zero disagreement islands across all five; every island is
    a range incompatibility, i.e. the pre-existing gate. So real-world exposure is small — and so is
    real-world validation. The minor-line mechanism's measured effect lives in the two synthetic repro arms.
  • No remote dedups onto a tag its own requiredVersion rejects (0 violations, all portfolios).

Performance

Two wins, both measured with the same probe toggled in place:

before after
pooling step, healthy portfolio (cold) 0.336 ms, 16 storage writes 0.13–0.165 ms, 0 writes
pooling step, warm init (nothing changed) 0.218 ms — 45% of a 0.48 ms warm init 0.002 ms
the gate's own cost, captured 7 (cold) +0.05–0.12 ms

A pool that islands nobody now writes nothing at all, and determine hands pooling the set of externals
it re-elected so a warm init skips pooling entirely. The gate compares tags only — no versionCheck
dependency, no acceptance table, no scoring — and short-circuits before touching builds when a single
build already serves every member.

The copy-weighted objective adds nothing measurable to determine (medians within noise, identical
isCompatible counts) — see the section above.

F-A, the islanding cascade: cause fixed, narrower residual left open

The cascade was pre-existing #56 behaviour surfaced by measurement, not introduced here. Its measured
cause — the objective's unit — is fixed above, which is what takes 7 + Ng21 sibling from 64 downloads and
5 islands to 45 and 2.

What remains is narrower: the objective is exact per external, but it is still evaluated per external.
Two members of one pool whose remote-count majorities sit on different version lines still elect opposite
winners, and pooling still amplifies the split. islanding-cascade.regression.spec.ts holds two specs for
the fixed behaviour and one labelled characterisation for this residual, so a future fix reads as a
deliberate change. F-A-islanding-cascade.md carries the two remaining avenues (a pool-aware objective, or
letting pooling veto rather than re-point) and, importantly, two measured dead ends so neither is
retried blind:

  • The family-instance election built and reverted on this branch (see the review note below).
  • Letting the agreement gate subsume the incompatibility gate — an incompatible remote self-serves only
    the members it rejects and islands only if its own build disagrees with the builds it still draws on.
    Measured, this removes bug: pooling should be on exact versions #63's guarantee outright: 0 islands on every portfolio, majors={21,22}, the split
    packages back (@angular/forms{21.2.18,22.0.8}), and 14 pooling tests red including
    pooling.integration.spec.ts's "no foreign framework runtime". A fully cross-major remote self-serves
    everything, so its draw set is {itself} and the gate passes it vacuously, while its sole-provided
    winners stay shared to the modern side. All-or-nothing islanding is load-bearing for coherence, not
    merely an amplifier.

Release notes (patch)

  • Fix: with useAutoExternalPooling (or a pool tag), a coupled family can no longer be served to
    one remote from two builds that disagree. Such a remote now serves its whole family from its own build.
  • Fix: the shared-version cost model counts extra downloads per remote copy rather than per
    version, which is what a scoped version actually costs. Portfolios where one version is shared by many
    remotes and its rivals by one each now resolve to the cheaper version — with pooling enabled this
    removes cascading islands (one measured portfolio: 64 → 45 downloads, 5 → 2 islanded remotes).
  • Behaviour change: pooled families may island where they previously mixed builds. This costs
    downloads and buys coherence — pooling did not reduce downloads on any measured portfolio.
  • Behaviour change: because extra downloads are now counted per copy, a larger group of remotes on an
    older tag can win the shared version over a smaller group on a newer one. Ties still break toward the
    newest tag. profile.latestSharedExternal opts out of the cost model entirely.
  • Host precedence is unchanged and remains absolute.
  • Performance: a warm init now does no pooling work; a pool that islands nobody performs no storage
    writes. The cost-model change is free (no extra compatibility checks).
  • Known limitation: F-A-islanding-cascade.md — per-external election can still split a pooled family
    when its members' majorities sit on different version lines.

Version left at 4.5.3 — preparing 4.5.4 is the usual separate chore: commit.

Notes for review

  • applyWinner was extracted from determine-shared-externals.ts in the first commit as a pure,
    spec-untouched refactor (778/778 green with zero spec edits), so the entrypoint-coverage policy from
    PR fix(61): Issue where asymmetric entries were silently dropped instead #62 has one implementation.
  • Commits efc5e70, fbfe4c9 and 4a4a4b7 build a family-instance election, which 4a4a4b7's
    successor reverts. It was built, measured and dropped: it changed the outcome of exactly one synthetic
    fixture, was worse on two portfolios until a coherence guard held it back, and cost 3–4× the pooling
    step on every init. The history is kept deliberately so the measurement is not repeated blind.
  • The copy-weighted objective re-authors one fixture,
    determine-shared-externals.spec.tswhen only a non-basis remote pinned the version: 2.2.2 gains a
    second copy so both candidates cost 2 and the newest wins the tie. With a single copy, 2.1.1 is simply
    the cheaper winner and nothing is redirected onto a tag a non-basis remote pinned — which is what that
    describe exists to test. The scenario is preserved; only the incidental tie is restored.
  • islanding-cascade.characterisation.spec.ts is renamed to islanding-cascade.regression.spec.ts, since
    two of its three tests now lock fixed behaviour rather than a known deficiency.
  • benchmark/ (the production capture and the three probes) is git-ignored — local measurement only.
  • The two working documents for this fix (research.md, plan.md) are removed in the final commit; they
    remain readable in this branch's history.

Adds the repro spec for #63 asserting the CURRENT (broken) behaviour: a pooled
monorepo family can be served from two builds at two versions, both when a strict
pin drags a member down and when host precedence pins one. Plus the design
(research.md, spec = §15 family-instance model) and the execution plan.
Pooling will re-point a member's winner onto a pooled family instance, which also
moves its remotes[0] serving basis - the thing applyEntrypointCoveragePolicy and
findTears key off (PR #62). Both callers must therefore run the same election tail
instead of pooling growing a second coverage rule or re-running determine.

Moves the verdict loop, the coverage policy, findTears and scopeTornRemotes into
2.app/steps/apply-winner.ts as createApplyWinner(config), plus versionAcceptance()
for the shared demands map. The optional acceptance argument lets pooling call it
with only determine's memoized isCompatible while determine reuses its own map.

No behaviour change: the suite passes unedited.
Pure, unit-tested building blocks for the family-instance model (research.md §15),
not yet wired into the step: buildInstances, consumedMembers, buildAcceptanceTable,
singleProviderMembers, canTakeAllFrom and hostPinnedTags.

buildInstances drops an islanded remote's whole instance rather than just its scope
versions - in the production capture the islanded Angular-21 remote is the sole
provider of animations, so skipping versions alone leaves animations@21.2.18 shared
next to core@22.0.8. research.md §15.3 now states this explicitly.

The acceptance table and the direct assignment of single-provider members are
mandatory precomputations, not optimisations: 4.4 ms vs 262 ms at R=50/M=80.
determine only scopes a rejecting version when some remote objected strictly, so a
remote declaring strictVersion:false is deduped onto an incompatible tag today by
its own choice. Testing requiredVersion alone would have made pooling stricter than
the resolver it defers to and islanded those remotes, costing more downloads than
the status quo. No pooling spec covers a loose remote, so the suite would not have
caught it. research.md §15.1 rule 4 qualified accordingly.

Also trims production comments to the necessary minimum.
poolFamily rebuilt and wrote back every member on every init, even when the pool
had nothing to island and the rebuild reproduced what storage already held. On a
coherent 6-remote portfolio from benchmark/ that is 16 redundant writes and ~55%
of the step (0.336 ms -> 0.13-0.165 ms); where a remote is islanded nothing
changes.

Islanding now warns once per (pool, remote) naming the member and tag that made
it impossible - it was silent at default log level, since determine sets
action='scope' without logging in non-strict mode. The derived scoped-only
warning is suppressed when an island in the same pass took the member's last
provider (research.md §11.4), so an operator gets the cause, not the effect.

No resolution changes. Side effect of skipping the rebuild: no-op pools no longer
reorder versions, which keeps store-remote-entry's newest-first order intact for
profile.latestSharedExternal.
Threads a shared isCompatible memo through determine and pooling (init.factory),
adds versionCheck to the step's ports, and elects the instance that serves the most
remotes entirely - coverage plus acceptance, since scoring 'is some mixed draw
acceptable' re-elects the very split families this fixes. Host pins and
single-provider members are seeded first and never scored.

Re-pointing goes through the shared election tail with strictExternalCompatibility
forced off (§11.5: an acceptance-driven scope is not a hard error), and splits a
version at remote granularity first: verdicts are per version but acceptance is per
remote, so one strict co-tenant would otherwise island every remote sharing its tag
(5 islands / 70 downloads vs 2 / 43 on captured 7 + eleven).

Case 1 of the repro now resolves the whole family from one build. NOT yet ticked in
plan.md: measured on benchmark/, election is neutral on 3 portfolios and worse on 2
(+7 downloads, +1 island), and on 7+eleven it leaves core@22.0.6 beside
core/primitives@22.0.8. See the BLOCKER note in plan.md iteration 4.
An instance may only serve a member if it ships every live member of that
member's package, at tags agreeing with anything already pinned. Entrypoints of
one package must come from one build - without this the extension pass fills the
siblings an elected instance does not ship from another instance, which on
benchmark/ produced @angular/core@22.0.6 beside @angular/core/primitives@22.0.8
and islanded the strictest consumer.

Measured on the five benchmark portfolios, election with the guard is identical
to current behaviour everywhere (it never fires) while still fixing repro Case 1;
without it, two portfolios lose 7 downloads and gain an island. Package
boundaries are not in DenseSharedInfo, so packageOf() follows the naming
convention - it can only restrict election, never widen it.

13 tests in elect-instance.spec.ts.
A remote that cannot take every member it consumes from the pooled builds now
scopes its whole family instead of mixing; a member whose share version lost all
its non-islanded copies counts as unserved. Scoping is monotone, so the pass
repeats after any round that scoped someone. Multi-build draws log at debug, never
warn (§15.1 rule 6), and the island warning names whether determine refused the
version or §15 acceptance did.

The healthy path builds no acceptance table: with nobody islanded and every member
shared, applyWinner's own skip rule already guarantees the test passes.

Perf work included: shared poolContext so instances/consumed/acceptance are built
once per pool, takeable groups precomputed per instance, and two gates before
scoring (objective already maxed; coverage-only upper bound on the objective).

NOT ticked in plan.md: the step is still 3-4x slower than iteration 3 (captured 7
cold 1.43-1.91 ms vs 0.34-0.42) and election accounts for most of it while changing
no real-portfolio outcome. See the BLOCKER note in plan.md iteration 5.
Reverts the election machinery (efc5e70, fbfe4c9, 4a4a4b7) and fixes the split
family with tag distance instead, per Auke's call: a remote may draw on several
builds - patch drift inside a family is normal - but not on builds that disagree,
meaning a member they both ship sits on a different minor line. Such a remote
serves its whole family from its own build. The gate iterates to a fixed point,
since islanding removes a serving build and can leave a member unserved.

Both repro cases are now fixed, including Case 2, which §15 had knowingly left
broken - and host precedence never bends: the host keeps its pin and the remote
that would mix builds gives way.

Measured on benchmark/: all five portfolios byte-identical to pre-fix behaviour
(the 22.0.6/22.0.8 shared sets are one minor line, the cross-major remotes were
already islanded), and the step costs +0.05-0.12 ms rather than election's 3-4x.
Pooling needs no versionCheck, no acceptance table and no scoring.
A remote loaded at runtime may dedup a pooled family only if the committed builds
serving its members agree - every member two of them both ship on the same minor
line. Otherwise it serves the whole family from its own build. Strictly additive:
the step still only rewrites its own actions, never committed versions.

Init already stops any remote from drawing disagreeing builds, but the committed
shared set can hold two that disagree when nothing so far consumed both (the
capture's forms@22.0.8 beside forms/signals@21.2.18). A remote loaded later is
exactly the consumer that would bridge them.

pool-dynamic-externals now takes sharedExternalsRepo; DI updated.
The probes are local testing tools, not part of the package. benchmark/ is now
git-ignored wholesale (fixtures were already untracked); plan.md and research.md
point at benchmark/probes/ so a later loop pass knows where they live.
Re-ran both probes against the five benchmark portfolios. Three findings the
docs had wrong or missing:

- Pooling never reduces downloads on a real portfolio (36/45/64/36/36 after,
  36/42/43/36/36 before). It buys coherence and pays downloads. \S13.3's
  'strictly better than today, everywhere' was a prototype claim that did not
  reproduce; \S13.3 and \S14.1 are now marked as such and \S16.1 holds the real table.
- The agreement gate fires on no real portfolio: every island is range
  incompatibility. Small blast radius, thin real-world validation.
- No remote dedups onto a tag its own requiredVersion rejects, which is why
  dropping the acceptance table lost nothing - determine already applies that
  test per external.

Also records follow-up F-A: one extra previous-major remote takes 7+eight from
36 to 64 downloads and islands 5 of 8. Neither election nor the gate fixes it,
but the gate is structurally subtractive and cannot, so it needs winner
re-pointing. Pre-existing #56 behaviour, does not block #63.

Corrected the stale spec framing throughout: status is implemented-through-6 not
designed-not-implemented, \S15.6's Case 2 limitation is retired, and plan.md's
hard-constraint preamble no longer describes the reverted election model.
Iterations 8-10 rewritten off the shipped behaviour.
determine clears `dirty` before pooling runs, so pooling had no way to tell
what changed and recomputed every pool on every init - 45% of a warm init
spent reproducing its own output.

determine now resolves to the externals it re-elected per shareScope, which
init.flow already threads into pooling. Pooling skips a scope with nothing
touched before reading it, and any pool no member of which was touched; a
touched member still processes its whole pool, since islanding one remote
scopes its untouched siblings.

Sound because pooling reads the `scope` verdicts it wrote itself: re-running
it over unchanged storage can only reproduce them. Warm pooling 0.218 ->
0.002 ms on the captured seven; cold unchanged.
Renames the #63 repro to a permanent regression spec and adds the three
fixture shapes the design argued about: patch drift inside one minor line is
tolerated rather than unified, a previous-major member leaves the shared set
when its only provider is islanded, and a clean subset consumer of an
asymmetric family is never islanded.

F-A gets its own characterisation file, and writing it corrected the
mechanism recorded in research.md 16.2. The cascade is not "sole-provided
members lose their provider, so consumers island": all five islands in
7+eight are range incompatibilities, and the three healthy Angular-22
remotes island on router@22.0.8 after determine moved that winner to the 21
line. Its extra-download objective counts versions, not remotes, so one 22
version shared by three remotes loses 2:1 to two patch-drifted 21 versions.
Pooling only amplifies it, so any fix belongs in the resolver's cost model -
still out of scope for #63.

Re-ran the outcome probe: every 16.1 row holds with W2 in place.
The pooling section still described the pre-#56 anchor model and claimed
coherence is a property of versions alone, "guaranteed by islanding". The
repro falsifies that: a split family contains no incompatibility, so
islanding never fires on it. Rewritten around what ships - determine's
winners stand, gate 1 islands strict incompatibilities, gate 2 stops a
remote drawing on builds that disagree at minor granularity, iterating to a
fixed point.

Adds the download trade (coherence, never fewer downloads), why the test is
tag distance and not requiredVersion, the authoring rule and where it
actually routes, the react/react-dom recipe for unscoped lockstep families,
a log-line table that says which lines need no action, and the dynamic-init
mirror.

Two claims corrected against the code: the serving basis is the first
non-islanded remote of the share version, and the dynamic gate compares only
committed builds. Also fixes the same overclaim in config.md and the stale
integration-spec docblock.
Records the islanding cascade with the mechanism as measured, not as first
assumed: determine's extra-download objective counts versions rather than
remotes, so two patch-drifted legacy remotes outvote three aligned modern
ones, and pooling's all-or-nothing islanding turns that one mis-election
into a whole family. Includes the measured tables, why a pooling-side fix
is impossible, three candidate avenues and acceptance criteria.

Also notes the limitation in version-resolver.md next to the cost model
itself, which claims to minimize download time and in this shape does not.
plan.md and research.md were the working documents for this fix; they stay
readable in this branch's history, and F-A-islanding-cascade.md points at
them for whoever picks that follow-up up.
A scoped version is served to every one of its remotes from that remote's
own build, so it costs one download per uncached copy — not one per
version, which is exact only when every version has a single remote. The
selection objective now weights each scoped version by that count,
precomputed outside the O(versions²) loop.

Since the shared version costs one download whichever candidate wins,
minimising that sum is exactly minimising total downloads for the
external: the same objective the docs already claimed, in the right unit.

This is follow-up F-A's measured cause. On the production capture, adding
one previous-major remote made @angular/router's single 22.0.8 version,
shared by three agreeing remotes, cost 2 against each of two patch-drifted
21-line versions costing 1; the winner moved to the 21 line and pooling's
all-or-nothing islanding amplified it into all 24 members. That portfolio
goes from 64 downloads and 5 of 8 remotes islanded to 45 and 2, both
genuinely cross-major. Every other measured portfolio is byte-identical,
and determine's timings and isCompatible counts are unchanged.

The non-basis-pin fixture gives 2.2.2 a second copy so both candidates
cost 2 and the newest wins the tie; with one copy 2.1.1 is simply the
cheaper winner and nothing is redirected onto a tag a non-basis remote
pinned, which is what that describe exists to test.

islanding-cascade.characterisation.spec.ts becomes
islanding-cascade.regression.spec.ts: two specs lock the fixed behaviour,
and one labelled characterisation keeps the residual — the election is
still per external, so members of one pool whose remote-count majorities
sit on different version lines still elect opposite winners.
version-resolver.md's Optimal Version Strategy now counts the cost per
remote copy, states that minimising it is exactly minimising downloads,
and carries the visible consequence: a larger group of remotes on an older
tag can win over a smaller group on a newer one. Ties still break toward
the newest tag, and host precedence and latestSharedExternal short-circuit
before the objective. Its known-limitation note narrows to the residual.

F-A-islanding-cascade.md flips to fixed-cause-with-residual and records
two measured dead ends so neither is retried blind: the family-instance
election reverted earlier on this branch, and letting the agreement gate
subsume the incompatibility gate. Measured, the latter removes #63's
guarantee outright — 0 islands on every portfolio, majors={21,22}, the
split packages back, and 14 pooling tests red including "no foreign
framework runtime" — because a fully cross-major remote self-serves
everything, so its draw set is {itself} and the gate passes it vacuously
while its sole-provided winners stay shared to the modern side. All-or-
nothing islanding is load-bearing for coherence, not merely an amplifier.
The e2e suite asserted on the import map the library *would* hand to the
browser: jsdom, a mocked fetch, and setImportMapFn replaced by a collector. It
could not observe the artefact actually being applied, which build a remote's
code ends up holding, or what the page downloads.

Playwright runs the published initFederation in Chromium instead. Nothing is
substituted: the map is installed by the library's own replaceInDOM, modules
load through its own loadModuleFn, storage is the browser's sessionStorage, and
only the logger is injected. Each remote is served over HTTP from its own origin
by one server dispatching on the Host header, with Chromium pointed at it via
--host-resolver-rules, so http://mfe-a/ is a real cross-origin remote at the
same URL the fixtures already used.

Four claims move from derived to measured. The map is parsed out of the
<script type="importmap"> elements in the document. Every remote's exposed
module statically imports every entrypoint its remoteEntry declares and reports
what each one resolved to, so a map that fails to cover a declaration fails the
test, and a dedup is confirmed by the consumer landing on the provider's file.
Download cost is what the server served and what the page evaluated - "pooling
buys coherence for one extra download" is now 2 requests against 3. A warm init
is a page load repeated against the same sessionStorage.

Restructured around the shape of a remoteEntry rather than the rule being
proved, one file per dimension: membership (npm scope, pool tag, shareScope,
singleton, entrypoints), ranges (the version fields), islands (verdict shape,
host precedence, the flag boundary), cost, chunks, lifecycle (warm, incremental,
dynamic, and how the browser treats a second import map) and capture. Every rule
the jsdom suite locked is carried over; the new cases are mostly ones only a
browser can answer.

Three of those corrected an assumption while being written:

- Chromium honours the additive second import map and merges it, so the dynamic
  path is exercised natively. A merge cannot replace an existing mapping, which
  is the browser-level reason the delta has to be additive; both halves are
  pinned, alongside the es-module-shims configuration.
- The unpooled capture is coherent per remote. Both tags are shared, but each
  remote gets the one its own range elected and the 21 half only reaches the
  islanded remote's scope, so the defect is in the shared set, not yet in the
  running page. The reachable crash is pinned in islands.
- shell splits @angular/platform-browser across two builds: the root from the
  elected 22.0.8 build, the /animations entrypoints self-filled from its own
  22.0.6 one. Intended - entrypoint-coverage documents sibling self-fill - and
  tolerable for the same reason patch drift between members is.

npm test stays vitest and unit-only; coverage thresholds never depended on the
e2e specs. npm run test-e2e is Playwright.
Every write goes through commit(), which sorts a shared external's versions
newest-first, and determine relies on that: it reads versions[0] as the latest
and keeps the first candidate of equal cost. Six pooling fixtures seeded the
opposite order, so their expectations encoded winners production cannot elect
and any tie-break change read as a regression.

The pooling specs' seed() helpers now sort through newestFirst(), and the
expectations are re-derived from sorted input. Two scenarios collapse under
sorted input and are re-authored on coverage asymmetry instead of tag order,
mirroring their e2e counterparts: patch-drift tolerance (mfe-a sole provider of
forms, so it really draws from two agreeing builds) and the asymmetric subset
consumer (the newer patch alternates per member, so the winners still split
across both builds). The tagged design-system fixture swaps its tags so the
incompatible consumer is the older remote, keeping the scenario reachable.

store-remote-entry.version-order.spec.ts asserts the invariant itself over
every scope, on both commit paths, so the assumption is checked rather than
implied. Dropping the sort from commit() fails 3 of its 5 tests.
The suite was one file per dimension of a remoteEntry, which put the feature
flag in five of them and left the input format almost uncovered. The before/
after argument for useAutoExternalPooling was spread across islands, capture,
entrypoints and lifecycle, while every generated portfolio emitted the same
shape: dense externals with a chunks property. Both flat forms are still in the
field - two of the eleven recorded fixtures use them - and convertFlatSharedInfo,
which decides how a flat entry is grouped, had no end-to-end coverage at all.

Files are now organized by what the portfolio looks like, since that is what
decides the verdict. flag is the only file that switches the flag: one portfolio
held fixed, on and off, for the split family it prevents, the sibling it stops
bridging, the two-major shared set it repairs, the dynamic path, and what it
costs (one download on the minimal case, nothing on the capture). symmetric
holds families whose remotes declare the same members, so only version lines
differ; asymmetric holds those whose member sets differ - containment, ragged
coverage, disjointness, sole-provided members and entrypoints leaving the shared
set with their island. ranges, islands, cost and chunks are gone and every
assertion in them moved; the two characterisations move verbatim.

vendor-shapes is new and covers the four shapes a build emits: externals dense
(entries) or flat (one element per entrypoint), crossed with chunking dense (a
chunks property) or flat (@nf-internal/chunk-* pseudo-externals). shape() in the
harness renders one declared portfolio into any of them, so a split family and a
ragged family are each driven through all four and must produce the same island,
the same map and the same builds per remote. A guard asserts the four served
documents really differ, so the loop cannot silently collapse onto one shape.

Three things only that matrix shows:

- convertFlatSharedInfo is load-bearing on a flat build. Off, @angular/material
  and @angular/material/sort are unrelated externals that elect separate
  providers, so the page runs two builds of one package and no gate reacts -
  issue #61's shape, unprotected. On, they regroup and the widest-remote basis
  serves the package whole. Both arms are pinned.
- Flat chunking maps a chunk into every declaring remote's scope, dense chunking
  only into the serving remote's. Same runtime result, different map; the flat
  extra mapping resolves and costs no download.
- A chunk pseudo-external never joins a pool, though @nf-internal looks exactly
  like an npm scope: non-singleton externals never reach the shared-externals
  repo, so buildPools cannot see them.

Origins and fixtures are anonymous and numbered: SCOPE is mfe1-mfe5 in the order
a portfolio declares its remotes, and the eleven recorded entries are mfe1-mfe11
(1-7 the production capture, 8-11 synthetic). What a name used to carry - the
cross-major outlier, the strict pin, the superset build - is now stated in the
spec docblocks and the two fixture READMEs, where it can be accurate.

New fixtures/pooling holds three entries in the one shape no capture has: flat
externals, flat chunking and pool tags together. Auto-pooling is off for them, so
the declared tag is the only thing forming the family; the incompatible remote
islands across the family while rxjs, in no pool, still dedups.

Also folds the entrypoint-coverage integration spec into entrypoints.e2e.spec.ts,
where the policy can be checked against a running page rather than a seeded
repository, and adds harness/coherence.ts for the whole-portfolio measures that
capture and flag both need. 97 tests, up from 76.
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.

bug: pooling should be on exact versions

1 participant