Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 33 additions & 3 deletions packages/bridge/bridge-react/src/hydration.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -82,12 +82,12 @@ describe('Bridge hydration registry', () => {
document.body.innerHTML = original + original;
expect(() =>
createBridgeHydrationRegistry(document).peek('remote/app', 'remote-1'),
).toThrow(/Duplicate Bridge SSR instanceId/);
).toThrow(/Duplicate Bridge SSR identity/);

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

document.body.innerHTML = original.replace(
'</script></div>',
Expand All @@ -105,4 +105,34 @@ describe('Bridge hydration registry', () => {
createBridgeHydrationRegistry(document).peek('remote/app', 'remote-1'),
).toThrow(/incompatible state envelope/);
});

it('allows the same instanceId across different module names', () => {
const second = {
...result,
moduleName: 'other/app',
html: '<p>other remote</p>',
};
document.body.innerHTML =
renderToStaticMarkup(
<BridgeRemoteSlot
moduleName={result.moduleName}
instanceId={result.instanceId}
payload={result}
/>,
) +
renderToStaticMarkup(
<BridgeRemoteSlot
moduleName={second.moduleName}
instanceId={second.instanceId}
payload={second}
/>,
);
const registry = createBridgeHydrationRegistry(document);
expect(registry.peek('remote/app', 'remote-1')?.html).toBe(
'<p>server remote</p>',
);
expect(registry.peek('other/app', 'remote-1')?.html).toBe(
'<p>other remote</p>',
);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,8 @@ export function createBaseBridgeComponent<T>({
? hydrateState(ssrState as BridgeJSONValue | undefined)
: {};

if (info.signal?.aborted) return;

const beforeBridgeRenderRes =
instance?.bridgeHook?.lifecycle?.beforeBridgeRender?.emit(info) || {};

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

if (info.signal?.aborted) {
if (root && 'unmount' in root) root.unmount();
rootMap.delete(dom);
return;
}

if (root && 'render' in root) {
if (!didHydrate) root.render(rootComponentWithErrorBoundary);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ export function createServerBridgeComponent<T>(
const config =
typeof bridgeInfo.ssr === 'object' ? bridgeInfo.ssr : undefined;
const preparedValue = await config?.prepare?.(context);
if (context.signal.aborted) throw context.signal.reason;
const prepared = (preparedValue || {}) as BridgeSSRPrepareResult<T>;
const renderInfo = (prepared.props ?? context.props) as T &
ProviderParams;
Expand Down
65 changes: 51 additions & 14 deletions packages/bridge/bridge-react/src/remote/RemoteAppWrapper.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { federationRuntime } from '../provider/plugin';
import { RemoteComponentProps, RemoteAppParams } from '../types';
import type { RemoteAppSSRProps } from '../types';
import {
BridgeSSRError,
getMatchingBridgeSSRPayload,
type BridgeSSRReference,
type BridgeSSRResult,
Expand All @@ -35,6 +36,25 @@ function scheduleBridgeDestroy(destroy: () => void) {
else void Promise.resolve().then(destroy);
}

function destroyProviderRoot(
provider: { destroy?: (info: { dom: HTMLElement }) => void } | null,
dom: HTMLElement | null,
destroyInfo: Record<string, unknown>,
) {
if (!provider?.destroy || !dom) return;
try {
federationRuntime.instance?.bridgeHook?.lifecycle?.beforeBridgeDestroy?.emit(
destroyInfo,
);
provider.destroy({ dom });
federationRuntime.instance?.bridgeHook?.lifecycle?.afterBridgeDestroy?.emit(
destroyInfo,
);
} catch (error) {
LoggerInstance.error('Bridge remote destroy failed', error);
}
}

export const RemoteAppWrapper = forwardRef<HTMLDivElement, any>(function (
props: RemoteAppParams & RemoteComponentProps & RemoteAppSSRProps,
ref,
Expand Down Expand Up @@ -66,6 +86,11 @@ export const RemoteAppWrapper = forwardRef<HTMLDivElement, any>(function (
? (ssrPayload as BridgeSSRReference)
: undefined;
const registry = useBridgeHydrationRegistry();
if (reference && !registry) {
throw new BridgeSSRError(
'Bridge SSR references require BridgeHydrationProvider before hydrateRoot',
);
}
const hydrationSnapshotRef = useRef<{
identity: string;
snapshot: ReturnType<NonNullable<typeof registry>['peek']>;
Expand All @@ -78,12 +103,16 @@ export const RemoteAppWrapper = forwardRef<HTMLDivElement, any>(function (
) {
hydrationSnapshotRef.current = {
identity: hydrationIdentity,
snapshot: registry?.peek(reference!.moduleName, instanceId!),
snapshot: registry!.peek(reference!.moduleName, instanceId!),
};
}
const snapshot = hydrationIdentity
? hydrationSnapshotRef.current?.snapshot
: undefined;
// Keep the SSR slot shape for the lifetime of a recovered snapshot so SPA
// revisit CSR switches do not replace the hydrated mount node underneath an
// active provider root. Hosts that omit `ssr` after consume still retain the
// snapshot until this wrapper unmounts.
const hasSSRPayload = Boolean((serverPayload || snapshot) && instanceId);

const instance = federationRuntime.instance;
Expand Down Expand Up @@ -132,17 +161,7 @@ export const RemoteAppWrapper = forwardRef<HTMLDivElement, any>(function (
...resProps,
};
scheduleBridgeDestroy(() => {
try {
instance?.bridgeHook?.lifecycle?.beforeBridgeDestroy?.emit(
destroyInfo,
);
provider.destroy({ dom });
instance?.bridgeHook?.lifecycle?.afterBridgeDestroy?.emit(
destroyInfo,
);
} catch (error) {
LoggerInstance.error('Bridge remote destroy failed', error);
}
destroyProviderRoot(provider, dom, destroyInfo);
});
};
}, [moduleName, providerInfo]);
Expand Down Expand Up @@ -171,6 +190,21 @@ export const RemoteAppWrapper = forwardRef<HTMLDivElement, any>(function (
if (areRenderInputsEqual(lastRenderInputsRef.current, renderInputs)) return;
lastRenderInputsRef.current = renderInputs;

const previousDom = renderDom.current;
if (previousDom && previousDom !== dom) {
// SSR slot <-> CSR mount transitions replace the ref target. Destroy the
// previous provider root before rendering into the new DOM node.
destroyProviderRoot(provider, previousDom, {
moduleName,
dom: previousDom,
basename,
memoryRoute,
fallback,
...resProps,
});
}
renderDom.current = dom;

const renderProps = {
moduleName,
dom,
Expand All @@ -182,7 +216,6 @@ export const RemoteAppWrapper = forwardRef<HTMLDivElement, any>(function (
signal,
...resProps,
};
renderDom.current = dom;

renderQueueRef.current = renderQueueRef.current
.then(async () => {
Expand All @@ -192,12 +225,16 @@ export const RemoteAppWrapper = forwardRef<HTMLDivElement, any>(function (
renderProps,
) || {},
)) as { extraProps?: Record<string, unknown> };
if (signal.aborted || !dom.isConnected) return;
const currentRenderProps = {
...renderProps,
...beforeBridgeRenderRes.extraProps,
};
await provider.render(currentRenderProps);
if (signal.aborted || !dom.isConnected) return;
if (signal.aborted || !dom.isConnected) {
provider.destroy?.({ dom });
return;
}
if (
snapshot &&
instanceId &&
Expand Down
7 changes: 7 additions & 0 deletions packages/bridge/bridge-shared/src/renderRemoteBridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,13 @@ export async function renderRemoteBridge<P = Record<string, unknown>>(
);
}

if (options.request.signal.aborted) {
throw new BridgeSSRError(
`Bridge SSR request for ${options.moduleName} was aborted`,
options.request.signal.reason,
);
}

const exportName = options.export ?? 'default';
const factory = remoteModule[exportName];
if (typeof factory !== 'function') {
Expand Down
23 changes: 23 additions & 0 deletions packages/bridge/bridge-shared/src/ssr.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,29 @@ describe('Bridge SSR V1 contract', () => {
expect(() => assertBridgeJSONValue({ value: Number.NaN })).toThrow(
/finite/,
);
expect(() =>
assertBridgeJSONValue(JSON.parse('{"__proto__":{"polluted":true}}')),
).toThrow(/__proto__/);
});

it('treats empty SSR markup as hydration-eligible when markers match', async () => {
const { hasBridgeSSRMarkup, getBridgeSSRContainerAttrs } =
await import('./ssr');
const attrs = getBridgeSSRContainerAttrs({
moduleName: 'remote/app',
instanceId: 'remote-1',
});
const dom = {
getAttribute(name: string) {
return attrs[name] ?? null;
},
} as HTMLElement;
expect(
hasBridgeSSRMarkup(dom, {
moduleName: 'remote/app',
instanceId: 'remote-1',
}),
).toBe(true);
});

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