};
+ 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(