Inter-MFE Communication
Table of Contents
- Overview
- Browser CustomEvents Pattern
- Event Naming Conventions
- Shell App as Event Coordinator
- Use Cases
- Anti-Patterns
- Memory Leak Prevention in Event Handler Chains
- Event Logging and Debugging
- Testing Strategies
- References
Overview
In a micro frontends architecture, independently deployed MFEs must communicate with each other to deliver a cohesive user experience. Common scenarios that require cross-MFE communication include:
- Cart updates: When a user adds an item in the product catalog MFE, the header MFE needs to update its cart badge count and the checkout MFE needs to reflect the new total.
- Navigation: One MFE may need to trigger navigation to a route owned by another MFE, without importing the other MFE's routing configuration.
- User context: When the user switches organizations or roles in the shell, every MFE needs to refetch its data for the new context.
- Notifications: Any MFE may need to display a toast notification, but the notification system lives in the shell.
Why Browser CustomEvents?
We use browser CustomEvent as the communication mechanism for the following reasons:
- Loose coupling: MFEs publish and subscribe to events on
window. There is no shared runtime dependency, no shared state library, and no framework-level coupling between MFEs. - Framework agnostic: CustomEvents are a native browser API. They work regardless of the React version (the platform currently standardizes on React ^19.2.4), framework, or rendering strategy each MFE uses. If an MFE were ever ported to Vue or Svelte, the communication layer would remain unchanged.
- Simplicity: The API surface is small and well-understood. Every web developer already knows
addEventListeneranddispatchEvent. - Debuggability: Events flow through the standard browser event system, making them visible in DevTools and easy to log.
Type Safety Through Shared Contracts
While CustomEvents are untyped at the browser level, we achieve full type safety by defining event contracts in a shared @org/event-contracts package within the monorepo. This package contains:
- The canonical event map (event name to payload type)
- Typed helper functions for dispatching and subscribing
- A React hook for ergonomic event subscription in components
Every MFE depends on @org/event-contracts as a build-time dependency. The package contains only TypeScript types and thin runtime helpers — no framework code, no state management, no business logic.
Shell as Event Coordinator
The shell application acts as the central event coordinator. It mediates communication between MFEs by:
- Listening for request events (e.g.,
mfe:navigation:requested) and acting on them - Dispatching context events (e.g.,
mfe:user:context-changed) that all MFEs may need - Owning cross-cutting infrastructure like the notification system, router, and auth state
MFEs communicate through the shell rather than directly with each other, which keeps the dependency graph clean and predictable.
Browser CustomEvents Pattern
How It Works
The pattern is straightforward: MFEs publish events by calling window.dispatchEvent with a CustomEvent, and subscribe by calling window.addEventListener on window.
Publishing an event:
window.dispatchEvent(
new CustomEvent('mfe:cart:updated', {
detail: {
items: [{ id: 'sku-123', name: 'Widget', quantity: 2, price: 29.99 }],
total: 59.98,
},
})
);
Subscribing to an event:
const handler = (event: CustomEvent) => {
console.log('Cart updated:', event.detail);
};
window.addEventListener('mfe:cart:updated', handler as EventListener);
// Clean up when done
window.removeEventListener('mfe:cart:updated', handler as EventListener);
There is no framework dependency in this pattern. It works across any MFE regardless of the React version (^19.2.4 is the current platform standard), rendering strategy, or even the framework itself. The window object is the shared bus, and the browser's native event system handles dispatch and delivery.
Events are synchronous in dispatch order but should be treated as fire-and-forget by publishers. The publisher does not know who is listening, how many subscribers exist, or what they do with the payload. This is the key decoupling property.
Type-Safe Event System
Raw CustomEvents are untyped. To get compile-time type safety across the monorepo, we define all event contracts in a shared package.
Event Contract Definitions
// packages/event-contracts/src/types.ts
export interface CartItem {
id: string;
name: string;
quantity: number;
price: number;
imageUrl?: string;
}
/**
* Event versioning scheme
* ─────────────────────────────────────────────────────────────────
* Every event payload carries a `_version` field that follows semver
* MAJOR notation (integer). The version is embedded in the payload
* rather than the event name so that subscribers can inspect the
* version at runtime and decide whether they can handle the event.
*
* Versioning rules:
* - Additive (new optional fields) → same version, no bump
* - Changing/removing an existing field → bump MAJOR version
* - Renaming or restructuring the payload → bump MAJOR version
*
* When bumping a MAJOR version:
* 1. Add a NEW event key to MfeEventMap (e.g., 'mfe:cart:updated:v2').
* 2. Keep the old key for a deprecation period (minimum 2 release cycles).
* 3. Publishers dispatch BOTH versions during the migration window.
* 4. Subscribers migrate to the new version.
* 5. Remove the old key after all consumers have migrated.
*
* The _version field lets runtime validators (see replayRecording)
* reject events whose schema has drifted from what the consumer expects.
*/
/** Base metadata included in every event payload. */
export interface MfeEventMeta {
/** Schema version of this event payload (integer, starts at 1). */
_version: number;
}
export interface MfeEventMap {
'mfe:cart:updated': MfeEventMeta & { items: CartItem[]; total: number };
'mfe:cart:item-added': MfeEventMeta & { item: CartItem };
'mfe:cart:cleared': MfeEventMeta & {};
'mfe:user:context-changed': MfeEventMeta & { userId: string; orgId: string; role: string };
'mfe:user:preferences-updated': MfeEventMeta & { locale: string; timezone: string };
'mfe:navigation:requested': MfeEventMeta & { path: string; params?: Record<string, string> };
'mfe:navigation:completed': MfeEventMeta & { path: string };
'mfe:notification:show': MfeEventMeta & {
message: string;
type: 'info' | 'warning' | 'error' | 'success';
duration?: number;
action?: { label: string; path: string };
};
'mfe:notification:dismiss': MfeEventMeta & { id: string };
'mfe:theme:changed': MfeEventMeta & { theme: 'light' | 'dark' };
'mfe:auth:session-expired': MfeEventMeta & {};
'mfe:auth:token-refreshed': MfeEventMeta & { expiresAt: number };
}
/**
* Current schema versions for each event. Bump the version here
* when making a breaking change to the payload shape.
*/
export const MFE_EVENT_VERSIONS: Record<keyof MfeEventMap, number> = {
'mfe:cart:updated': 1,
'mfe:cart:item-added': 1,
'mfe:cart:cleared': 1,
'mfe:user:context-changed': 1,
'mfe:user:preferences-updated': 1,
'mfe:navigation:requested': 1,
'mfe:navigation:completed': 1,
'mfe:notification:show': 1,
'mfe:notification:dismiss': 1,
'mfe:theme:changed': 1,
'mfe:auth:session-expired': 1,
'mfe:auth:token-refreshed': 1,
};
// Utility type to extract the detail type for a given event name
export type MfeEventDetail<K extends keyof MfeEventMap> = MfeEventMap[K];
// Typed CustomEvent
export type MfeCustomEvent<K extends keyof MfeEventMap> = CustomEvent<MfeEventMap[K]>;
Typed Dispatch and Subscribe Helpers
// packages/event-contracts/src/dispatch.ts
import type { MfeEventMap, MfeEventDetail } from './types';
import { MFE_EVENT_VERSIONS } from './types';
/**
* Dispatch a typed MFE event on the window.
* The _version field is injected automatically from MFE_EVENT_VERSIONS,
* so callers do not need to supply it manually.
*
* @example
* dispatch('mfe:cart:updated', { items: [...], total: 59.98 });
* // detail dispatched: { _version: 1, items: [...], total: 59.98 }
*/
export function dispatch<K extends keyof MfeEventMap>(
eventName: K,
detail: Omit<MfeEventDetail<K>, '_version'>
): void {
const versionedDetail = {
...detail,
_version: MFE_EVENT_VERSIONS[eventName],
} as MfeEventDetail<K>;
const event = new CustomEvent(eventName, {
detail: versionedDetail,
bubbles: false,
cancelable: false,
});
window.dispatchEvent(event);
}
// packages/event-contracts/src/subscribe.ts
import type { MfeEventMap, MfeCustomEvent } from './types';
type MfeEventHandler<K extends keyof MfeEventMap> = (
event: MfeCustomEvent<K>
) => void;
/**
* Subscribe to a typed MFE event on the window.
* Returns an unsubscribe function for cleanup.
*
* @example
* const unsubscribe = subscribe('mfe:cart:updated', (event) => {
* console.log(event.detail.items); // fully typed
* });
*
* // Later: clean up
* unsubscribe();
*/
export function subscribe<K extends keyof MfeEventMap>(
eventName: K,
handler: MfeEventHandler<K>
): () => void {
const wrappedHandler = handler as EventListener;
window.addEventListener(eventName, wrappedHandler);
return () => {
window.removeEventListener(eventName, wrappedHandler);
};
}
React Hook for Event Subscription
// packages/event-contracts/src/useMfeEvent.ts
import { useEffect, useLayoutEffect, useRef } from 'react';
import type { MfeEventMap, MfeCustomEvent } from './types';
type MfeEventHandler<K extends keyof MfeEventMap> = (
event: MfeCustomEvent<K>
) => void;
// useLayoutEffect runs synchronously after render but before the browser
// paints, so the ref is updated before any pending events can fire.
// In SSR environments useLayoutEffect warns, so fall back to useEffect.
const useIsomorphicLayoutEffect =
typeof window !== 'undefined' ? useLayoutEffect : useEffect;
/**
* React hook for subscribing to MFE events with automatic cleanup.
*
* The handler is stored in a ref and updated synchronously via
* useLayoutEffect, which eliminates stale closure risk: the event
* listener always invokes the latest handler even if the component
* re-renders between the event dispatch and the handler execution.
*
* Previous versions used a plain useEffect (no deps) to update the ref,
* which left a one-tick window where the ref still pointed to the
* previous handler. useLayoutEffect closes that gap because it runs
* synchronously after render, before the browser yields back to the
* event loop.
*
* You do NOT need to memoize the handler with useCallback.
*
* @example
* function CartBadge() {
* const [count, setCount] = useState(0);
*
* useMfeEvent('mfe:cart:updated', (event) => {
* setCount(event.detail.items.length);
* });
*
* return <Badge count={count} />;
* }
*/
export function useMfeEvent<K extends keyof MfeEventMap>(
eventName: K,
handler: MfeEventHandler<K>
): void {
const handlerRef = useRef(handler);
// Synchronously update the ref after every render so that the
// listener closure below always calls the latest handler.
// useLayoutEffect (not useEffect) ensures the ref is current
// before any queued events can fire in the same browser task.
useIsomorphicLayoutEffect(() => {
handlerRef.current = handler;
});
useEffect(() => {
const listener = (event: Event) => {
handlerRef.current(event as MfeCustomEvent<K>);
};
window.addEventListener(eventName, listener);
return () => {
window.removeEventListener(eventName, listener);
};
}, [eventName]);
}
Package Barrel Export
// packages/event-contracts/src/index.ts
export type { MfeEventMap, MfeEventDetail, MfeCustomEvent, MfeEventMeta, CartItem } from './types';
export { MFE_EVENT_VERSIONS } from './types';
export { dispatch } from './dispatch';
export { subscribe } from './subscribe';
export { useMfeEvent } from './useMfeEvent';
Every MFE imports from @org/event-contracts. TypeScript enforces that the event name and payload match at compile time:
import { dispatch } from '@org/event-contracts';
// Correct: TypeScript validates the payload shape
dispatch('mfe:cart:updated', { items: [], total: 0 });
// Type error: 'count' does not exist on type '{ items: CartItem[]; total: number }'
dispatch('mfe:cart:updated', { count: 5 });
// Type error: 'mfe:cart:unknown' is not a key of MfeEventMap
dispatch('mfe:cart:unknown', {});
Event Naming Conventions
All MFE events follow a strict naming convention to ensure consistency and discoverability across the platform.
Format
mfe:{domain}:{action}
- Prefix: Always
mfe:— this namespaces our events and avoids collisions with browser-native events or third-party library events. - Domain: The owning MFE or concern area, in lowercase. This identifies who is responsible for the event contract. Examples:
cart,user,navigation,notification,auth,theme. - Action: What happened or what is being requested, in lowercase with hyphens for multi-word actions.
- Use past tense for events that describe something that already happened:
updated,completed,cleared,session-expired. - Use imperative for events that request an action:
requested,show,dismiss.
- Use past tense for events that describe something that already happened:
Event Catalog
Every event payload includes a _version field (see Event Versioning above). The Version column shows the current schema version. Bump the version when making breaking changes to the payload shape.
| Event | Version | Publisher | Subscribers | Description |
|---|---|---|---|---|
mfe:cart:updated | 1 | Cart MFE | Header, Checkout | Cart contents changed (items added, removed, or quantity updated) |
mfe:cart:item-added | 1 | Cart MFE | Shell (analytics) | A single item was added to the cart |
mfe:cart:cleared | 1 | Cart MFE | Header, Checkout | Cart was emptied |
mfe:user:context-changed | 1 | Shell | All MFEs | User switched organization or role |
mfe:user:preferences-updated | 1 | Settings MFE | Shell, All MFEs | User updated locale or timezone preferences |
mfe:navigation:requested | 1 | Any MFE | Shell (router) | Request navigation to a path owned by another MFE |
mfe:navigation:completed | 1 | Shell | Any MFE | Navigation to a new route completed |
mfe:notification:show | 1 | Any MFE | Shell (notification UI) | Display a toast notification to the user |
mfe:notification:dismiss | 1 | Any MFE | Shell (notification UI) | Dismiss a specific notification |
mfe:theme:changed | 1 | Shell | All MFEs | Theme was switched between light and dark |
mfe:auth:session-expired | 1 | Shell | All MFEs | User session has expired, authentication required |
mfe:auth:token-refreshed | 1 | Shell | All MFEs | Access token was refreshed, new expiry time |
Adding New Events
When adding a new event:
- Add the event name and payload type to
MfeEventMapin@org/event-contracts. - Add an entry to the event catalog table above.
- Open a PR — the event contract is a shared API and should be reviewed by the teams that will publish or subscribe to it.
- Consumers update their dependency on
@org/event-contractsand implement handlers.
Shell App as Event Coordinator
The shell application is the central coordinator for cross-MFE communication. It serves two roles:
- Listener: It subscribes to request events dispatched by MFEs and acts on them (e.g., handling navigation requests, displaying notifications).
- Broadcaster: It dispatches context events that all MFEs may need (e.g., user context changes, theme changes, auth state).
Shell Event Coordination Setup
// apps/shell/src/events/setupEventCoordination.ts
import { subscribe, dispatch } from '@org/event-contracts';
import type { MfeEventDetail } from '@org/event-contracts';
interface EventCoordinationDeps {
router: {
navigate: (path: string, params?: Record<string, string>) => void;
};
notificationService: {
show: (notification: MfeEventDetail<'mfe:notification:show'>) => void;
dismiss: (id: string) => void;
};
authService: {
logout: () => void;
getSessionState: () => { isExpired: boolean };
};
}
/**
* Set up the shell's event coordination layer.
* Call this once during shell app initialization.
* Returns a cleanup function that removes all listeners.
*/
export function setupEventCoordination(
deps: EventCoordinationDeps
): () => void {
const cleanups: Array<() => void> = [];
// --- Navigation Coordination ---
// MFEs dispatch navigation requests; the shell's router handles them.
cleanups.push(
subscribe('mfe:navigation:requested', (event) => {
const { path, params } = event.detail;
deps.router.navigate(path, params);
// Notify MFEs that navigation completed
dispatch('mfe:navigation:completed', { path });
})
);
// --- Notification Coordination ---
// MFEs dispatch notification requests; the shell's notification UI renders them.
cleanups.push(
subscribe('mfe:notification:show', (event) => {
deps.notificationService.show(event.detail);
})
);
cleanups.push(
subscribe('mfe:notification:dismiss', (event) => {
deps.notificationService.dismiss(event.detail.id);
})
);
// --- Auth Coordination ---
// The shell monitors auth state and broadcasts session expiry to all MFEs.
// This is typically triggered by an HTTP 401 interceptor or token refresh failure.
// See 09-authentication.md for the full token lifecycle: the BFF Worker at
// bff.example.com handles token exchange (POST https://api.workos.com/sso/token)
// and token refresh (POST https://api.workos.com/user_management/authenticate)
// on behalf of the browser. The shell's role here is limited to detecting
// 401 responses and broadcasting the session-expired event.
// Return cleanup function
return () => {
cleanups.forEach((cleanup) => cleanup());
};
}
Shell App Initialization
// apps/shell/src/App.tsx
import { useEffect, useRef } from 'react';
import { useRouter } from './routing/useRouter';
import { useNotificationService } from './notifications/useNotificationService';
import { useAuthService } from './auth/useAuthService';
import { setupEventCoordination } from './events/setupEventCoordination';
export function App() {
const router = useRouter();
const notificationService = useNotificationService();
const authService = useAuthService();
const cleanupRef = useRef<(() => void) | null>(null);
useEffect(() => {
cleanupRef.current = setupEventCoordination({
router,
notificationService,
authService,
});
return () => {
cleanupRef.current?.();
};
}, [router, notificationService, authService]);
return (
<ShellLayout>
<NotificationContainer />
<RouterOutlet />
</ShellLayout>
);
}
Context Broadcasting
When the shell detects a context change (user switches org, theme changes, etc.), it broadcasts it to all MFEs:
// apps/shell/src/auth/useOrgSwitcher.ts
import { dispatch } from '@org/event-contracts';
export function useOrgSwitcher() {
const switchOrg = async (orgId: string) => {
// 1. Update the org on the backend
const response = await fetch('/api/auth/switch-org', {
method: 'POST',
body: JSON.stringify({ orgId }),
headers: { 'Content-Type': 'application/json' },
});
const { userId, orgId: newOrgId, role } = await response.json();
// 2. Broadcast the context change to all MFEs
dispatch('mfe:user:context-changed', {
userId,
orgId: newOrgId,
role,
});
};
return { switchOrg };
}
Rules for Event Flow
- MFEs dispatch request events; the shell acts on them. An MFE should never directly handle another MFE's request event (e.g., only the shell handles
mfe:navigation:requested). - The shell dispatches context events; MFEs subscribe. Context events like user changes, theme changes, and auth state are always broadcast by the shell.
- MFEs dispatch domain events; other MFEs may subscribe. An MFE that owns a domain (e.g., Cart) dispatches events about state changes in that domain. Other MFEs that need that information subscribe directly. The shell may also subscribe for analytics or coordination purposes.
- MFEs should NOT subscribe to another MFE's internal events. If an event is only relevant within a single MFE, it should not be in
MfeEventMap— use component props, React context, or local state management instead.
Use Cases
Cart Updates
The cart MFE owns the shopping cart domain. When the cart contents change, it dispatches an event so that other MFEs can react without importing the cart MFE's code or state.
Cart MFE: Publishing Updates
// apps/cart-mfe/src/services/cartService.ts
import { dispatch } from '@org/event-contracts';
import type { CartItem } from '@org/event-contracts';
interface CartState {
items: CartItem[];
total: number;
}
let cartState: CartState = { items: [], total: 0 };
function recalculateTotal(items: CartItem[]): number {
return items.reduce((sum, item) => sum + item.price * item.quantity, 0);
}
function broadcastCartUpdate(): void {
dispatch('mfe:cart:updated', {
items: cartState.items,
total: cartState.total,
});
}
export function addToCart(item: CartItem): void {
const existingIndex = cartState.items.findIndex((i) => i.id === item.id);
if (existingIndex >= 0) {
cartState.items[existingIndex].quantity += item.quantity;
} else {
cartState.items.push({ ...item });
}
cartState.total = recalculateTotal(cartState.items);
// Broadcast the update so Header and Checkout MFEs can react
broadcastCartUpdate();
// Also dispatch the specific item-added event for analytics
dispatch('mfe:cart:item-added', { item });
}
export function removeFromCart(itemId: string): void {
cartState.items = cartState.items.filter((i) => i.id !== itemId);
cartState.total = recalculateTotal(cartState.items);
broadcastCartUpdate();
}
export function clearCart(): void {
cartState = { items: [], total: 0 };
dispatch('mfe:cart:cleared', {});
broadcastCartUpdate();
}
Header MFE: Subscribing to Cart Badge Count
// apps/header-mfe/src/components/CartBadge.tsx
import { useState } from 'react';
import { useMfeEvent } from '@org/event-contracts';
export function CartBadge() {
const [itemCount, setItemCount] = useState(0);
useMfeEvent('mfe:cart:updated', (event) => {
const totalItems = event.detail.items.reduce(
(sum, item) => sum + item.quantity,
0
);
setItemCount(totalItems);
});
if (itemCount === 0) {
return (
<button className="cart-button" aria-label="Cart is empty">
<CartIcon />
</button>
);
}
return (
<button className="cart-button" aria-label={`Cart with ${itemCount} items`}>
<CartIcon />
<span className="cart-badge">{itemCount}</span>
</button>
);
}
Checkout MFE: Subscribing to Cart Total
// apps/checkout-mfe/src/components/OrderSummary.tsx
import { useState } from 'react';
import { useMfeEvent } from '@org/event-contracts';
import type { CartItem } from '@org/event-contracts';
export function OrderSummary() {
const [items, setItems] = useState<CartItem[]>([]);
const [total, setTotal] = useState(0);
useMfeEvent('mfe:cart:updated', (event) => {
setItems(event.detail.items);
setTotal(event.detail.total);
});
return (
<section className="order-summary" aria-label="Order summary">
<h2>Order Summary</h2>
<ul>
{items.map((item) => (
<li key={item.id} className="order-item">
<span>{item.name}</span>
<span>
{item.quantity} x ${item.price.toFixed(2)}
</span>
</li>
))}
</ul>
<div className="order-total">
<strong>Total: ${total.toFixed(2)}</strong>
</div>
</section>
);
}
Navigation Between MFEs
MFEs need to link to routes owned by other MFEs without importing each other's routing configuration. The mfe:navigation:requested event lets any MFE request navigation, and the shell's router handles it.
MFE Dispatching a Navigation Request
// apps/product-mfe/src/components/ProductCard.tsx
import { dispatch } from '@org/event-contracts';
interface ProductCardProps {
productId: string;
name: string;
price: number;
imageUrl: string;
}
export function ProductCard({ productId, name, price, imageUrl }: ProductCardProps) {
const handleViewDetails = () => {
dispatch('mfe:navigation:requested', {
path: `/products/${productId}`,
});
};
const handleAddToCartAndCheckout = () => {
// First add to cart (handled within this MFE or via cart API)
// Then navigate to checkout (owned by a different MFE)
dispatch('mfe:navigation:requested', {
path: '/checkout',
params: { productId },
});
};
return (
<article className="product-card">
<img src={imageUrl} alt={name} />
<h3>{name}</h3>
<p>${price.toFixed(2)}</p>
<button onClick={handleViewDetails}>View Details</button>
<button onClick={handleAddToCartAndCheckout}>Buy Now</button>
</article>
);
}
Shell Handling Navigation
The shell's event coordination layer (shown earlier) handles this:
// Inside setupEventCoordination (shown above)
subscribe('mfe:navigation:requested', (event) => {
const { path, params } = event.detail;
// Build the URL with query params if provided
const url = new URL(path, window.location.origin);
if (params) {
Object.entries(params).forEach(([key, value]) => {
url.searchParams.set(key, value);
});
}
// Use the shell's router to navigate
deps.router.navigate(url.pathname + url.search);
// Notify MFEs that navigation completed
dispatch('mfe:navigation:completed', { path: url.pathname });
});
Reusable Navigation Helper
For convenience, MFEs can use a helper component that wraps the event dispatch:
// packages/event-contracts/src/MfeLink.tsx
import type { ReactNode, MouseEvent } from 'react';
import { dispatch } from './dispatch';
interface MfeLinkProps {
to: string;
params?: Record<string, string>;
className?: string;
children: ReactNode;
}
/**
* Validates that a navigation target is a safe, relative path.
* Rejects absolute URLs and protocol-relative URLs to prevent
* open redirect vulnerabilities where an attacker could craft a
* link like <MfeLink to="https://evil.com/phish"> or
* <MfeLink to="//evil.com"> that navigates the user off-site.
*
* Allowed:
* /products/123
* /checkout?item=abc
* /settings/profile
*
* Rejected:
* https://evil.com/phish
* //evil.com
* javascript:alert(1)
* data:text/html,...
*/
function isValidInternalPath(path: string): boolean {
// Must start with a single forward slash (relative path).
// Reject protocol-relative URLs (//...), absolute URLs (http://...),
// and dangerous schemes (javascript:, data:, vbscript:, etc.).
if (!path.startsWith('/') || path.startsWith('//')) {
return false;
}
// Double-check by parsing as a URL. If the resulting origin differs
// from the current page, the path would navigate off-site.
try {
const resolved = new URL(path, window.location.origin);
return resolved.origin === window.location.origin;
} catch {
return false;
}
}
/**
* A link component for navigating between MFEs.
* Renders an anchor tag for accessibility and progressive enhancement,
* but intercepts clicks to dispatch a navigation event instead.
*
* The `to` prop is validated to ensure it is a relative path on the
* same origin. Absolute URLs, protocol-relative URLs, and dangerous
* schemes (javascript:, data:) are rejected to prevent open redirect
* vulnerabilities. If validation fails, the click is blocked and a
* warning is logged.
*/
export function MfeLink({ to, params, className, children }: MfeLinkProps) {
const handleClick = (e: MouseEvent<HTMLAnchorElement>) => {
// Allow cmd+click / ctrl+click to open in new tab
if (e.metaKey || e.ctrlKey || e.shiftKey) {
return;
}
e.preventDefault();
if (!isValidInternalPath(to)) {
if (process.env.NODE_ENV === 'development') {
console.error(
`[MfeLink] Blocked navigation to unsafe path: "${to}". ` +
'MfeLink only supports relative paths starting with "/" on the same origin.'
);
}
return;
}
dispatch('mfe:navigation:requested', { path: to, params });
};
// For the href attribute, only render safe paths. If the path is
// invalid, fall back to "#" so the anchor is still keyboard-accessible
// but does not expose the unsafe URL in the DOM.
const safeHref = isValidInternalPath(to) ? to : '#';
return (
<a href={safeHref} onClick={handleClick} className={className}>
{children}
</a>
);
}
User Context Changes
When the user switches organizations (common in B2B SaaS), every MFE needs to refetch its data for the new org context. The shell owns this flow.
Shell Broadcasting Context Change
// apps/shell/src/components/OrgSwitcher.tsx
import { useState } from 'react';
import { dispatch } from '@org/event-contracts';
interface Org {
id: string;
name: string;
}
interface OrgSwitcherProps {
currentOrgId: string;
organizations: Org[];
}
export function OrgSwitcher({ currentOrgId, organizations }: OrgSwitcherProps) {
const [isLoading, setIsLoading] = useState(false);
const handleOrgChange = async (orgId: string) => {
if (orgId === currentOrgId) return;
setIsLoading(true);
try {
const response = await fetch('/api/auth/switch-org', {
method: 'POST',
body: JSON.stringify({ orgId }),
headers: { 'Content-Type': 'application/json' },
});
if (!response.ok) {
throw new Error('Failed to switch organization');
}
const { userId, orgId: newOrgId, role } = await response.json();
// Broadcast context change to all MFEs
dispatch('mfe:user:context-changed', {
userId,
orgId: newOrgId,
role,
});
} catch (error) {
dispatch('mfe:notification:show', {
message: 'Failed to switch organization. Please try again.',
type: 'error',
});
} finally {
setIsLoading(false);
}
};
return (
<select
value={currentOrgId}
onChange={(e) => handleOrgChange(e.target.value)}
disabled={isLoading}
aria-label="Switch organization"
>
{organizations.map((org) => (
<option key={org.id} value={org.id}>
{org.name}
</option>
))}
</select>
);
}
MFE Reacting to Context Change
// apps/dashboard-mfe/src/components/Dashboard.tsx
import { useState, useEffect, useCallback } from 'react';
import { useMfeEvent } from '@org/event-contracts';
interface DashboardData {
metrics: Array<{ label: string; value: number }>;
recentActivity: Array<{ id: string; description: string; timestamp: string }>;
}
export function Dashboard() {
const [data, setData] = useState<DashboardData | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [orgId, setOrgId] = useState<string | null>(null);
const fetchDashboardData = useCallback(async (targetOrgId: string) => {
setIsLoading(true);
try {
const response = await fetch(`/api/dashboard?orgId=${targetOrgId}`);
const dashboardData: DashboardData = await response.json();
setData(dashboardData);
} catch (error) {
console.error('Failed to fetch dashboard data:', error);
} finally {
setIsLoading(false);
}
}, []);
// React to org context changes broadcast by the shell
useMfeEvent('mfe:user:context-changed', (event) => {
const { orgId: newOrgId } = event.detail;
setOrgId(newOrgId);
fetchDashboardData(newOrgId);
});
// Initial data fetch
useEffect(() => {
if (orgId) {
fetchDashboardData(orgId);
}
}, [orgId, fetchDashboardData]);
if (isLoading) return <DashboardSkeleton />;
if (!data) return <EmptyState />;
return (
<div className="dashboard">
<MetricsGrid metrics={data.metrics} />
<ActivityFeed activity={data.recentActivity} />
</div>
);
}
Session Expiry
When the user's session expires, the shell needs to notify all MFEs so they can stop pending operations and the shell can redirect to login.
Shell Detecting and Broadcasting Session Expiry
The session monitor intercepts fetch calls at the shell level to detect HTTP 401 responses. It does NOT perform token exchange or refresh itself — that responsibility belongs to the BFF Worker (see 09-authentication.md for the full flow). The BFF Worker calls WorkOS endpoints (https://api.workos.com/sso/token for initial exchange, https://api.workos.com/user_management/authenticate for refresh). The shell-side interceptor below only observes responses and broadcasts the session-expired event when the BFF's refresh has already failed.
// apps/shell/src/auth/sessionMonitor.ts
import { dispatch } from '@org/event-contracts';
/**
* Set up an HTTP interceptor that detects 401 responses
* and broadcasts session expiry to all MFEs.
*
* IMPORTANT: The interceptor preserves AbortController / AbortSignal
* behavior. If the caller passes an AbortSignal (directly or via a
* Request object) and the signal is aborted, the original AbortError
* propagates unchanged. The interceptor does NOT catch or swallow
* abort errors — doing so would break request cancellation in MFEs
* that rely on AbortController for cleanup (e.g., React useEffect
* teardown, user-initiated cancellation, timeout signals).
*
* Returns a cleanup function that restores the original fetch.
*/
export function setupSessionMonitor(): () => void {
let hasExpired = false;
// Intercept fetch to detect 401 responses
const originalFetch = window.fetch;
window.fetch = async (input: RequestInfo | URL, init?: RequestInit) => {
// Extract the signal from either the Request object or the init options.
// This ensures we can check whether an error is an intentional abort.
const signal = init?.signal ?? (input instanceof Request ? input.signal : undefined);
let response: Response;
try {
response = await originalFetch(input, init);
} catch (error: unknown) {
// If the request was aborted via AbortController, let the
// AbortError propagate without triggering session expiry logic.
// AbortSignal.timeout() also throws an AbortError when the
// timeout elapses — this is intentional cancellation, not a
// session problem.
if (signal?.aborted) {
throw error;
}
// DOMException with name 'AbortError' can occur even without a
// signal reference (e.g., the browser cancelled the request
// during navigation). Re-throw without side effects.
if (error instanceof DOMException && error.name === 'AbortError') {
throw error;
}
// Network errors (DNS failure, offline, etc.) — re-throw as-is.
throw error;
}
if (response.status === 401 && !hasExpired) {
hasExpired = true;
// Broadcast session expiry to all MFEs
dispatch('mfe:auth:session-expired', {});
// Show a notification
dispatch('mfe:notification:show', {
message: 'Your session has expired. Please log in again.',
type: 'warning',
});
// Redirect to login after a short delay so MFEs can clean up
setTimeout(() => {
window.location.href = '/login?reason=session-expired';
}, 2000);
}
return response;
};
// Return a cleanup function that restores the original fetch.
// This prevents the interceptor from leaking if the shell
// unmounts (e.g., during hot module replacement in development).
return () => {
window.fetch = originalFetch;
};
}
MFE Reacting to Session Expiry
// apps/dashboard-mfe/src/components/Dashboard.tsx
import { useState, useRef } from 'react';
import { useMfeEvent } from '@org/event-contracts';
export function Dashboard() {
const [isSessionExpired, setIsSessionExpired] = useState(false);
const abortControllerRef = useRef<AbortController | null>(null);
useMfeEvent('mfe:auth:session-expired', () => {
setIsSessionExpired(true);
// Abort any in-flight requests
abortControllerRef.current?.abort();
});
if (isSessionExpired) {
return (
<div className="session-expired-overlay" role="alert">
<p>Your session has expired. Redirecting to login...</p>
</div>
);
}
return <DashboardContent abortControllerRef={abortControllerRef} />;
}
Full Session Expiry Sequence
The complete flow:
- An MFE's API call returns HTTP 401.
- The shell's fetch interceptor catches the 401.
- The shell dispatches
mfe:auth:session-expired. - All subscribed MFEs:
- Abort in-flight requests.
- Show a "session expired" overlay or disable interactive elements.
- Stop polling or background operations.
- The shell displays a notification and redirects to
/loginafter a brief delay.
Anti-Patterns
Avoid Direct MFE-to-MFE Imports
Never import code directly from another MFE's bundle.
// BAD: Importing directly from another MFE
import { cartStore } from '@cart-mfe/store';
import { CartWidget } from '@cart-mfe/components';
This creates a build-time dependency between MFEs. If the cart MFE changes its internal API, renames a module, or upgrades a library, the importing MFE will break. This defeats the core value proposition of micro frontends: independent deployability.
Instead, use events for communication and Module Federation for runtime component sharing (only when explicitly configured as an exposed module).
Avoid Shared Redux/State Stores
Do not share a global Redux (or Zustand, MobX, etc.) store across MFEs.
// BAD: Shared global store
const globalStore = createStore({
cart: cartReducer, // owned by Cart MFE
user: userReducer, // owned by Shell
dashboard: dashReducer, // owned by Dashboard MFE
});
Problems with this approach:
- Tight coupling: MFEs cannot be deployed independently because they all depend on the same store shape and the same version of the state management library.
- Version conflicts: If one MFE needs Redux 5 and another is still on Redux 4, the shared store breaks.
- Testing difficulty: You cannot test one MFE's state in isolation without setting up the entire shared store.
- Unclear ownership: When multiple MFEs can read and write the same state, bugs become extremely difficult to trace.
Instead, each MFE owns its own internal state (using whatever library it prefers). Cross-MFE data sharing happens exclusively via events. If an MFE needs data from another MFE's domain, it subscribes to the relevant event or fetches the data from the BFF.
Avoid Synchronous Communication
Do not build request-response patterns over events.
// BAD: Trying to get a response from an event
dispatch('mfe:cart:get-total', {});
subscribe('mfe:cart:total-response', (event) => {
// This is fragile — what if the cart MFE hasn't loaded yet?
// What if multiple things request the total simultaneously?
const total = event.detail.total;
});
Events are fire-and-forget. The publisher does not know or care who is listening. Building request-response semantics on top of events leads to:
- Race conditions when the subscriber has not mounted yet.
- Ambiguity when multiple publishers dispatch the same request.
- Fragile timeout logic trying to handle "no response" cases.
Instead, if an MFE needs data from another domain, it should:
- Call the BFF API directly (the BFF aggregates data from multiple backend services).
- Subscribe to the event that pushes updates when the data changes.
Avoid Over-Eventing
Not every state change needs to be an event.
Events should be used for cross-cutting concerns — things that multiple MFEs genuinely need to know about. If only one MFE cares about a state change, it is an internal concern and should use component props, React context, or local state management.
Signs of over-eventing:
- Events with only one subscriber (probably should be a prop or context).
- Events that fire dozens of times per second (probably should be internal state with debouncing).
- Events that carry large payloads (probably should be a BFF query instead).
- Events for UI state (e.g., "modal opened") that only matter within one MFE.
Rule of thumb: If you remove the event and only one MFE breaks, it should not be an event.
Memory Leak Prevention in Event Handler Chains
Complex event handler chains — where one event handler subscribes to additional events, dispatches further events, or captures references to large data structures — are a common source of memory leaks in micro frontend architectures. Because window is a long-lived global object, listeners attached to it are never garbage-collected until they are explicitly removed.
Common Memory Leak Patterns
Leak 1: Subscribing inside a subscriber without cleanup
// BAD: Every time 'mfe:user:context-changed' fires, a NEW listener is
// added for 'mfe:cart:updated'. The old listeners are never removed.
// After 10 org switches, there are 10 orphaned cart listeners.
subscribe('mfe:user:context-changed', (event) => {
const { orgId } = event.detail;
// This creates a new listener on every invocation — memory leak!
subscribe('mfe:cart:updated', (cartEvent) => {
syncCartWithOrg(orgId, cartEvent.detail);
});
});
Fix: Track and clean up inner subscriptions
// GOOD: Store the cleanup function and call it before re-subscribing.
let cleanupCartListener: (() => void) | null = null;
subscribe('mfe:user:context-changed', (event) => {
const { orgId } = event.detail;
// Remove the previous inner subscription before creating a new one.
cleanupCartListener?.();
cleanupCartListener = subscribe('mfe:cart:updated', (cartEvent) => {
syncCartWithOrg(orgId, cartEvent.detail);
});
});
Leak 2: Capturing large objects in handler closures
// BAD: The handler closure captures `hugeDataSet`, preventing it from
// being garbage-collected even after the component unmounts — unless
// the listener is explicitly removed.
const hugeDataSet = await fetchLargeDataSet();
subscribe('mfe:theme:changed', () => {
reprocessData(hugeDataSet); // hugeDataSet is retained by the closure
});
Fix: Use a ref or indirect reference
// GOOD (in React): Store the data in a ref so the closure captures
// only the ref object, not the data itself.
const dataRef = useRef<LargeDataSet | null>(null);
dataRef.current = hugeDataSet;
useMfeEvent('mfe:theme:changed', () => {
if (dataRef.current) {
reprocessData(dataRef.current);
}
});
// When the component unmounts, useMfeEvent removes the listener,
// and setting dataRef.current = null (or letting the ref go out of
// scope) allows the data to be garbage-collected.
Leak 3: Forgetting to call the unsubscribe function
// BAD: The return value (unsubscribe function) is ignored.
subscribe('mfe:notification:show', handleNotification);
// GOOD: Always store and call the cleanup function when done.
const unsubscribe = subscribe('mfe:notification:show', handleNotification);
// ... later, when the consumer is torn down:
unsubscribe();
Cleanup Checklist
When setting up event handler chains, follow this checklist to prevent memory leaks:
- Always capture the unsubscribe function returned by
subscribe(). Store it in a variable, array, or ref. - In React components, prefer
useMfeEventover rawsubscribe(). The hook automatically removes the listener on unmount. - Never subscribe inside a subscriber without tracking the inner subscription's cleanup function. If the outer handler fires N times, you get N orphaned inner listeners.
- In
useEffect, return a cleanup function that calls every unsubscribe function created within that effect. - Avoid capturing large objects directly in handler closures. Use refs or indirect lookups.
- In the shell's
setupEventCoordination, push every unsubscribe function into thecleanupsarray and call them all in the returned teardown function. - In integration tests, always call cleanup/unmount after the test to prevent listener accumulation across test cases.
Detecting Leaks
Use the event logger (documented below) to detect listener accumulation:
// In Chrome DevTools console: count listeners per event
getEventListeners(window);
// Look for event names with unexpectedly high listener counts.
// A single MFE should typically have 0-1 listeners per event name.
// If you see 10+ listeners for the same event, you have a leak.
Event Logging and Debugging
Event Logger Middleware
A development-only event logger that intercepts all MFE events and logs them to the console. This is invaluable for debugging event flow between MFEs.
// packages/event-contracts/src/devtools/eventLogger.ts
import type { MfeEventMap } from '../types';
interface EventLogEntry {
timestamp: number;
eventName: string;
detail: unknown;
source: string;
}
const EVENT_LOG_LIMIT = 500;
const eventLog: EventLogEntry[] = [];
/**
* Attach a development-only event logger that logs all MFE events to the console.
* Call this once during shell app initialization in development mode only.
*
* Returns a cleanup function that removes all listeners.
*
* @example
* if (process.env.NODE_ENV === 'development') {
* attachEventLogger();
* }
*/
export function attachEventLogger(): () => void {
const eventNames = [
'mfe:cart:updated',
'mfe:cart:item-added',
'mfe:cart:cleared',
'mfe:user:context-changed',
'mfe:user:preferences-updated',
'mfe:navigation:requested',
'mfe:navigation:completed',
'mfe:notification:show',
'mfe:notification:dismiss',
'mfe:theme:changed',
'mfe:auth:session-expired',
'mfe:auth:token-refreshed',
] as Array<keyof MfeEventMap>;
const cleanups: Array<() => void> = [];
for (const eventName of eventNames) {
const handler = (event: Event) => {
const customEvent = event as CustomEvent;
const entry: EventLogEntry = {
timestamp: Date.now(),
eventName,
detail: customEvent.detail,
source: new Error().stack?.split('\n')[2]?.trim() ?? 'unknown',
};
eventLog.push(entry);
// Keep the log bounded
if (eventLog.length > EVENT_LOG_LIMIT) {
eventLog.shift();
}
// Pretty-print to console with grouping
console.groupCollapsed(
`%c[MFE Event] %c${eventName}`,
'color: #888; font-weight: normal;',
'color: #4CAF50; font-weight: bold;'
);
console.log('Detail:', customEvent.detail);
console.log('Timestamp:', new Date(entry.timestamp).toISOString());
console.log('Source:', entry.source);
console.groupEnd();
};
window.addEventListener(eventName, handler);
cleanups.push(() => window.removeEventListener(eventName, handler));
}
// Expose the event log on window for DevTools access
(window as any).__MFE_EVENT_LOG__ = eventLog;
// Expose helper functions on window
(window as any).__MFE_EVENTS__ = {
getLog: () => [...eventLog],
getByName: (name: string) => eventLog.filter((e) => e.eventName === name),
clear: () => {
eventLog.length = 0;
console.log('[MFE Events] Log cleared');
},
getLast: (n = 10) => eventLog.slice(-n),
};
console.log(
'%c[MFE Events] Logger attached. Use __MFE_EVENTS__.getLog() to inspect.',
'color: #4CAF50; font-weight: bold;'
);
return () => {
cleanups.forEach((cleanup) => cleanup());
delete (window as any).__MFE_EVENT_LOG__;
delete (window as any).__MFE_EVENTS__;
};
}
Enabling the Logger in the Shell
// apps/shell/src/main.tsx
import { attachEventLogger } from '@org/event-contracts/devtools/eventLogger';
if (process.env.NODE_ENV === 'development') {
attachEventLogger();
}
DevTools Integration
Monitoring CustomEvents in Chrome DevTools
Chrome DevTools can monitor CustomEvents using the monitorEvents console utility:
// In Chrome DevTools Console:
// Monitor all events on window (noisy — use sparingly)
monitorEvents(window, 'mfe:cart:updated');
// Use the exposed helper from the event logger
__MFE_EVENTS__.getLog(); // All events
__MFE_EVENTS__.getByName('mfe:cart:updated'); // Filter by name
__MFE_EVENTS__.getLast(5); // Last 5 events
__MFE_EVENTS__.clear(); // Clear the log
Performance API Tracing
Use the browser's Performance API to measure event dispatch and handler execution time:
// packages/event-contracts/src/devtools/performanceTracer.ts
import type { MfeEventMap } from '../types';
/**
* Wraps dispatch and subscribe to add Performance API marks and measures.
* Enables tracing event flow in Chrome DevTools Performance panel.
*/
export function attachPerformanceTracer(): () => void {
const cleanups: Array<() => void> = [];
const knownEvents = new Set<string>();
// Patch window.dispatchEvent to add performance marks
const originalDispatchEvent = window.dispatchEvent.bind(window);
window.dispatchEvent = function tracedDispatchEvent(event: Event): boolean {
if (event.type.startsWith('mfe:')) {
const markName = `mfe-dispatch:${event.type}:${Date.now()}`;
performance.mark(markName);
knownEvents.add(event.type);
}
return originalDispatchEvent(event);
};
cleanups.push(() => {
window.dispatchEvent = originalDispatchEvent;
});
// Expose a function to generate a performance summary
(window as any).__MFE_PERF__ = {
getSummary: () => {
const entries = performance.getEntriesByType('mark').filter(
(entry) => entry.name.startsWith('mfe-dispatch:')
);
const summary = new Map<string, number>();
for (const entry of entries) {
const eventName = entry.name.split(':').slice(1, -1).join(':');
summary.set(eventName, (summary.get(eventName) ?? 0) + 1);
}
return Object.fromEntries(summary);
},
clear: () => {
performance.clearMarks();
performance.clearMeasures();
},
};
return () => {
cleanups.forEach((cleanup) => cleanup());
delete (window as any).__MFE_PERF__;
};
}
Event Replay for Testing
Record events during a user session and replay them later for debugging or automated testing.
Event Recorder
// packages/event-contracts/src/devtools/eventRecorder.ts
import type { MfeEventMap } from '../types';
interface RecordedEvent {
eventName: keyof MfeEventMap;
detail: unknown;
timestamp: number;
relativeTime: number; // ms since recording started
}
interface EventRecording {
id: string;
startedAt: number;
events: RecordedEvent[];
}
let activeRecording: EventRecording | null = null;
let recordingCleanup: (() => void) | null = null;
const ALL_MFE_EVENTS: Array<keyof MfeEventMap> = [
'mfe:cart:updated',
'mfe:cart:item-added',
'mfe:cart:cleared',
'mfe:user:context-changed',
'mfe:user:preferences-updated',
'mfe:navigation:requested',
'mfe:navigation:completed',
'mfe:notification:show',
'mfe:notification:dismiss',
'mfe:theme:changed',
'mfe:auth:session-expired',
'mfe:auth:token-refreshed',
];
/**
* Start recording all MFE events.
*/
export function startRecording(): void {
if (activeRecording) {
console.warn('[MFE Recorder] Already recording. Stop first.');
return;
}
activeRecording = {
id: `recording-${Date.now()}`,
startedAt: Date.now(),
events: [],
};
const cleanups: Array<() => void> = [];
for (const eventName of ALL_MFE_EVENTS) {
const handler = (event: Event) => {
if (!activeRecording) return;
const customEvent = event as CustomEvent;
activeRecording.events.push({
eventName,
detail: structuredClone(customEvent.detail),
timestamp: Date.now(),
relativeTime: Date.now() - activeRecording.startedAt,
});
};
window.addEventListener(eventName, handler);
cleanups.push(() => window.removeEventListener(eventName, handler));
}
recordingCleanup = () => cleanups.forEach((c) => c());
console.log(`[MFE Recorder] Recording started: ${activeRecording.id}`);
}
/**
* Stop recording and return the recorded events.
*/
export function stopRecording(): EventRecording | null {
if (!activeRecording) {
console.warn('[MFE Recorder] No active recording.');
return null;
}
recordingCleanup?.();
recordingCleanup = null;
const recording = activeRecording;
activeRecording = null;
console.log(
`[MFE Recorder] Recording stopped: ${recording.id} (${recording.events.length} events)`
);
return recording;
}
/**
* Validates that a replayed event payload matches the expected schema
* before it is dispatched. This prevents stale or corrupted recordings
* from injecting malformed data into live event handlers.
*
* Validation checks:
* 1. The event name is a recognized key in MfeEventMap.
* 2. The detail is a non-null object.
* 3. The _version field is present and is a positive integer.
* 4. Domain-specific required fields are present (shallow check).
*
* Returns an error message if validation fails, or null if the event
* is valid.
*/
function validateReplayEvent(event: RecordedEvent): string | null {
if (!ALL_MFE_EVENTS.includes(event.eventName)) {
return `Unknown event name: "${String(event.eventName)}"`;
}
if (event.detail == null || typeof event.detail !== 'object') {
return `Event "${String(event.eventName)}": detail must be a non-null object, got ${typeof event.detail}`;
}
const detail = event.detail as Record<string, unknown>;
// Every event payload must carry a _version field (integer >= 1).
if (typeof detail._version !== 'number' || detail._version < 1 || !Number.isInteger(detail._version)) {
return `Event "${String(event.eventName)}": missing or invalid _version field (expected integer >= 1, got ${JSON.stringify(detail._version)})`;
}
// Shallow required-field checks per event domain.
// These catch the most common recording corruption issues without
// duplicating the full TypeScript type definitions at runtime.
const requiredFields: Partial<Record<keyof MfeEventMap, string[]>> = {
'mfe:cart:updated': ['items', 'total'],
'mfe:cart:item-added': ['item'],
'mfe:user:context-changed': ['userId', 'orgId', 'role'],
'mfe:navigation:requested': ['path'],
'mfe:navigation:completed': ['path'],
'mfe:notification:show': ['message', 'type'],
'mfe:notification:dismiss': ['id'],
'mfe:theme:changed': ['theme'],
'mfe:auth:token-refreshed': ['expiresAt'],
};
const required = requiredFields[event.eventName];
if (required) {
for (const field of required) {
if (!(field in detail)) {
return `Event "${String(event.eventName)}": missing required field "${field}"`;
}
}
}
return null; // Valid
}
/**
* Replay a recorded sequence of events with original timing.
* Each event is validated against the expected schema before dispatch.
* Invalid events are skipped with a warning rather than halting replay.
*/
export async function replayRecording(recording: EventRecording): Promise<void> {
console.log(
`[MFE Recorder] Replaying: ${recording.id} (${recording.events.length} events)`
);
let skipped = 0;
for (let i = 0; i < recording.events.length; i++) {
const event = recording.events[i];
const previousTime = i > 0 ? recording.events[i - 1].relativeTime : 0;
const delay = event.relativeTime - previousTime;
if (delay > 0) {
await new Promise((resolve) => setTimeout(resolve, delay));
}
// Validate the event before dispatching
const validationError = validateReplayEvent(event);
if (validationError) {
console.warn(
`[MFE Recorder] Skipping invalid event at index ${i}: ${validationError}`
);
skipped++;
continue;
}
window.dispatchEvent(
new CustomEvent(event.eventName, {
detail: event.detail,
})
);
console.log(
`[MFE Recorder] Replayed: ${event.eventName} (+${event.relativeTime}ms)`
);
}
console.log(
`[MFE Recorder] Replay complete. ${recording.events.length - skipped} dispatched, ${skipped} skipped.`
);
}
/**
* Export a recording as a JSON string for saving to a file.
*/
export function exportRecording(recording: EventRecording): string {
return JSON.stringify(recording, null, 2);
}
/**
* Import a recording from a JSON string.
*/
export function importRecording(json: string): EventRecording {
return JSON.parse(json) as EventRecording;
}
Usage in DevTools Console
// Start recording
__MFE_RECORDER__.start();
// ... interact with the application ...
// Stop recording and get the result
const recording = __MFE_RECORDER__.stop();
// Export to clipboard for saving
copy(JSON.stringify(recording, null, 2));
// Later: replay the recording
__MFE_RECORDER__.replay(recording);
To expose the recorder on the window:
// apps/shell/src/main.tsx
import {
startRecording,
stopRecording,
replayRecording,
exportRecording,
importRecording,
} from '@org/event-contracts/devtools/eventRecorder';
if (process.env.NODE_ENV === 'development') {
(window as any).__MFE_RECORDER__ = {
start: startRecording,
stop: stopRecording,
replay: replayRecording,
export: exportRecording,
import: importRecording,
};
}
Testing Strategies
Unit Testing Event Dispatch
Test that a function dispatches the correct event with the correct payload.
// apps/cart-mfe/src/__tests__/cartService.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { addToCart, clearCart } from '../services/cartService';
describe('cartService', () => {
let dispatchedEvents: Array<{ type: string; detail: unknown }>;
beforeEach(() => {
dispatchedEvents = [];
// Spy on window.dispatchEvent to capture dispatched events
vi.spyOn(window, 'dispatchEvent').mockImplementation((event: Event) => {
const customEvent = event as CustomEvent;
dispatchedEvents.push({
type: customEvent.type,
detail: customEvent.detail,
});
return true;
});
});
it('dispatches mfe:cart:updated when an item is added', () => {
addToCart({
id: 'sku-123',
name: 'Widget',
quantity: 1,
price: 29.99,
});
const cartUpdatedEvent = dispatchedEvents.find(
(e) => e.type === 'mfe:cart:updated'
);
expect(cartUpdatedEvent).toBeDefined();
expect(cartUpdatedEvent?.detail).toEqual({
items: [{ id: 'sku-123', name: 'Widget', quantity: 1, price: 29.99 }],
total: 29.99,
});
});
it('dispatches mfe:cart:item-added with the added item', () => {
const item = { id: 'sku-456', name: 'Gadget', quantity: 2, price: 15.0 };
addToCart(item);
const itemAddedEvent = dispatchedEvents.find(
(e) => e.type === 'mfe:cart:item-added'
);
expect(itemAddedEvent).toBeDefined();
expect(itemAddedEvent?.detail).toEqual({ item });
});
it('dispatches mfe:cart:cleared when cart is cleared', () => {
addToCart({ id: 'sku-123', name: 'Widget', quantity: 1, price: 29.99 });
dispatchedEvents = [];
clearCart();
const clearedEvent = dispatchedEvents.find(
(e) => e.type === 'mfe:cart:cleared'
);
expect(clearedEvent).toBeDefined();
});
});
Unit Testing Event Subscription in React Components
Test that a React component correctly reacts to an MFE event.
// apps/header-mfe/src/__tests__/CartBadge.test.tsx
import { describe, it, expect } from 'vitest';
import { render, screen, act } from '@testing-library/react';
import { CartBadge } from '../components/CartBadge';
// Helper to dispatch a typed MFE event in tests
function dispatchMfeEvent<T>(eventName: string, detail: T): void {
window.dispatchEvent(
new CustomEvent(eventName, { detail })
);
}
describe('CartBadge', () => {
it('renders with zero count initially', () => {
render(<CartBadge />);
expect(screen.getByLabelText('Cart is empty')).toBeInTheDocument();
});
it('updates badge count when mfe:cart:updated is dispatched', () => {
render(<CartBadge />);
act(() => {
dispatchMfeEvent('mfe:cart:updated', {
items: [
{ id: 'sku-1', name: 'Widget', quantity: 2, price: 10 },
{ id: 'sku-2', name: 'Gadget', quantity: 1, price: 20 },
],
total: 40,
});
});
expect(screen.getByLabelText('Cart with 3 items')).toBeInTheDocument();
expect(screen.getByText('3')).toBeInTheDocument();
});
it('returns to empty state when cart is cleared', () => {
render(<CartBadge />);
act(() => {
dispatchMfeEvent('mfe:cart:updated', {
items: [{ id: 'sku-1', name: 'Widget', quantity: 1, price: 10 }],
total: 10,
});
});
expect(screen.getByText('1')).toBeInTheDocument();
act(() => {
dispatchMfeEvent('mfe:cart:updated', {
items: [],
total: 0,
});
});
expect(screen.getByLabelText('Cart is empty')).toBeInTheDocument();
});
});
Testing the useMfeEvent Hook
Test the hook in isolation to verify its subscription and cleanup behavior.
// packages/event-contracts/src/__tests__/useMfeEvent.test.ts
import { describe, it, expect, vi } from 'vitest';
import { renderHook, act } from '@testing-library/react';
import { useMfeEvent } from '../useMfeEvent';
describe('useMfeEvent', () => {
it('calls handler when the event is dispatched', () => {
const handler = vi.fn();
renderHook(() => useMfeEvent('mfe:cart:updated', handler));
act(() => {
window.dispatchEvent(
new CustomEvent('mfe:cart:updated', {
detail: { items: [], total: 0 },
})
);
});
expect(handler).toHaveBeenCalledTimes(1);
expect(handler).toHaveBeenCalledWith(
expect.objectContaining({
detail: { items: [], total: 0 },
})
);
});
it('cleans up the listener on unmount', () => {
const handler = vi.fn();
const { unmount } = renderHook(() =>
useMfeEvent('mfe:cart:updated', handler)
);
unmount();
act(() => {
window.dispatchEvent(
new CustomEvent('mfe:cart:updated', {
detail: { items: [], total: 0 },
})
);
});
expect(handler).not.toHaveBeenCalled();
});
it('always uses the latest handler without re-subscribing', () => {
const handler1 = vi.fn();
const handler2 = vi.fn();
const { rerender } = renderHook(
({ handler }) => useMfeEvent('mfe:cart:updated', handler),
{ initialProps: { handler: handler1 } }
);
// Update the handler
rerender({ handler: handler2 });
act(() => {
window.dispatchEvent(
new CustomEvent('mfe:cart:updated', {
detail: { items: [], total: 0 },
})
);
});
// Only the latest handler should be called
expect(handler1).not.toHaveBeenCalled();
expect(handler2).toHaveBeenCalledTimes(1);
});
});
Integration Testing: Event Contract Compliance
Verify that publishers and subscribers agree on the event contract. This test runs as part of CI to catch contract drift.
// packages/event-contracts/src/__tests__/contractCompliance.test.ts
import { describe, it, expect, vi } from 'vitest';
import { dispatch, subscribe } from '../index';
import type { MfeEventMap, MfeEventDetail } from '../types';
/**
* Helper that tests a full publish-subscribe cycle for a given event.
* Ensures the types flow correctly from dispatch to handler.
*/
function testEventContract<K extends keyof MfeEventMap>(
eventName: K,
payload: MfeEventDetail<K>
): void {
const handler = vi.fn();
const unsubscribe = subscribe(eventName, handler);
dispatch(eventName, payload);
expect(handler).toHaveBeenCalledTimes(1);
expect(handler).toHaveBeenCalledWith(
expect.objectContaining({
type: eventName,
detail: payload,
})
);
unsubscribe();
// Verify cleanup: handler should not be called after unsubscribe
dispatch(eventName, payload);
expect(handler).toHaveBeenCalledTimes(1);
}
describe('Event Contract Compliance', () => {
it('mfe:cart:updated contract', () => {
testEventContract('mfe:cart:updated', {
items: [{ id: 'sku-1', name: 'Widget', quantity: 1, price: 9.99 }],
total: 9.99,
});
});
it('mfe:user:context-changed contract', () => {
testEventContract('mfe:user:context-changed', {
userId: 'user-123',
orgId: 'org-456',
role: 'admin',
});
});
it('mfe:navigation:requested contract', () => {
testEventContract('mfe:navigation:requested', {
path: '/products/123',
params: { tab: 'reviews' },
});
});
it('mfe:notification:show contract', () => {
testEventContract('mfe:notification:show', {
message: 'Item added to cart',
type: 'success',
duration: 3000,
});
});
it('mfe:theme:changed contract', () => {
testEventContract('mfe:theme:changed', {
theme: 'dark',
});
});
it('mfe:auth:session-expired contract', () => {
testEventContract('mfe:auth:session-expired', {});
});
});
Test Utilities Package
Provide a shared testing utility that MFE teams can use in their test suites:
// packages/event-contracts/src/testing.ts
import type { MfeEventMap, MfeEventDetail } from './types';
/**
* Dispatch a typed MFE event for use in tests.
* Wraps the event dispatch in a way that works with React Testing Library's act().
*/
export function dispatchTestEvent<K extends keyof MfeEventMap>(
eventName: K,
detail: MfeEventDetail<K>
): void {
window.dispatchEvent(
new CustomEvent(eventName, { detail })
);
}
/**
* Create a spy that records all dispatches of a specific MFE event.
* Returns an object with the spy function and a cleanup function.
*
* @example
* const { spy, cleanup } = createEventSpy('mfe:cart:updated');
* // ... trigger some action ...
* expect(spy).toHaveBeenCalledWith(expect.objectContaining({
* detail: { items: [], total: 0 }
* }));
* cleanup();
*/
export function createEventSpy<K extends keyof MfeEventMap>(
eventName: K
): {
spy: ReturnType<typeof import('vitest').vi.fn>;
cleanup: () => void;
} {
const { vi } = await import('vitest');
const spy = vi.fn();
const handler = (event: Event) => {
spy(event as CustomEvent);
};
window.addEventListener(eventName, handler);
return {
spy,
cleanup: () => window.removeEventListener(eventName, handler),
};
}
/**
* Wait for a specific MFE event to be dispatched.
* Useful for testing async flows that eventually dispatch an event.
*
* @example
* const eventPromise = waitForEvent('mfe:cart:updated');
* triggerAsyncCartUpdate();
* const event = await eventPromise;
* expect(event.detail.total).toBe(29.99);
*/
export function waitForEvent<K extends keyof MfeEventMap>(
eventName: K,
timeoutMs = 5000
): Promise<CustomEvent<MfeEventDetail<K>>> {
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
window.removeEventListener(eventName, handler);
reject(new Error(`Timed out waiting for event: ${eventName}`));
}, timeoutMs);
const handler = (event: Event) => {
clearTimeout(timeout);
window.removeEventListener(eventName, handler);
resolve(event as CustomEvent<MfeEventDetail<K>>);
};
window.addEventListener(eventName, handler);
});
}
References
- MDN: CustomEvent — Browser API reference for CustomEvent construction and dispatch.
- MDN: EventTarget.dispatchEvent() — How events are dispatched and delivered.
- MDN: EventTarget.addEventListener() — Subscription API and options (capture, passive, once).
- Martin Fowler: Event-Driven Architecture — Foundational patterns for event-driven systems.
- Micro Frontends — Overview of micro frontend architecture patterns.
- Module Federation Documentation — Runtime module sharing used alongside the event system for component federation.