Skip to content

Commit 21d8004

Browse files
authored
feat(bridge): add lifecycle observability (#4922)
1 parent 5b8174a commit 21d8004

21 files changed

Lines changed: 2304 additions & 156 deletions

File tree

.changeset/calm-bridges-observe.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
'@module-federation/runtime-core': patch
3+
'@module-federation/runtime': patch
4+
'@module-federation/bridge-shared': patch
5+
'@module-federation/bridge-react': patch
6+
'@module-federation/bridge-vue3': patch
7+
'@module-federation/observability-plugin': patch
8+
---
9+
10+
Add safe Bridge render, commit, failure, destroy, and route synchronization signals for React and Vue applications, together with correlated runtime observability reports.
Lines changed: 262 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,262 @@
1+
import React from 'react';
2+
import { act, render, waitFor } from '@testing-library/react';
3+
import { MemoryRouter, useNavigate } from 'react-router-dom';
4+
import {
5+
createBridgeComponent as createLegacyBridgeComponent,
6+
createRemoteAppComponent,
7+
} from '../src';
8+
import { createBridgeComponent as createReact18BridgeComponent } from '../src/v18';
9+
import { createBridgeComponent as createReact19BridgeComponent } from '../src/v19';
10+
import { federationRuntime } from '../src/provider/plugin';
11+
import { createContainer } from './util';
12+
13+
const createLifecycleFixture = () => {
14+
const events: Array<{ lifecycle: string; payload: Record<string, any> }> = [];
15+
const eventHook = (lifecycle: string) => ({
16+
emit: jest.fn((payload: Record<string, any>) => {
17+
events.push({ lifecycle, payload });
18+
}),
19+
});
20+
const lifecycle = {
21+
beforeBridgeRender: { emit: jest.fn(() => ({})) },
22+
afterBridgeRender: { emit: jest.fn() },
23+
beforeBridgeDestroy: { emit: jest.fn() },
24+
afterBridgeDestroy: { emit: jest.fn() },
25+
beforeBridgeOperation: eventHook('beforeBridgeOperation'),
26+
bridgeRenderInvoked: eventHook('bridgeRenderInvoked'),
27+
afterBridgeOperation: eventHook('afterBridgeOperation'),
28+
afterBridgeCommit: eventHook('afterBridgeCommit'),
29+
};
30+
federationRuntime.instance = { bridgeHook: { lifecycle } } as any;
31+
return { events, lifecycle };
32+
};
33+
34+
describe('React Bridge operation lifecycle', () => {
35+
afterEach(() => {
36+
federationRuntime.instance = null;
37+
document.body.innerHTML = '';
38+
});
39+
40+
it.each([
41+
['legacy', createLegacyBridgeComponent],
42+
['react18', createReact18BridgeComponent],
43+
['react19', createReact19BridgeComponent],
44+
])('confirms a real commit for the %s provider path', async (_, factory) => {
45+
const { events } = createLifecycleFixture();
46+
const containerInfo = createContainer();
47+
const bridge = factory({
48+
rootComponent: () => <div>committed</div>,
49+
})();
50+
51+
await act(async () => {
52+
await bridge.render({
53+
dom: containerInfo.container,
54+
moduleName: 'remote/App',
55+
basename: '/safe?token=private#hash',
56+
businessValue: 'must-not-leak',
57+
});
58+
});
59+
60+
await waitFor(() => {
61+
expect(
62+
events.some((event) => event.lifecycle === 'afterBridgeCommit'),
63+
).toBe(true);
64+
});
65+
const renderEvents = events.filter(
66+
(event) => event.payload.operation === 'render',
67+
);
68+
expect(renderEvents.map((event) => event.lifecycle)).toEqual(
69+
expect.arrayContaining([
70+
'beforeBridgeOperation',
71+
'bridgeRenderInvoked',
72+
'afterBridgeOperation',
73+
'afterBridgeCommit',
74+
]),
75+
);
76+
expect(
77+
new Set(renderEvents.map((event) => event.payload.operationId)).size,
78+
).toBe(1);
79+
expect(JSON.stringify(events)).not.toContain('must-not-leak');
80+
expect(JSON.stringify(events)).not.toContain('token=private');
81+
expect(JSON.stringify(events)).not.toContain('HTMLDivElement');
82+
83+
bridge.destroy({ dom: containerInfo.container, moduleName: 'remote/App' });
84+
bridge.destroy({ dom: containerInfo.container, moduleName: 'remote/App' });
85+
const destroyResults = events.filter(
86+
(event) =>
87+
event.lifecycle === 'afterBridgeOperation' &&
88+
event.payload.operation === 'destroy',
89+
);
90+
expect(destroyResults.map((event) => event.payload.outcome)).toEqual([
91+
'success',
92+
'skipped',
93+
]);
94+
containerInfo.clean();
95+
});
96+
97+
it('distinguishes updates and correlates consumer and producer operations', async () => {
98+
const { events } = createLifecycleFixture();
99+
const BridgeProvider = createReact18BridgeComponent({
100+
rootComponent: ({ value }: { value?: string }) => <div>{value}</div>,
101+
});
102+
const remoteModule: Record<PropertyKey, unknown> = {
103+
default: BridgeProvider,
104+
};
105+
remoteModule[Symbol.for('mf_module_id')] = 'remote/App';
106+
const RemoteComponent = createRemoteAppComponent({
107+
loader: async () => remoteModule,
108+
fallback: () => null,
109+
loading: null,
110+
});
111+
const result = render(<RemoteComponent value="first" />);
112+
113+
await waitFor(() =>
114+
expect(result.container.textContent).toContain('first'),
115+
);
116+
const firstStarts = events.filter(
117+
(event) =>
118+
event.lifecycle === 'beforeBridgeOperation' &&
119+
event.payload.operation === 'render',
120+
);
121+
expect(firstStarts.map((event) => event.payload.side).sort()).toEqual([
122+
'consumer',
123+
'producer',
124+
]);
125+
expect(
126+
new Set(firstStarts.map((event) => event.payload.operationId)).size,
127+
).toBe(1);
128+
129+
result.rerender(<RemoteComponent value="second" />);
130+
await waitFor(() =>
131+
expect(result.container.textContent).toContain('second'),
132+
);
133+
expect(
134+
events.some(
135+
(event) =>
136+
event.lifecycle === 'beforeBridgeOperation' &&
137+
event.payload.operation === 'update' &&
138+
event.payload.reason === 'props-update',
139+
),
140+
).toBe(true);
141+
result.unmount();
142+
});
143+
144+
it.each([
145+
[
146+
'throw',
147+
() => {
148+
throw new Error('sync render failed token=secret');
149+
},
150+
],
151+
[
152+
'reject',
153+
() => Promise.reject(new Error('async render failed token=secret')),
154+
],
155+
])(
156+
'records a custom render %s and preserves the error',
157+
async (_, customRender) => {
158+
const { events } = createLifecycleFixture();
159+
const containerInfo = createContainer();
160+
const bridge = createLegacyBridgeComponent({
161+
rootComponent: () => <div />,
162+
render: customRender as any,
163+
})();
164+
165+
await expect(
166+
bridge.render({
167+
dom: containerInfo.container,
168+
moduleName: 'remote/App',
169+
}),
170+
).rejects.toThrow('render failed');
171+
const result = events.find(
172+
(event) =>
173+
event.lifecycle === 'afterBridgeOperation' &&
174+
event.payload.operation === 'render',
175+
);
176+
expect(result?.payload.outcome).toBe('error');
177+
expect(result?.payload.error.message).not.toContain('token=secret');
178+
containerInfo.clean();
179+
},
180+
);
181+
182+
it('records destroy errors without swallowing them', async () => {
183+
const { events } = createLifecycleFixture();
184+
const containerInfo = createContainer();
185+
const bridge = createLegacyBridgeComponent({
186+
rootComponent: () => <div />,
187+
createRoot: () => ({
188+
render: jest.fn(),
189+
unmount: () => {
190+
throw new Error('destroy failed');
191+
},
192+
}),
193+
})();
194+
await bridge.render({
195+
dom: containerInfo.container,
196+
moduleName: 'remote/App',
197+
});
198+
expect(
199+
events.some((event) => event.lifecycle === 'afterBridgeCommit'),
200+
).toBe(false);
201+
202+
expect(() =>
203+
bridge.destroy({
204+
dom: containerInfo.container,
205+
moduleName: 'remote/App',
206+
}),
207+
).toThrow('destroy failed');
208+
expect(
209+
events.find(
210+
(event) =>
211+
event.lifecycle === 'afterBridgeOperation' &&
212+
event.payload.operation === 'destroy',
213+
)?.payload.outcome,
214+
).toBe('error');
215+
containerInfo.clean();
216+
});
217+
218+
it('records host-to-remote popstate route synchronization', async () => {
219+
const { events } = createLifecycleFixture();
220+
const BridgeProvider = createReact18BridgeComponent({
221+
rootComponent: () => <div>remote</div>,
222+
});
223+
const remoteModule: Record<PropertyKey, unknown> = {
224+
default: BridgeProvider,
225+
};
226+
remoteModule[Symbol.for('mf_module_id')] = 'remote/App';
227+
const RemoteComponent = createRemoteAppComponent({
228+
loader: async () => remoteModule,
229+
fallback: () => null,
230+
loading: null,
231+
});
232+
let navigate!: ReturnType<typeof useNavigate>;
233+
const Host = () => {
234+
navigate = useNavigate();
235+
return <RemoteComponent />;
236+
};
237+
const result = render(
238+
<MemoryRouter initialEntries={['/first']}>
239+
<Host />
240+
</MemoryRouter>,
241+
);
242+
await waitFor(() =>
243+
expect(result.container.textContent).toContain('remote'),
244+
);
245+
246+
act(() => navigate('/second'));
247+
await waitFor(() => {
248+
expect(
249+
events.some(
250+
(event) =>
251+
event.lifecycle === 'afterBridgeOperation' &&
252+
event.payload.operation === 'route-sync' &&
253+
event.payload.side === 'consumer' &&
254+
event.payload.route.action === 'host-to-remote' &&
255+
event.payload.route.mechanism === 'popstate' &&
256+
event.payload.outcome === 'success',
257+
),
258+
).toBe(true);
259+
});
260+
result.unmount();
261+
});
262+
});

0 commit comments

Comments
 (0)