diff --git a/packages/bridge/bridge-react/src/hydration.spec.tsx b/packages/bridge/bridge-react/src/hydration.spec.tsx index d7cc65c6c76..4154aad18f8 100644 --- a/packages/bridge/bridge-react/src/hydration.spec.tsx +++ b/packages/bridge/bridge-react/src/hydration.spec.tsx @@ -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( '', @@ -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: '

other remote

', + }; + document.body.innerHTML = + renderToStaticMarkup( + , + ) + + renderToStaticMarkup( + , + ); + const registry = createBridgeHydrationRegistry(document); + expect(registry.peek('remote/app', 'remote-1')?.html).toBe( + '

server remote

', + ); + expect(registry.peek('other/app', 'remote-1')?.html).toBe( + '

other remote

', + ); + }); }); diff --git a/packages/bridge/bridge-react/src/provider/versions/bridge-server.tsx b/packages/bridge/bridge-react/src/provider/versions/bridge-server.tsx index ab3839cdcbb..58ad7d952a8 100644 --- a/packages/bridge/bridge-react/src/provider/versions/bridge-server.tsx +++ b/packages/bridge/bridge-react/src/provider/versions/bridge-server.tsx @@ -34,6 +34,7 @@ export function createServerBridgeComponent( 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; const renderInfo = (prepared.props ?? context.props) as T & ProviderParams; diff --git a/packages/bridge/bridge-react/src/remote/RemoteAppWrapper.tsx b/packages/bridge/bridge-react/src/remote/RemoteAppWrapper.tsx index 45b07332dd8..047f99fc6a0 100644 --- a/packages/bridge/bridge-react/src/remote/RemoteAppWrapper.tsx +++ b/packages/bridge/bridge-react/src/remote/RemoteAppWrapper.tsx @@ -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, @@ -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, +) { + 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(function ( props: RemoteAppParams & RemoteComponentProps & RemoteAppSSRProps, ref, @@ -66,6 +86,11 @@ export const RemoteAppWrapper = forwardRef(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['peek']>; @@ -78,7 +103,7 @@ export const RemoteAppWrapper = forwardRef(function ( ) { hydrationSnapshotRef.current = { identity: hydrationIdentity, - snapshot: registry?.peek(reference!.moduleName, instanceId!), + snapshot: registry!.peek(reference!.moduleName, instanceId!), }; } const snapshot = hydrationIdentity @@ -132,17 +157,7 @@ export const RemoteAppWrapper = forwardRef(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]); @@ -171,6 +186,21 @@ export const RemoteAppWrapper = forwardRef(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, @@ -182,7 +212,6 @@ export const RemoteAppWrapper = forwardRef(function ( signal, ...resProps, }; - renderDom.current = dom; renderQueueRef.current = renderQueueRef.current .then(async () => { @@ -192,12 +221,16 @@ export const RemoteAppWrapper = forwardRef(function ( renderProps, ) || {}, )) as { extraProps?: Record }; + 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 && diff --git a/packages/bridge/bridge-shared/src/renderRemoteBridge.ts b/packages/bridge/bridge-shared/src/renderRemoteBridge.ts index 556ad8ba6b8..4bab133046c 100644 --- a/packages/bridge/bridge-shared/src/renderRemoteBridge.ts +++ b/packages/bridge/bridge-shared/src/renderRemoteBridge.ts @@ -43,6 +43,13 @@ export async function renderRemoteBridge

>( ); } + 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') { diff --git a/packages/bridge/bridge-shared/src/ssr.spec.ts b/packages/bridge/bridge-shared/src/ssr.spec.ts index 18feaea2968..b26786a207f 100644 --- a/packages/bridge/bridge-shared/src/ssr.spec.ts +++ b/packages/bridge/bridge-shared/src/ssr.spec.ts @@ -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', () => { diff --git a/packages/bridge/bridge-shared/src/ssr.ts b/packages/bridge/bridge-shared/src/ssr.ts index 586f70659a6..9ec952d9759 100644 --- a/packages/bridge/bridge-shared/src/ssr.ts +++ b/packages/bridge/bridge-shared/src/ssr.ts @@ -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') @@ -287,6 +292,8 @@ 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) === @@ -294,8 +301,7 @@ export function hasBridgeSSRMarkup( (!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) ); } @@ -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( slot, @@ -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(); @@ -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); }, }; } diff --git a/packages/bridge/bridge-shared/src/type.ts b/packages/bridge/bridge-shared/src/type.ts index 65205b4ccba..4e1c86874ea 100644 --- a/packages/bridge/bridge-shared/src/type.ts +++ b/packages/bridge/bridge-shared/src/type.ts @@ -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; }; diff --git a/packages/bridge/vue3-bridge/src/provider.spec.ts b/packages/bridge/vue3-bridge/src/provider.spec.ts index 3071d4540d2..0979d3afc28 100644 --- a/packages/bridge/vue3-bridge/src/provider.spec.ts +++ b/packages/bridge/vue3-bridge/src/provider.spec.ts @@ -149,4 +149,38 @@ describe('Vue Bridge server provider', () => { expect(dom.textContent).toBe('updated'); provider.destroy({ dom }); }); + + it('ignores apps created before an aborted client mount', async () => { + const controller = new AbortController(); + const provider = createBridgeComponentWithServerRenderer({ + rootComponent: defineComponent(() => () => h('p', 'remote')), + appOptions: () => { + queueMicrotask(() => controller.abort(new Error('cancelled'))); + return { + router: createRouter({ + history: createMemoryHistory(), + routes: [ + { + path: '/', + component: defineComponent(() => () => h('p')), + }, + ], + }), + }; + }, + })(); + const dom = document.createElement('div'); + document.body.appendChild(dom); + await expect( + provider.render({ + dom, + moduleName: 'vue/remote', + memoryRoute: { entryPath: '/' }, + signal: controller.signal, + }), + ).resolves.toBeUndefined(); + expect( + (dom as HTMLElement & { __vue_app__?: any }).__vue_app__, + ).toBeUndefined(); + }); }); diff --git a/packages/bridge/vue3-bridge/src/provider.ts b/packages/bridge/vue3-bridge/src/provider.ts index 8f33db17afa..2e68ac3e6f7 100644 --- a/packages/bridge/vue3-bridge/src/provider.ts +++ b/packages/bridge/vue3-bridge/src/provider.ts @@ -181,13 +181,22 @@ export function createBridgeComponentWithServerRenderer( roots.delete(dom); } const reactiveProps = Vue.shallowReactive(nextProps); - const { app } = await setupBridgeApp( - bridgeInfo, - { basename, memoryRoute, hashRoute, instanceId, signal }, - reactiveProps, - shouldHydrate ? 'hydrate' : 'csr', - ); - if (signal?.aborted) return; + let app: Vue.App | undefined; + try { + ({ app } = await setupBridgeApp( + bridgeInfo, + { basename, memoryRoute, hashRoute, instanceId, signal }, + reactiveProps, + shouldHydrate ? 'hydrate' : 'csr', + )); + } catch (error) { + if (signal?.aborted) return; + throw error; + } + if (signal?.aborted) { + // Never mounted: drop the request-local app without unmount warnings. + return; + } app.mount(dom, shouldHydrate); if (signal?.aborted || !dom.isConnected) { app.unmount(); @@ -211,6 +220,7 @@ export function createBridgeComponentWithServerRenderer( provider.renderServer = async (context) => { if (context.signal.aborted) throw context.signal.reason; const preparedValue = await config?.prepare?.(context); + if (context.signal.aborted) throw context.signal.reason; const prepared = (preparedValue || {}) as BridgeSSRPrepareResult< Record >; @@ -235,6 +245,7 @@ export function createBridgeComponentWithServerRenderer( applicationProps, 'ssr', ); + if (context.signal.aborted) throw context.signal.reason; const ssrContext: Record = {}; const html = await serverRenderer(app, ssrContext); if (context.signal.aborted) throw context.signal.reason; diff --git a/packages/bridge/vue3-bridge/src/remoteApp.tsx b/packages/bridge/vue3-bridge/src/remoteApp.tsx index e5b2094c044..7a3fc5e5451 100644 --- a/packages/bridge/vue3-bridge/src/remoteApp.tsx +++ b/packages/bridge/vue3-bridge/src/remoteApp.tsx @@ -69,14 +69,19 @@ export default defineComponent({ ? (ssrPayload as BridgeSSRReference) : undefined; const registry = useBridgeHydrationRegistry(); + if (reference && !registry) { + throw new Error( + 'Bridge SSR references require provideBridgeHydrationRegistry before mount', + ); + } const snapshot = reference && instanceId - ? registry?.peek(reference.moduleName, instanceId) + ? registry!.peek(reference.moduleName, instanceId) : undefined; const hasSSRPayload = Boolean((serverPayload || snapshot) && instanceId); const getBridgeRenderProps = () => ({ - name: props.moduleName, + moduleName: props.moduleName, dom: rootRef.value, basename: props.basename, memoryRoute: props.memoryRoute, @@ -128,9 +133,12 @@ export default defineComponent({ wasDeactivated.value || !dom.isConnected ) { - providerReturn.destroy?.({ dom }); + // Only destroy if this provider is still the active one. A newer + // providerInfo watch may already have destroyed and replaced it. if (providerInfoRef.value === providerReturn) { + providerReturn.destroy?.({ dom }); providerInfoRef.value = null; + isRendered.value = false; } return; } @@ -158,7 +166,9 @@ export default defineComponent({ const destroyComponent = () => { const providerReturn = providerInfoRef.value as any; - if (!providerReturn || !isRendered.value) { + // Destroy whenever a provider was obtained. A failed/aborted render may + // still have mounted an app before rejecting, so do not gate on isRendered. + if (!providerReturn) { return; } LoggerInstance.debug(