Repository navigation
Runtime import cycles between internal modules break Module Federation shared singletons #10770
Description
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
exportsmaps and the public API stay the same. - The context object is still created once, so runtime behaviour doesn't change.
Per cycle:
- RAC: move
OverlayTriggerStateContextintosrc/OverlayTriggerStateContext.ts.PopoverandMenuimport it from there, andDialogre-exports it. This alone breaks the whole Dialog/Popover/Menu cycle. - RSP table: move
TableContext,useTableContext,VirtualizerContext,useVirtualizerContextand theTableContextValuetype intosrc/table/TableContext.ts, and re-export them fromTableViewBase. - RSP list: move
ListViewContextintosrc/list/ListViewContext.ts, and re-export it fromListView. - AI: move
matchMimeType/getMimeTypeLabelintosrc/mimeTypes.ts, and re-export them fromPromptField. - and 6. Import
PixelLoaderfrom./loader/reactandpointerMapfrom./userEventMapsdirectly, 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
- Inspect the published files listed above (for example
react-aria-components@1.22.1/dist/private/Popover.mjsandDialog.mjs). Each one imports the other. - In a webpack 5 or rspack Module Federation host and remote, add both modules of any pair above to
sharedas separate entries, keyed by resolved path. Then render a component that uses them. - 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