Summary
The MFE extensions-domain implementation has a hard-wired "zero or exactly one mounted extension per domain" invariant baked into the state model, the public registry API, the framework Redux slice, and several React hooks.
This was never an intended design constraint. It was introduced silently during implementation. It blocks valid use cases like a widgets extensions domain where multiple widgets (extensions) are expected to be mounted simultaneously.
Expected behavior
An extensions domain must be able to host N simultaneously mounted extensions (where N >= 0). A FrontX project should be able to define, for example, a widgets domain where several widget extensions render side by side, and the framework/state/hooks must correctly reflect the full set of currently mounted extensions.
The screen domain's swap semantics ("exactly one at a time") is a legitimate per-domain policy, not a framework-wide invariant.
Actual behavior
The entire stack assumes a single mounted extension per domain, even for toggle-semantics domains (sidebar/popup/overlay) where MountExtToggleHandler does not force an unmount of the previous extension. Two toggle mounts both physically render, but the framework bookkeeping silently drops all but the most recent one.
Evidence (trace through the stack)
1. Domain state is a scalar. packages/screensets/src/mfe/runtime/extension-manager.ts:28
/** Currently mounted extension ID (single extension per domain invariant) */
mountedExtension: string | undefined;
2. Mount unconditionally overwrites the scalar. packages/screensets/src/mfe/runtime/default-mount-manager.ts:289-290
// Track mounted extension in domain (single extension per domain invariant)
this.extensionManager.setMountedExtension(extensionState.extension.domain, extensionId);
No branching on swap vs toggle — both paths overwrite.
3. Public registry API returns a single value.
ScreensetsRegistry.getMountedExtension(domainId): string | undefined (ScreensetsRegistry.ts:227)
- Docstring: "Each domain supports at most one mounted extension at a time." (
extension-manager.ts:135)
4. Framework slice mirrors the same single-valued shape. packages/framework/src/plugins/microfrontends/slice.ts:26
mountedExtensions: Record<string, string | undefined>; // one ID per domainId
Reducers setExtensionMounted / setExtensionUnmounted only accept/clear one ID per domain. selectMountedExtension(state, domainId) returns string | undefined.
5. Plugin sync wrapper is single-valued. packages/framework/src/plugins/microfrontends/index.ts:137-157 — compares a single mountedBefore to a single getMountedExtension(...) after each chain, then dispatches setExtensionMounted / setExtensionUnmounted. A second simultaneous mount in the same domain is invisible to the store.
6. React hooks baked into the same assumption.
useActivePackage (packages/react/src/mfe/hooks/useActivePackage.ts:83) calls getMountedExtension(HAI3_SCREEN_DOMAIN) and treats the result as the active extension.
Reproduction (conceptual)
- Register a custom
widgets extensions domain that declares both mount_ext and unmount_ext (toggle semantics).
- Register two widget extensions A and B in that domain.
mount_ext(A) then mount_ext(B) via executeActionsChain.
- Observe:
- Both A and B physically mount (extension
mountState === 'mounted', Shadow DOM live in each extension's container).
registry.getMountedExtension(widgetsDomainId) returns only B.
- Store
state.mfe.mountedExtensions[widgetsDomainId] equals B only; A is invisible.
selectMountedExtension, useActivePackage, and any consumer of these APIs misreport the domain as containing a single widget.
unmount_ext(A):
- A is physically unmounted.
- The guard at
default-mount-manager.ts:383 (if (domainState.mountedExtension === extensionId)) is false, so the scalar is not cleared — consistent with reality (B is still mounted), but only by accident.
Impact
- Multi-mount domains (widgets, dashboards, card stacks, tag clouds, any "container of siblings") cannot be modeled correctly.
- Toggle-semantics domains appear to work in the DOM but silently lie about their state.
- Consumers of
getMountedExtension / selectMountedExtension / useActivePackage cannot enumerate what is actually mounted.
Suggested direction (not a design decision, just pointers)
Widen the tracking from scalar to set at every layer:
ExtensionDomainState.mountedExtension: string | undefined → mountedExtensions: Set<string>.
getMountedExtension(domainId) → add getMountedExtensions(domainId): string[] (keep old for swap domains or deprecate).
- Slice
mountedExtensions: Record<string, string | undefined> → Record<string, string[]> (or Set serialized).
- Plugin sync wrapper needs to diff sets, not compare scalars.
useActivePackage needs a policy: which extension's package is "active" when multiple are mounted? This question only has an answer in swap-semantics domains (screen) — for toggle/multi domains the hook contract itself needs revisiting.
Branch: fix/migration_to_mf2 (observed on this branch; invariant is pre-existing).
Summary
The MFE extensions-domain implementation has a hard-wired "zero or exactly one mounted extension per domain" invariant baked into the state model, the public registry API, the framework Redux slice, and several React hooks.
This was never an intended design constraint. It was introduced silently during implementation. It blocks valid use cases like a
widgetsextensions domain where multiple widgets (extensions) are expected to be mounted simultaneously.Expected behavior
An extensions domain must be able to host N simultaneously mounted extensions (where N >= 0). A FrontX project should be able to define, for example, a
widgetsdomain where several widget extensions render side by side, and the framework/state/hooks must correctly reflect the full set of currently mounted extensions.The screen domain's swap semantics ("exactly one at a time") is a legitimate per-domain policy, not a framework-wide invariant.
Actual behavior
The entire stack assumes a single mounted extension per domain, even for toggle-semantics domains (sidebar/popup/overlay) where
MountExtToggleHandlerdoes not force an unmount of the previous extension. Two toggle mounts both physically render, but the framework bookkeeping silently drops all but the most recent one.Evidence (trace through the stack)
1. Domain state is a scalar.
packages/screensets/src/mfe/runtime/extension-manager.ts:282. Mount unconditionally overwrites the scalar.
packages/screensets/src/mfe/runtime/default-mount-manager.ts:289-290No branching on swap vs toggle — both paths overwrite.
3. Public registry API returns a single value.
ScreensetsRegistry.getMountedExtension(domainId): string | undefined(ScreensetsRegistry.ts:227)extension-manager.ts:135)4. Framework slice mirrors the same single-valued shape.
packages/framework/src/plugins/microfrontends/slice.ts:26Reducers
setExtensionMounted/setExtensionUnmountedonly accept/clear one ID per domain.selectMountedExtension(state, domainId)returnsstring | undefined.5. Plugin sync wrapper is single-valued.
packages/framework/src/plugins/microfrontends/index.ts:137-157— compares a singlemountedBeforeto a singlegetMountedExtension(...)after each chain, then dispatchessetExtensionMounted/setExtensionUnmounted. A second simultaneous mount in the same domain is invisible to the store.6. React hooks baked into the same assumption.
useActivePackage(packages/react/src/mfe/hooks/useActivePackage.ts:83) callsgetMountedExtension(HAI3_SCREEN_DOMAIN)and treats the result as the active extension.Reproduction (conceptual)
widgetsextensions domain that declares bothmount_extandunmount_ext(toggle semantics).mount_ext(A)thenmount_ext(B)viaexecuteActionsChain.mountState === 'mounted', Shadow DOM live in each extension's container).registry.getMountedExtension(widgetsDomainId)returns onlyB.state.mfe.mountedExtensions[widgetsDomainId]equalsBonly; A is invisible.selectMountedExtension,useActivePackage, and any consumer of these APIs misreport the domain as containing a single widget.unmount_ext(A):default-mount-manager.ts:383(if (domainState.mountedExtension === extensionId)) is false, so the scalar is not cleared — consistent with reality (B is still mounted), but only by accident.Impact
getMountedExtension/selectMountedExtension/useActivePackagecannot enumerate what is actually mounted.Suggested direction (not a design decision, just pointers)
Widen the tracking from scalar to set at every layer:
ExtensionDomainState.mountedExtension: string | undefined→mountedExtensions: Set<string>.getMountedExtension(domainId)→ addgetMountedExtensions(domainId): string[](keep old for swap domains or deprecate).mountedExtensions: Record<string, string | undefined>→Record<string, string[]>(orSetserialized).useActivePackageneeds a policy: which extension's package is "active" when multiple are mounted? This question only has an answer in swap-semantics domains (screen) — for toggle/multi domains the hook contract itself needs revisiting.Branch:
fix/migration_to_mf2(observed on this branch; invariant is pre-existing).