Skip to content

Runtime import cycles between internal modules break Module Federation shared singletons #10770

Open

Description

@BrknRules

Provide a general summary of the issue here

Several internal modules in react-aria-components, @adobe/react-spectrum and @react-spectrum/ai import each other at runtime, and these cycles are preserved in the published per-file dist output. Plain ESM tolerates them, but they break a Module Federation (webpack 5 / rspack) host/remote setup that shares internal modules by resolved path: loading any module in a cycle recurses until RangeError: Maximum call stack size exceeded.

The common pattern is that a parent component's file defines the context its children consume, and also imports those children.

🤔 Expected Behavior?

There are no runtime import cycles between internal modules, so each dist/private/* file can be loaded in dependency order and shared as a singleton on its own.

😯 Current Behavior

These cycles exist on current main (9b7a06e). I only counted runtime imports, so import type and imports used only as types were left out. Each edge listed below closes its loop:

# Package Files Imports that form the loop In built dist?
1 react-aria-components src/Dialog.tsx, src/Popover.tsx, src/Menu.tsx Dialog imports PopoverContext from ./Popover and RootMenuTriggerStateContext from ./Menu; Popover and Menu import OverlayTriggerStateContext from ./Dialog (Menu also imports PopoverContext from ./Popover) Yes
2 @adobe/react-spectrum src/table/TableViewBase.tsx, src/table/Resizer.tsx, src/table/InsertionIndicator.tsx, src/table/RootDropIndicator.tsx TableViewBase imports Resizer/ResizeStateContext, InsertionIndicator and RootDropIndicator; those import useTableContext (and Resizer also imports useVirtualizerContext) from ./TableViewBase Yes
3 @adobe/react-spectrum src/list/ListView.tsx, src/list/ListViewItem.tsx, src/list/InsertionIndicator.tsx, src/list/RootDropIndicator.tsx ListView imports the three children; each imports ListViewContext from ./ListView Yes
4 @react-spectrum/ai src/PromptField.tsx, src/AttachmentList.tsx PromptField imports AttachmentList and related exports; AttachmentList imports getMimeTypeLabel/matchMimeType from ./PromptField Yes
5 @react-spectrum/ai src/ResponseStatus.tsx, exports/index.ts ResponseStatus imports PixelLoader from its own package barrel ../exports, which re-exports ResponseStatus No, only in source (Parcel resolves the re-export)
6 @react-aria/test-utils src/user.ts, src/index.ts user.ts imports pointerMap from its own barrel './', which re-exports User No, only in source

The same edges appear in the published packages, for example:

// react-aria-components@1.22.1/dist/private/Popover.mjs
import {OverlayTriggerStateContext} from "./Dialog.mjs";
// react-aria-components@1.22.1/dist/private/Dialog.mjs
import {PopoverContext} from "./Popover.mjs";
import {RootMenuTriggerStateContext} from "./Menu.mjs";
// @adobe/react-spectrum@3.47.7/dist/private/table/Resizer.mjs
import {useTableContext, useVirtualizerContext} from "./TableViewBase.mjs";
// @adobe/react-spectrum@3.47.7/dist/private/list/ListViewItem.mjs
import {ListViewContext} from "./ListView.mjs";
// @react-spectrum/ai@0.5.1/dist/private/AttachmentList.mjs
import {getMimeTypeLabel, matchMimeType} from "./PromptField.mjs";

I also scanned react-aria, react-stately, the @react-aria/*, @react-stately/* and @react-spectrum/* packages, and @react-spectrum/s2. They have no other runtime cycles. The one remaining cycle I found is in @internationalized/date (CalendarDate ↔ manipulation/conversion/queries/string). That one is already tracked in #9927 / #6394. It's structural: the date classes call functions that construct those classes. I've left it out of this proposal.

Why plain ESM hides these cycles: ESM links every module in a cycle before it evaluates any of them, and the bindings are live. Here the imported contexts and hooks are only read inside components at render time, and by then every module has finished evaluating. Module Federation behaves differently. When a shared module is consumed, its own shared dependencies are loaded synchronously, and a consume is only marked as in flight after its factory returns. So if two shared modules import each other, A → B → A recurses until the stack overflows.

💁 Possible Solution

The fix is a small refactor that keeps behaviour the same:

  • Move the shared context (and any tiny hooks that only read it) into its own leaf module that both sides import.
  • Re-export it from the original module, so export names, the package exports maps and the public API stay the same.
  • The context object is still created once, so runtime behaviour doesn't change.

Per cycle:

  1. RAC: move OverlayTriggerStateContext into src/OverlayTriggerStateContext.ts. Popover and Menu import it from there, and Dialog re-exports it. This alone breaks the whole Dialog/Popover/Menu cycle.
  2. RSP table: move TableContext, useTableContext, VirtualizerContext, useVirtualizerContext and the TableContextValue type into src/table/TableContext.ts, and re-export them from TableViewBase.
  3. RSP list: move ListViewContext into src/list/ListViewContext.ts, and re-export it from ListView.
  4. AI: move matchMimeType/getMimeTypeLabel into src/mimeTypes.ts, and re-export them from PromptField.
  5. and 6. Import PixelLoader from ./loader/react and pointerMap from ./userEventMaps directly, instead of from the package barrels.

I'll open a draft PR with these changes. If you'd like, a follow-up could add a CI check that fails on new runtime import cycles, for example madge --circular or a small script over src that skips type-only imports. I've kept that out of the PR.

🔦 Context

We run React Spectrum in a Module Federation host/remote setup that shares internal modules by resolved path, for example react-aria-components/dist/private/Dialog.mjs. This gives the host and every remote a single instance of each React context and of module-level state. Today we work around the cycles by leaving the cyclic files unshared and moving their top-level createContext(...) calls into virtual modules with a custom bundler loader. We'd rather drop that workaround.

🖥️ Steps to Reproduce

  1. Inspect the published files listed above (for example react-aria-components@1.22.1/dist/private/Popover.mjs and Dialog.mjs). Each one imports the other.
  2. In a webpack 5 or rspack Module Federation host and remote, add both modules of any pair above to shared as separate entries, keyed by resolved path. Then render a component that uses them.
  3. Module loading fails with RangeError: Maximum call stack size exceeded.

Version

react-aria-components 1.22.1, @adobe/react-spectrum 3.47.7, @react-spectrum/ai 0.5.1 (first observed with react-aria-components 1.19.0 / @adobe/react-spectrum 3.47.2)

What browsers are you seeing the problem on?

Chrome, Firefox, Safari, Microsoft Edge (the failure happens at module-loading time and doesn't depend on the browser)

If other, please specify.

No response

What operating system are you using?

macOS (doesn't depend on the OS)

🧢 Your Company/Team

No response

🕷 Tracking Issue

No response

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions