Skip to content

[feat] Sharing decision ignores transitive peer-dep versions, causing InjectionToken identity mismatch #81

Description

@muenchto

Problem

I have multiple Angular MFEs that expose web components, plus several of my own libraries. Consider two MFEs (MFE1, MFE2) and two libs (libBig, libSmall).

  • libBig defines an InjectionToken SOME_TOKEN (it only declares it, it does not provide it).
  • libSmall has libBig as a peer dependency and internally does inject(SOME_TOKEN).
  • Both MFE1 and MFE2 use libBig and provide SOME_TOKEN themselves.
  • I use shareAll, so I want to share as much as possible whenever versions match exactly.
  • MFE1 and MFE2 have the same version of libSmall, but different versions of libBig.

At runtime this breaks. MFE1 works: it provides SOME_TOKEN (from its libBig), and the shared libSmall injects it fine. But MFE2 provides SOME_TOKEN from its own libBig, while the shared libSmall still tries to inject the SOME_TOKEN from the other libBig. Those are two different token objects, so we get a NullInjectorError — the token libSmall is asking for is not the one MFE2 provided.

Note: building a full demo repo for this is not trivial (it needs two MFEs, two lib versions and the peer-dependency wiring). I'm happy to put one together if the description alone isn't enough to reproduce/understand it.

Why it breaks

flowchart TB
    subgraph SharedScope["Shared runtime (single import map)"]
        LS["libSmall@1.0.0<br/>(shared singleton — ONE instance)<br/>import { SOME_TOKEN } from 'libBig'"]
    end

    subgraph MFE1["MFE1"]
        B1["libBig@1<br/>defines SOME_TOKEN → TOKEN_OBJ_A"]
        P1["provide(SOME_TOKEN = TOKEN_OBJ_A)"]
    end

    subgraph MFE2["MFE2"]
        B2["libBig@2<br/>defines SOME_TOKEN → TOKEN_OBJ_B"]
        P2["provide(SOME_TOKEN = TOKEN_OBJ_B)"]
    end

    LS -. "import 'libBig' resolves ONCE<br/>to libBig@1 (winner)" .-> B1
    P1 -->|"provides TOKEN_OBJ_A"| B1
    P2 -->|"provides TOKEN_OBJ_B"| B2
    LS ==>|"inject(SOME_TOKEN)=TOKEN_OBJ_A ✅ found in MFE1"| P1
    LS ==>|"inject(SOME_TOKEN)=TOKEN_OBJ_A ❌ MFE2 has only TOKEN_OBJ_B → NullInjectorError"| P2

    style B1 fill:#d5f5d5
    style B2 fill:#f5d5d5
    style LS fill:#fff3cd
Loading

There is only one shared libSmall instance. Its import { SOME_TOKEN } from 'libBig' is resolved once, to a single libBig (the winner — MFE1's), because import-map scopes are keyed by the importing module's own URL, not by the caller. So libSmall.inject(SOME_TOKEN) always asks for MFE1's token object. Inside MFE2, the injector only has MFE2's token object → NullInjectorError.

I have a solution proposal — and why pooling (#49) doesn't seem to fully cover it

Pooling as described in #49 forces a group of externals to come from one coherent source. That helps when the members can actually be served from one anchor. But in my case the two libBig versions genuinely differ, so libBig can never collapse to one instance — there is nothing to pool. Pooling addresses "members of a group resolved from different remotes", whereas my problem is "a shared dependent (libSmall) is silently pinned to one specific version of a split dependency (libBig)". So it needs to propagate up the dependency graph, automatically.

The real issue is the sharing decision itself. Today it seems to decide shareability of libSmall from its own version number only: when MFE2 is loaded, NF sees libSmall at the same version as already loaded, and reuses it without re-wiring the imports. That assumption is wrong, because the already-loaded libSmall is wired against MFE1's libBig.

The fix should be: the identity used to decide whether libSmall is shareable must include the resolved versions of its (peer) dependencies, not just its own version. Concretely:

  1. When deciding if libSmall can be reused, also check its dependency closure (e.g. libBig) and compare the resolved instances across the consumers.
  2. If they all match → share as today.
  3. If even one dependency resolves differently (as libBig does here) → treat libSmall as not shareable for that consumer: load a second copy and re-wire its imports against MFE2's dependencies (i.e. scope it so its import 'libBig' resolves to MFE2's libBig).
  4. This should propagate transitively: a split in libBig "poisons" libSmall, and anything depending on libSmall in turn.

This does mean a singleton dependent effectively can't stay a singleton when one of its deps is version-split — which is unavoidable, but at least a warning there would already help.

Possibly related issues in the pre-v4 repo

While searching angular-architects/module-federation-plugin I found these, which look like the same underlying "duplicate token identity because a dependency exists as multiple instances" family:

Question

Is there anything I can already do in the pre-v4 versions to work around this (config, scoping, forcing libSmall to not be shared, etc.), while a proper fix is considered?

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