Skip to content

Extensions domains silently enforce single-mount invariant; multi-widget domains unsupported #278

@GeraBart

Description

@GeraBart

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)

  1. Register a custom widgets extensions domain that declares both mount_ext and unmount_ext (toggle semantics).
  2. Register two widget extensions A and B in that domain.
  3. mount_ext(A) then mount_ext(B) via executeActionsChain.
  4. 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.
  5. 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 | undefinedmountedExtensions: 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).

Metadata

Metadata

Assignees

Labels

bugSomething isn't workingmfesIssues related to MFEs implementation.

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions