Skip to content

Commit 2915de3

Browse files
committed
fix(bridge): harden SSR V1 identity and lifecycle boundaries
Key registry state by moduleName+instanceId, hydrate empty SSR islands from markers, destroy provider roots when mount DOM identity changes, require a hydration registry for client references, and soft-cancel aborted Vue mounts so failed or stale renders cannot leak remotes.
1 parent 2c3f1e5 commit 2915de3

11 files changed

Lines changed: 239 additions & 61 deletions

File tree

packages/bridge/bridge-react/src/hydration.spec.tsx

Lines changed: 33 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -82,12 +82,12 @@ describe('Bridge hydration registry', () => {
8282
document.body.innerHTML = original + original;
8383
expect(() =>
8484
createBridgeHydrationRegistry(document).peek('remote/app', 'remote-1'),
85-
).toThrow(/Duplicate Bridge SSR instanceId/);
85+
).toThrow(/Duplicate Bridge SSR identity/);
8686

8787
document.body.innerHTML = original;
88-
expect(() =>
88+
expect(
8989
createBridgeHydrationRegistry(document).peek('other/app', 'remote-1'),
90-
).toThrow(/belongs to remote\/app/);
90+
).toBeUndefined();
9191

9292
document.body.innerHTML = original.replace(
9393
'</script></div>',
@@ -105,4 +105,34 @@ describe('Bridge hydration registry', () => {
105105
createBridgeHydrationRegistry(document).peek('remote/app', 'remote-1'),
106106
).toThrow(/incompatible state envelope/);
107107
});
108+
109+
it('allows the same instanceId across different module names', () => {
110+
const second = {
111+
...result,
112+
moduleName: 'other/app',
113+
html: '<p>other remote</p>',
114+
};
115+
document.body.innerHTML =
116+
renderToStaticMarkup(
117+
<BridgeRemoteSlot
118+
moduleName={result.moduleName}
119+
instanceId={result.instanceId}
120+
payload={result}
121+
/>,
122+
) +
123+
renderToStaticMarkup(
124+
<BridgeRemoteSlot
125+
moduleName={second.moduleName}
126+
instanceId={second.instanceId}
127+
payload={second}
128+
/>,
129+
);
130+
const registry = createBridgeHydrationRegistry(document);
131+
expect(registry.peek('remote/app', 'remote-1')?.html).toBe(
132+
'<p>server remote</p>',
133+
);
134+
expect(registry.peek('other/app', 'remote-1')?.html).toBe(
135+
'<p>other remote</p>',
136+
);
137+
});
108138
});

