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 @@ -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
61 changes: 47 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,7 +103,7 @@ 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
Expand Down Expand Up @@ -132,17 +157,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 +186,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 +212,6 @@ export const RemoteAppWrapper = forwardRef<HTMLDivElement, any>(function (
signal,
...resProps,
};
renderDom.current = dom;

renderQueueRef.current = renderQueueRef.current
.then(async () => {
Expand All @@ -192,12 +221,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
78 changes: 46 additions & 32 deletions packages/bridge/bridge-shared/src/ssr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,11 @@ function validateJSON(
for (const [key, descriptor] of Object.entries(
Object.getOwnPropertyDescriptors(value),
)) {
if (key === '__proto__' || key === 'prototype' || key === 'constructor') {
throw new BridgeSSRError(
`Bridge SSR value at ${path} must not contain ${key}`,
);
}
if (
!descriptor.enumerable ||
!Object.prototype.hasOwnProperty.call(descriptor, 'value')
Expand Down Expand Up @@ -287,15 +292,16 @@ export function hasBridgeSSRMarkup(
dom: HTMLElement,
value?: { moduleName?: string; instanceId?: string },
) {
// Empty SSR output is still hydration-eligible: trust markers/identity, not
// child presence. Remotes may render null/empty fragments with state only.
return (
dom.getAttribute(MF_BRIDGE_SSR_ATTR) === 'true' &&
dom.getAttribute(MF_BRIDGE_VERSION_ATTR) ===
String(BRIDGE_SSR_PROTOCOL_VERSION) &&
(!value?.moduleName ||
dom.getAttribute(MF_BRIDGE_MODULE_ATTR) === value.moduleName) &&
(!value?.instanceId ||
dom.getAttribute(MF_BRIDGE_INSTANCE_ATTR) === value.instanceId) &&
dom.hasChildNodes()
dom.getAttribute(MF_BRIDGE_INSTANCE_ATTR) === value.instanceId)
);
}

Expand Down Expand Up @@ -324,16 +330,21 @@ function hydrationError(message: string, cause?: unknown) {
return new BridgeSSRError(message, cause);
}

function hydrationIdentityKey(moduleName: string, instanceId: string) {
return `${moduleName}\0${instanceId}`;
}

function readSlotSnapshot(slot: HTMLElement): BridgeHydrationSnapshot {
const protocolVersion = Number(slot.getAttribute(MF_BRIDGE_VERSION_ATTR));
const versionAttr = slot.getAttribute(MF_BRIDGE_VERSION_ATTR);
const moduleName = slot.getAttribute(MF_BRIDGE_MODULE_ATTR) || '';
const instanceId = slot.getAttribute(MF_BRIDGE_INSTANCE_ATTR) || '';
assertBridgeSSRIdentity({ moduleName, instanceId });
if (protocolVersion !== BRIDGE_SSR_PROTOCOL_VERSION) {
if (versionAttr !== String(BRIDGE_SSR_PROTOCOL_VERSION)) {
throw hydrationError(
`Bridge SSR slot ${instanceId} uses unsupported protocol version ${protocolVersion}`,
`Bridge SSR slot ${moduleName}:${instanceId} uses unsupported protocol version ${versionAttr}`,
);
}
const protocolVersion = BRIDGE_SSR_PROTOCOL_VERSION;

const mounts = directChildrenWithAttribute<HTMLElement>(
slot,
Expand Down Expand Up @@ -429,7 +440,7 @@ export function createBridgeHydrationRegistry(
root ?? (typeof document === 'undefined' ? undefined : document);
if (!hydrationRoot) {
throw hydrationError(
'createBridgeHydrationRegistry requires a document root on the server',
'createBridgeHydrationRegistry requires a document root',
);
}
const snapshots = new Map<string, BridgeHydrationSnapshot>();
Expand All @@ -440,55 +451,58 @@ export function createBridgeHydrationRegistry(
`[${MF_BRIDGE_SLOT_ATTR}="true"]`,
),
)) {
const moduleName = slot.getAttribute(MF_BRIDGE_MODULE_ATTR) || '';
const instanceId = slot.getAttribute(MF_BRIDGE_INSTANCE_ATTR) || '';
if (snapshots.has(instanceId) || errors.has(instanceId)) {
snapshots.delete(instanceId);
const key = hydrationIdentityKey(moduleName, instanceId);
if (snapshots.has(key) || errors.has(key)) {
snapshots.delete(key);
errors.set(
instanceId,
hydrationError(`Duplicate Bridge SSR instanceId ${instanceId}`),
key,
hydrationError(
`Duplicate Bridge SSR identity ${moduleName}:${instanceId}`,
),
);
continue;
}
try {
const snapshot = readSlotSnapshot(slot);
snapshots.set(snapshot.instanceId, snapshot);
snapshots.set(
hydrationIdentityKey(snapshot.moduleName, snapshot.instanceId),
snapshot,
);
} catch (error) {
errors.set(
instanceId,
key,
error instanceof BridgeSSRError
? error
: hydrationError(
`Unable to read Bridge SSR slot ${instanceId}`,
`Unable to read Bridge SSR slot ${moduleName}:${instanceId}`,
error,
),
);
}
}

const peek = (moduleName: string, instanceId: string) => {
const key = hydrationIdentityKey(moduleName, instanceId);
const error = errors.get(key);
if (error) throw error;
return snapshots.get(key);
};

return {
peek(moduleName, instanceId) {
const error = errors.get(instanceId);
if (error) throw error;
const snapshot = snapshots.get(instanceId);
if (!snapshot) return undefined;
if (snapshot.moduleName !== moduleName) {
throw hydrationError(
`Bridge SSR instance ${instanceId} belongs to ${snapshot.moduleName}, not ${moduleName}`,
);
}
return snapshot;
},
peek,
consume(moduleName, instanceId) {
const snapshot = this.peek(moduleName, instanceId);
snapshots.delete(instanceId);
errors.delete(instanceId);
const key = hydrationIdentityKey(moduleName, instanceId);
const snapshot = peek(moduleName, instanceId);
snapshots.delete(key);
errors.delete(key);
return snapshot;
},
fail(moduleName, instanceId) {
const snapshot = snapshots.get(instanceId);
if (snapshot && snapshot.moduleName !== moduleName) return;
snapshots.delete(instanceId);
errors.delete(instanceId);
const key = hydrationIdentityKey(moduleName, instanceId);
snapshots.delete(key);
errors.delete(key);
},
};
}
1 change: 1 addition & 0 deletions packages/bridge/bridge-shared/src/type.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ export type BridgeSSRResult = {
protocolVersion: typeof BRIDGE_SSR_PROTOCOL_VERSION;
moduleName: string;
instanceId: string;
/** Trusted remote HTML. Remotes are treated as trusted HTML producers. */
html: string;
dehydratedState?: BridgeJSONValue;
};
Expand Down
Loading
Loading