@mfe-forge/core
Runtime foundation for MFE Forge micro-frontends: an error boundary built for remotes, components that load remotes with a real retry, a prefix-namespaced event bus, and a small remote registry.
Version: 0.1.1 · Module format: ESM only · Node: >=20 · License: MIT
When to use it
- You render a remote (federated) component in a host and want a failure in that remote to stay contained instead of unmounting the whole page.
- You need loose, publish/subscribe communication between MFEs that do not import each other.
- You want a place to record which remotes exist and how healthy they are.
It does not configure Module Federation, fetch remoteEntry.js files, or implement network retry/timeout policies. Those are handled by your bundler setup and the CLI.
Installation
npm install @mfe-forge/corepnpm add @mfe-forge/core| Peer dependency | Range |
|---|---|
react | ^19.0.0 |
react-dom | ^19.0.0 |
The package is marked sideEffects: false.
Import paths
| Specifier | Exports |
|---|---|
@mfe-forge/core | Everything listed on this page |
@mfe-forge/core/error-boundary | MFErrorBoundary |
@mfe-forge/core/event-bus | EventBus, createEventBus, globalEventBus |
@mfe-forge/core/loader | RemoteLoader, LazyRemote, MAX_CACHE_SIZE, cache helpers |
RemoteRegistry and remoteRegistry are only available from the root specifier.
Subpaths and singletons
globalEventBus and the remote component cache are module-level singletons. Pick one specifier per symbol across your MFEs and share @mfe-forge/core as a Module Federation singleton, otherwise different bundles may hold different instances.
Exports at a glance
| Export | Kind |
|---|---|
MFErrorBoundary | React class component |
RemoteLoader | React component |
LazyRemote | Component factory |
MAX_CACHE_SIZE | Constant (100) |
getRemoteComponentCacheSize | Function |
hasRemoteComponent | Function |
evictRemoteComponent | Function |
clearRemoteComponentCache | Function |
EventBus | Class |
createEventBus | Function |
globalEventBus | EventBus instance |
RemoteRegistry | Class |
remoteRegistry | RemoteRegistry instance |
MFEErrorInfo, RemoteComponentProps, RemoteDefinition, RemoteHealth | Types |
MFErrorBoundary
A React error boundary that catches render errors thrown by its subtree (typically a remote) and renders a fallback instead of crashing the host.
class MFErrorBoundary extends React.Component<{
children: ReactNode
fallback?: ReactNode | ((error: MFEErrorInfo, reset: () => void) => ReactNode)
remoteName?: string
onError?: (info: MFEErrorInfo) => void
onReset?: () => void
}> {
reset(): void
}Props
| Prop | Type | Default | Description |
|---|---|---|---|
children | ReactNode | required | Tree to render while no error is present. |
fallback | ReactNode | (error: MFEErrorInfo, reset: () => void) => ReactNode | built-in accessible fallback | Static element, or render function receiving the error info and a reset callback. |
remoteName | string | undefined | Identifies the wrapped remote. Copied into MFEErrorInfo.remoteName, shown in the default fallback and set as data-remote-name. |
onError | (info: MFEErrorInfo) => void | undefined | Called once per caught error from componentDidCatch. Use it to report to monitoring. |
onReset | () => void | undefined | Called by reset() before the error state is cleared, so you can discard cached failed state (for example a rejected lazy import). |
Behavior
- A render error sets the boundary into the error state (
getDerivedStateFromError). componentDidCatchbuilds anMFEErrorInfo(error,componentStack,remoteName) and callsonError.- When
process.env.NODE_ENV === 'production', the error info is also logged withconsole.error('[MFE Error]', info). Nothing is logged by the boundary in other modes (React itself still logs in development). - Rendering in the error state:
fallbackis a function:fallback(errorInfo, reset)is rendered.fallbackis any other truthy node: it is rendered as is (no access toreset).- Otherwise the default fallback is rendered.
reset()callsonReset?.()and then clears the error, re-renderingchildren.
Default fallback and accessibility
The default fallback renders:
- an outer
div.mfe-error-boundarywithdata-testid="mfe-error"anddata-remote-name; - an inner card with
role="alert", so screen readers announce the failure immediately; - a heading "Something went wrong" (the warning emoji is
aria-hidden); - "Remote: <name>" when
remoteNameis set, followed byerror.message; - a native
<button type="button">Try again</button>that callsreset().
Styles are inline (red card on a light background), so no CSS import is required.
Examples
import { MFErrorBoundary } from '@mfe-forge/core'
;<MFErrorBoundary
remoteName="checkout/cart"
onError={(info) =>
reportError(info.error, { remote: info.remoteName, stack: info.componentStack })
}
>
<CartApp />
</MFErrorBoundary>Custom fallback with retry:
<MFErrorBoundary
remoteName="checkout/cart"
fallback={({ error, remoteName }, reset) => (
<div role="alert">
<p>
{remoteName} is unavailable: {error.message}
</p>
<button type="button" onClick={reset}>
Retry
</button>
</div>
)}
>
<CartApp />
</MFErrorBoundary>Gotchas
What error boundaries do not catch
Like every React error boundary, it only catches errors thrown during rendering, in lifecycle methods and in constructors of its subtree. Errors in event handlers, timers and unawaited promises are not caught.
- If your custom fallback is a node and not a function, it has no way to reset the boundary. Use the function form to offer a retry.
reset()re-renders the same children. If the failure is deterministic (for example a cached rejectedReact.lazy), the same error is thrown again unlessonResetclears that state.RemoteLoaderandLazyRemotedo this for you.componentStackin the fallback'sMFEErrorInfocan be''on the first error render, because React supplies it incomponentDidCatch, aftergetDerivedStateFromError. It is filled on the following render.- With a custom
fallback, accessible markup (such asrole="alert"and a real<button>) is your responsibility.
RemoteLoader
Loads and renders a remote module by name at runtime, wrapped in Suspense and MFErrorBoundary.
function RemoteLoader(props: RemoteComponentProps): JSX.ElementProps (RemoteComponentProps)
| Prop | Type | Default | Description |
|---|---|---|---|
remoteName | string | required | Federated remote name, for example 'checkoutApp'. Also passed to the error boundary. |
moduleName | string | 'App' | Exposed module to load. |
fallback | ReactNode | built-in loading indicator | Suspense fallback shown while the module loads. A falsy value uses the default. |
props | Record<string, any> | {} | Props spread onto the loaded component. |
Behavior
- The component is resolved with
React.lazy(() => import(`${remoteName}/${moduleName}`)). The specifier is a bare module id such ascheckoutApp/App, which must be resolvable at runtime by your Module Federation runtime. The import is marked@vite-ignore, so Vite does not analyze it. - Caching. Lazy components are cached per
remoteName/moduleNamekey in a module-level LRU map with a capacity ofMAX_CACHE_SIZE(100). A cache hit refreshes the entry's recency; inserting the 101st entry evicts the least recently used one. Caching keeps React component identity stable across renders, so the remote is not remounted on every parent render. - Lookup at render time. The cache is read inside an inner component on each render, so an evicted entry is replaced by a fresh
lazy()on the next render. - Retry. The error boundary's
onResetevicts the cache entry for thisremoteName/moduleName, so pressing "Try again" triggers a newimport()instead of re-throwing the rejected promise thatReact.lazykeeps forever. - Errors are caught by
MFErrorBoundarywith the default fallback.RemoteLoaderdoes not exposeonErroror a custom error fallback; wrap it in your ownMFErrorBoundary(or useLazyRemote) if you need them.
Example
import { RemoteLoader } from '@mfe-forge/core'
;<RemoteLoader
remoteName="checkoutApp"
moduleName="Cart"
props={{ userId }}
fallback={<Spinner />}
/>Gotchas
- No timeout is applied. If the import never settles, the loading fallback stays on screen.
- Retrying re-imports the module specifier. Whether this triggers a fresh network fetch depends on your federation runtime and browser module cache.
propsdefaults to a new{}each render; pass a memoized object if the remote relies on referential equality.
LazyRemote
Creates a component that lazily loads a remote from a factory you provide, with built-in Suspense, MFErrorBoundary and retry. Prefer it over RemoteLoader when the remote is known at build time, because a static import('remote/Module') is visible to the bundler.
function LazyRemote(
factory: () => Promise<{ default: React.ComponentType<Record<string, unknown>> }>,
options?: { fallback?: ReactNode; remoteName?: string }
): (props: Record<string, unknown>) => JSX.ElementParameters
| Name | Type | Default | Description |
|---|---|---|---|
factory | () => Promise<{ default: ComponentType }> | required | Dynamic import of the remote module. Must resolve to a module with a default export. |
options.fallback | ReactNode | built-in loading indicator | Suspense fallback. |
options.remoteName | string | undefined | Passed to the error boundary and shown in the default loading and error UIs. |
Return value
A React component (LazyRemoteWrapper) that forwards all of its props to the loaded component.
Behavior
React.lazy(factory)is created once whenLazyRemoteis called and shared by every instance of the returned component until a retry happens.- Retry. Each rendered instance holds its lazy component in state. On reset, it replaces it with a new
lazy(factory), so the factory runs again. The new lazy component is local to the instance that was reset. - Errors are rendered with the default
MFErrorBoundaryfallback.
Example
import { LazyRemote } from '@mfe-forge/core'
const CartApp = LazyRemote(() => import('checkoutApp/Cart'), {
remoteName: 'checkout/cart',
fallback: <CartSkeleton />,
})
<Route path="/cart" element={<CartApp currency="EUR" />} />TIP
Call LazyRemote at module scope, not inside a component body. Calling it during render creates a new component type on every render, which remounts the remote and re-runs the import.
Default loading fallback
Used by RemoteLoader and LazyRemote when no fallback is given:
role="status"witharia-live="polite"anddata-testid="mfe-loading";- text "Loading <remoteName>..." (or "Loading component..." when no name is known);
- a spinner marked
aria-hidden="true"whose animation is disabled underprefers-reduced-motion: reduce; - a minimum height of
200px, centered.
The data-testid values mfe-loading and mfe-error are used by the @mfe-forge/testing end-to-end helpers.
MAX_CACHE_SIZE
const MAX_CACHE_SIZE = 100Capacity of the RemoteLoader LRU cache. Read-only; it cannot be configured.
Remote component cache helpers
These functions manage the cache used by RemoteLoader. They do not affect LazyRemote.
function getRemoteComponentCacheSize(): number
function hasRemoteComponent(remoteName: string, moduleName?: string): boolean
function evictRemoteComponent(remoteName: string, moduleName?: string): boolean
function clearRemoteComponentCache(): void| Function | Parameters | Returns | Description |
|---|---|---|---|
getRemoteComponentCacheSize | none | number | Number of cached entries. |
hasRemoteComponent | remoteName, moduleName = 'App' | boolean | Whether remoteName/moduleName is cached. Does not refresh LRU order. |
evictRemoteComponent | remoteName, moduleName = 'App' | boolean | Removes one entry; true if something was removed. |
clearRemoteComponentCache | none | void | Removes all entries. |
import { evictRemoteComponent } from '@mfe-forge/core'
// After a remote is redeployed, force the next render to import it again
evictRemoteComponent('checkoutApp', 'Cart')WARNING
Evicting does not re-render anything by itself. Already mounted RemoteLoaders keep showing the component they rendered until they render again.
EventBus
A small synchronous publish/subscribe bus for communication between MFEs. Event names are namespaced with a prefix.
class EventBus {
constructor(config?: EventBusConfig)
on<T = any>(event: string, handler: (payload: T) => void): () => void
once<T = any>(event: string, handler: (payload: T) => void): () => void
off<T = any>(event: string, handler: (payload: T) => void): void
emit<T = any>(event: string, payload?: T): void
clear(event?: string): void
}Constructor options (EventBusConfig)
| Option | Type | Default | Description |
|---|---|---|---|
debug | boolean | false | Logs subscriptions and emissions with console.log('[EventBus] ...'). |
prefix | string | 'mfe:' | Prepended to every event name that does not already start with it. |
Methods
| Method | Returns | Description |
|---|---|---|
on(event, handler) | () => void | Subscribes handler. Returns an unsubscribe function. |
once(event, handler) | () => void | Subscribes for a single emission; the handler is removed after it runs. The returned function cancels it before it fires. |
off(event, handler) | void | Removes that exact handler reference. |
emit(event, payload?) | void | Calls every handler for the event with payload. |
clear(event?) | void | Removes all handlers for one event, or every handler when called without an argument. |
Semantics
- Prefixing.
getEventName(event)returnseventunchanged if it already starts with the prefix, otherwiseprefix + event. With the default prefix,on('cart:updated')andon('mfe:cart:updated')refer to the same event. - Synchronous delivery.
emitcalls handlers synchronously, in subscription order, before returning. There is no queue, replay or "sticky" last value: handlers subscribed after an emit do not receive it. - Isolation of failures. An exception thrown by a handler is caught and logged with
console.error; remaining handlers still run andemitdoes not throw. - Deduplication. Handlers are stored in a
Set, so subscribing the same function reference twice to the same event registers it once, and a single unsubscribe removes it. - No wildcards. Event names are matched exactly (after prefixing).
- Typing.
Tis not checked against the event name; it is a per-call annotation.
Example
import { EventBus } from '@mfe-forge/core'
const bus = new EventBus({ prefix: 'shop:', debug: import.meta.env.DEV })
const unsubscribe = bus.on<{ id: string; qty: number }[]>('cart:updated', (items) => {
render(items)
})
bus.emit('cart:updated', [{ id: 'sku-1', qty: 2 }])
unsubscribe()Gotchas
offneeds the same handler reference that was passed toon. It cannot remove a handler registered withonce, becauseoncewraps it; use the functiononcereturns.Always call the unsubscribe function in React effect cleanups to avoid leaks and duplicate handling under
StrictMode:tsxuseEffect(() => globalEventBus.on('user:login', handleLogin), [])prefix: ''disables namespacing entirely.
createEventBus
function createEventBus(config?: EventBusConfig): EventBusEquivalent to new EventBus(config). Use it to create a bus scoped to one MFE or feature.
const cartBus = createEventBus({ prefix: 'cart:' })
cartBus.once('ready', () => console.log('cart ready'))globalEventBus
const globalEventBus: EventBus // prefix 'mfe:global:', debug falseA shared singleton for cross-MFE events. It is only shared between MFEs that receive the same module instance, which requires sharing @mfe-forge/core as a singleton in Module Federation.
import { globalEventBus } from '@mfe-forge/core'
globalEventBus.on<{ id: string }>('user:login', (user) => console.log(user.id))
globalEventBus.emit('user:login', { id: '123' })It pairs with useRouteSync and syncStoreAcrossMFEs.
RemoteRegistry
An in-memory store of remote definitions and their health. It does not fetch, load or probe remotes, and RemoteLoader / LazyRemote do not read from it. You update health yourself, for example from an onError callback.
class RemoteRegistry {
register(definition: RemoteDefinition): void
get(name: string): RemoteDefinition | undefined
setHealth(name: string, health: RemoteHealth): void
getHealth(name: string): RemoteHealth
clear(name?: string): void
}
const remoteRegistry: RemoteRegistry| Method | Description |
|---|---|
register(definition) | Stores the definition under definition.name, replacing any existing one, and resets its health to { status: 'unknown', attempts: 0 }. |
get(name) | Returns the definition or undefined. |
setHealth(name, health) | Replaces the health record. Works even for names that were never registered. |
getHealth(name) | Returns the health record, or { status: 'unknown', attempts: 0 } if none exists. |
clear(name?) | Removes the definition and health for name, or everything when called without an argument. |
import { MFErrorBoundary, remoteRegistry } from '@mfe-forge/core'
remoteRegistry.register({
name: 'cartApp',
url: 'https://cdn.example.com/cart/remoteEntry.js',
environment: 'staging',
})
<MFErrorBoundary
remoteName="cartApp"
onError={({ error }) => {
const { attempts } = remoteRegistry.getHealth('cartApp')
remoteRegistry.setHealth('cartApp', { status: 'failed', attempts: attempts + 1, error })
}}
>
<CartApp />
</MFErrorBoundary>TIP
remoteRegistry is a module-level singleton. Create your own new RemoteRegistry() in tests or when you need isolated registries.
Types reference
/** Information about an error caught by MFErrorBoundary. */
interface MFEErrorInfo {
componentStack: string
error: Error
remoteName?: string
}
/** Props for RemoteLoader. */
interface RemoteComponentProps {
remoteName: string
moduleName?: string // default 'App'
fallback?: ReactNode
props?: Record<string, any>
}
interface RemoteDefinition {
name: string
url: string
environment?: string
}
interface RemoteHealth {
status: 'unknown' | 'loading' | 'ready' | 'failed'
attempts: number
error?: Error
}
/** Not exported from the package; shown for reference. */
interface EventBusConfig {
debug?: boolean // default false
prefix?: string // default 'mfe:'
}
/** Not exported from the package; shown for reference. */
type EventHandler<T = any> = (payload: T) => voidINFO
EventBusConfig, EventHandler and the MFErrorBoundary props interface are not exported. Derive them when needed:
type EventBusConfig = NonNullable<ConstructorParameters<typeof EventBus>[0]>
type BoundaryProps = React.ComponentProps<typeof MFErrorBoundary>