packages/bridge/bridge-react/src/provider/versions/bridge-base.tsx

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,8 @@ export function createBaseBridgeComponent<T>({
7171
? hydrateState(ssrState as BridgeJSONValue | undefined)
7272
: {};
7373

74+
if (info.signal?.aborted) return;
75+
7476
const beforeBridgeRenderRes =
7577
instance?.bridgeHook?.lifecycle?.beforeBridgeRender?.emit(info) || {};
7678

@@ -124,6 +126,12 @@ export function createBaseBridgeComponent<T>({
124126
rootMap.set(dom, root as any);
125127
}
126128

129+
if (info.signal?.aborted) {
130+
if (root && 'unmount' in root) root.unmount();
131+
rootMap.delete(dom);
132+
return;
133+
}
134+
127135
if (root && 'render' in root) {
128136
if (!didHydrate) root.render(rootComponentWithErrorBoundary);
129137
}

packages/bridge/bridge-react/src/provider/versions/bridge-server.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ export function createServerBridgeComponent<T>(
3434
const config =
3535
typeof bridgeInfo.ssr === 'object' ? bridgeInfo.ssr : undefined;
3636
const preparedValue = await config?.prepare?.(context);
37+
if (context.signal.aborted) throw context.signal.reason;
3738
const prepared = (preparedValue || {}) as BridgeSSRPrepareResult<T>;
3839
const renderInfo = (prepared.props ?? context.props) as T &
3940
ProviderParams;

packages/bridge/bridge-react/src/remote/RemoteAppWrapper.tsx

Lines changed: 51 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import { federationRuntime } from '../provider/plugin';
1414
import { RemoteComponentProps, RemoteAppParams } from '../types';
1515
import type { RemoteAppSSRProps } from '../types';
1616
import {
17+
BridgeSSRError,
1718
getMatchingBridgeSSRPayload,
1819
type BridgeSSRReference,
1920
type BridgeSSRResult,
@@ -35,6 +36,25 @@ function scheduleBridgeDestroy(destroy: () => void) {
3536
else void Promise.resolve().then(destroy);
3637
}
3738

39+
function destroyProviderRoot(
40+
provider: { destroy?: (info: { dom: HTMLElement }) => void } | null,
41+
dom: HTMLElement | null,
42+
destroyInfo: Record<string, unknown>,
43+
) {
44+
if (!provider?.destroy || !dom) return;
45+
try {
46+
federationRuntime.instance?.bridgeHook?.lifecycle?.beforeBridgeDestroy?.emit(
47+
destroyInfo,
48+
);
49+
provider.destroy({ dom });
50+
federationRuntime.instance?.bridgeHook?.lifecycle?.afterBridgeDestroy?.emit(
51+
destroyInfo,
52+
);
53+
} catch (error) {
54+
LoggerInstance.error('Bridge remote destroy failed', error);
55+
}
56+
}
57+
3858
export const RemoteAppWrapper = forwardRef<HTMLDivElement, any>(function (
3959
props: RemoteAppParams & RemoteComponentProps & RemoteAppSSRProps,
4060
ref,
@@ -66,6 +86,11 @@ export const RemoteAppWrapper = forwardRef<HTMLDivElement, any>(function (
6686
? (ssrPayload as BridgeSSRReference)
6787
: undefined;
6888
const registry = useBridgeHydrationRegistry();
89+
if (reference && !registry) {
90+
throw new BridgeSSRError(
91+
'Bridge SSR references require BridgeHydrationProvider before hydrateRoot',
92+
);
93+
}
6994
const hydrationSnapshotRef = useRef<{
7095
identity: string;
7196
snapshot: ReturnType<NonNullable<typeof registry>['peek']>;
@@ -78,12 +103,16 @@ export const RemoteAppWrapper = forwardRef<HTMLDivElement, any>(function (
78103
) {
79104
hydrationSnapshotRef.current = {
80105
identity: hydrationIdentity,
81-
snapshot: registry?.peek(reference!.moduleName, instanceId!),
106+
snapshot: registry!.peek(reference!.moduleName, instanceId!),
82107
};
83108
}
84109
const snapshot = hydrationIdentity
85110
? hydrationSnapshotRef.current?.snapshot
86111
: undefined;
112+
// Keep the SSR slot shape for the lifetime of a recovered snapshot so SPA
113+
// revisit CSR switches do not replace the hydrated mount node underneath an
114+
// active provider root. Hosts that omit `ssr` after consume still retain the
115+
// snapshot until this wrapper unmounts.
87116
const hasSSRPayload = Boolean((serverPayload || snapshot) && instanceId);
88117

89118
const instance = federationRuntime.instance;
@@ -132,17 +161,7 @@ export const RemoteAppWrapper = forwardRef<HTMLDivElement, any>(function (
132161
...resProps,
133162
};
134163
scheduleBridgeDestroy(() => {
135-
try {
136-
instance?.bridgeHook?.lifecycle?.beforeBridgeDestroy?.emit(
137-
destroyInfo,
138-
);
139-
provider.destroy({ dom });
140-
instance?.bridgeHook?.lifecycle?.afterBridgeDestroy?.emit(
141-
destroyInfo,
142-
);
143-
} catch (error) {
144-
LoggerInstance.error('Bridge remote destroy failed', error);
145-
}
164+
destroyProviderRoot(provider, dom, destroyInfo);
146165
});
147166
};
148167
}, [moduleName, providerInfo]);
@@ -171,6 +190,21 @@ export const RemoteAppWrapper = forwardRef<HTMLDivElement, any>(function (
171190
if (areRenderInputsEqual(lastRenderInputsRef.current, renderInputs)) return;
172191
lastRenderInputsRef.current = renderInputs;
173192

193+
const previousDom = renderDom.current;
194+
if (previousDom && previousDom !== dom) {
195+
// SSR slot <-> CSR mount transitions replace the ref target. Destroy the
196+
// previous provider root before rendering into the new DOM node.
197+
destroyProviderRoot(provider, previousDom, {
198+
moduleName,
199+
dom: previousDom,
200+
basename,
201+
memoryRoute,
202+
fallback,
203+
...resProps,
204+
});
205+
}
206+
renderDom.current = dom;
207+
174208
const renderProps = {
175209
moduleName,
176210
dom,
@@ -182,7 +216,6 @@ export const RemoteAppWrapper = forwardRef<HTMLDivElement, any>(function (
182216
signal,
183217
...resProps,
184218
};
185-
renderDom.current = dom;
186219

187220
renderQueueRef.current = renderQueueRef.current
188221
.then(async () => {
@@ -192,12 +225,16 @@ export const RemoteAppWrapper = forwardRef<HTMLDivElement, any>(function (
192225
renderProps,
193226
) || {},
194227
)) as { extraProps?: Record<string, unknown> };
228+
if (signal.aborted || !dom.isConnected) return;
195229
const currentRenderProps = {
196230
...renderProps,
197231
...beforeBridgeRenderRes.extraProps,
198232
};
199233
await provider.render(currentRenderProps);
200-
if (signal.aborted || !dom.isConnected) return;
234+
if (signal.aborted || !dom.isConnected) {
235+
provider.destroy?.({ dom });
236+
return;
237+
}
201238
if (
202239
snapshot &&
203240
instanceId &&

packages/bridge/bridge-shared/src/renderRemoteBridge.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,13 @@ export async function renderRemoteBridge<P = Record<string, unknown>>(
4343
);
4444
}
4545

46+
if (options.request.signal.aborted) {
47+
throw new BridgeSSRError(
48+
`Bridge SSR request for ${options.moduleName} was aborted`,
49+
options.request.signal.reason,
50+
);
51+
}
52+
4653
const exportName = options.export ?? 'default';
4754
const factory = remoteModule[exportName];
4855
if (typeof factory !== 'function') {

packages/bridge/bridge-shared/src/ssr.spec.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,29 @@ describe('Bridge SSR V1 contract', () => {
2525
expect(() => assertBridgeJSONValue({ value: Number.NaN })).toThrow(
2626
/finite/,
2727
);
28+
expect(() =>
29+
assertBridgeJSONValue(JSON.parse('{"__proto__":{"polluted":true}}')),
30+
).toThrow(/__proto__/);
31+
});
32+
33+
it('treats empty SSR markup as hydration-eligible when markers match', async () => {
34+
const { hasBridgeSSRMarkup, getBridgeSSRContainerAttrs } =
35+
await import('./ssr');
36+
const attrs = getBridgeSSRContainerAttrs({
37+
moduleName: 'remote/app',
38+
instanceId: 'remote-1',
39+
});
40+
const dom = {
41+
getAttribute(name: string) {
42+
return attrs[name] ?? null;
43+
},
44+
} as HTMLElement;
45+
expect(
46+
hasBridgeSSRMarkup(dom, {
47+
moduleName: 'remote/app',
48+
instanceId: 'remote-1',
49+
}),
50+
).toBe(true);
2851
});
2952

3053
it('validates host-carried results before matching their identity', () => {

0 commit comments

Comments
 (0)