diff --git a/.changeset/bridge-ssr-v1.md b/.changeset/bridge-ssr-v1.md
new file mode 100644
index 00000000000..a03c0a77087
--- /dev/null
+++ b/.changeset/bridge-ssr-v1.md
@@ -0,0 +1,7 @@
+---
+'@module-federation/bridge-shared': minor
+'@module-federation/bridge-react': minor
+'@module-federation/bridge-vue3': minor
+---
+
+add a thin buffered Bridge SSR contract with remote-owned React 18 and Vue 3 rendering, document-slot hydration, identity-only host references, instance-scoped mount markers, and contextual server-render failures
diff --git a/.changeset/calm-cats-test.md b/.changeset/calm-cats-test.md
new file mode 100644
index 00000000000..44f93d7d8e9
--- /dev/null
+++ b/.changeset/calm-cats-test.md
@@ -0,0 +1,5 @@
+---
+'@module-federation/rstest': patch
+---
+
+Add an Rstest integration that enables federation-aware Node test builds and can reuse an existing Rsbuild federation configuration.
diff --git a/.changeset/config.json b/.changeset/config.json
index c2b31315e19..ff7d47e9f9c 100644
--- a/.changeset/config.json
+++ b/.changeset/config.json
@@ -23,6 +23,7 @@
"@module-federation/modern-js-v3",
"@module-federation/retry-plugin",
"@module-federation/rsbuild-plugin",
+ "@module-federation/rstest",
"@module-federation/error-codes",
"@module-federation/inject-external-runtime-core-plugin",
"@module-federation/runtime-core",
diff --git a/.changeset/rstest-legacy-node-defaults.md b/.changeset/rstest-legacy-node-defaults.md
new file mode 100644
index 00000000000..d22308f5360
--- /dev/null
+++ b/.changeset/rstest-legacy-node-defaults.md
@@ -0,0 +1,6 @@
+---
+'@module-federation/rsbuild-plugin': patch
+'@module-federation/rstest': patch
+---
+
+Coordinate Node configuration ownership between the Rsbuild and Rstest federation plugins while preserving Rstest's runtime chunk and split-chunk behavior.
diff --git a/.changeset/tidy-ssr-library-name.md b/.changeset/tidy-ssr-library-name.md
new file mode 100644
index 00000000000..89678c5a2e1
--- /dev/null
+++ b/.changeset/tidy-ssr-library-name.md
@@ -0,0 +1,5 @@
+---
+'@module-federation/rsbuild-plugin': patch
+---
+
+Expose typed federation options to integrations, preserve Rstest-owned Node compiler settings, and keep bundler-derived library names in SSR builds.
diff --git a/.eslintrc.json b/.eslintrc.json
index 51e0f4f3ce4..4b1c04dec46 100644
--- a/.eslintrc.json
+++ b/.eslintrc.json
@@ -2,7 +2,7 @@
"root": true,
"overrides": [
{
- "files": ["*.ts", "*.tsx"],
+ "files": ["*.ts", "*.tsx", "*.mts", "*.cts"],
"extends": ["plugin:@typescript-eslint/recommended"],
"parser": "@typescript-eslint/parser",
"parserOptions": { "ecmaVersion": 2020, "sourceType": "module" },
diff --git a/.github/workflows/build-and-test.yml b/.github/workflows/build-and-test.yml
index a0793b07b2a..ddda12f3d8f 100644
--- a/.github/workflows/build-and-test.yml
+++ b/.github/workflows/build-and-test.yml
@@ -161,6 +161,12 @@ jobs:
uses: ./.github/workflows/e2e-node.yml
secrets: inherit
+ e2e-rstest:
+ needs: checkout-install
+ if: ${{ fromJSON(needs.checkout-install.outputs.e2e_suites).rstest }}
+ uses: ./.github/workflows/e2e-rstest.yml
+ secrets: inherit
+
e2e-next-dev:
needs: checkout-install
if: ${{ fromJSON(needs.checkout-install.outputs.e2e_suites).next }}
diff --git a/.github/workflows/e2e-bridge-ssr.yml b/.github/workflows/e2e-bridge-ssr.yml
new file mode 100644
index 00000000000..da56fe37fad
--- /dev/null
+++ b/.github/workflows/e2e-bridge-ssr.yml
@@ -0,0 +1,30 @@
+name: E2E Test for Bridge SSR
+
+on:
+ pull_request:
+ paths:
+ - 'packages/bridge/**'
+ - 'apps/bridge-ssr-demo/**'
+ - 'tools/scripts/*bridge-ssr*'
+ - '.github/workflows/e2e-bridge-ssr.yml'
+ - 'pnpm-lock.yaml'
+
+concurrency:
+ group: e2e-bridge-ssr-${{ github.ref }}
+ cancel-in-progress: true
+
+jobs:
+ rsbuild:
+ runs-on: ubuntu-latest
+ timeout-minutes: 45
+ steps:
+ - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
+ - uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4
+ - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
+ with:
+ node-version: '24'
+ cache: pnpm
+ - run: pnpm install --frozen-lockfile
+ - run: pnpm run build:packages
+ - run: pnpm run e2e:bridge:ssr
+ - run: pnpm run e2e:bridge:ssr:production
diff --git a/.github/workflows/e2e-rstest.yml b/.github/workflows/e2e-rstest.yml
new file mode 100644
index 00000000000..afc7f557f41
--- /dev/null
+++ b/.github/workflows/e2e-rstest.yml
@@ -0,0 +1,46 @@
+# .github/workflows/e2e-rstest.yml
+name: E2E Rstest Federation
+
+on:
+ workflow_call:
+
+jobs:
+ e2e-rstest:
+ runs-on: ubuntu-latest
+ timeout-minutes: 30
+ steps:
+ - name: Checkout Repository
+ uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
+ with:
+ fetch-depth: 0
+
+ - name: Setup pnpm
+ uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4
+
+ - name: Setup Node.js 24
+ uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
+ with:
+ node-version: '24'
+ cache: 'pnpm'
+ cache-dependency-path: '**/pnpm-lock.yaml'
+
+ - name: Export SKIP_DEVTOOLS_POSTINSTALL
+ run: echo "SKIP_DEVTOOLS_POSTINSTALL=true" >> "$GITHUB_ENV"
+
+ - name: Install dependencies
+ run: pnpm install --frozen-lockfile
+
+ - name: Restore Turborepo cache
+ uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5
+ with:
+ path: .turbo/cache
+ key: ${{ runner.os }}-turbo-${{ github.ref_name }}-${{ hashFiles('pnpm-lock.yaml') }}
+ restore-keys: |
+ ${{ runner.os }}-turbo-${{ github.ref_name }}-
+ ${{ runner.os }}-turbo-
+
+ - name: Build shared packages
+ run: pnpm run build:packages
+
+ - name: E2E Rstest Federation
+ run: pnpm exec turbo run e2e --filter=rstest-federation-host
diff --git a/.prettierignore b/.prettierignore
index a9e2bf4e1d4..eee1c0d6961 100644
--- a/.prettierignore
+++ b/.prettierignore
@@ -25,3 +25,6 @@ packages/assemble-release-plan/**
# Generated Chrome DevTools proxy asset
packages/chrome-devtools/src/vendor/basic-proxy-core.js
+
+**/playwright-report/**
+**/test-results/**
diff --git a/apps/bridge-ssr-demo/.gitignore b/apps/bridge-ssr-demo/.gitignore
new file mode 100644
index 00000000000..dbd77de6c7e
--- /dev/null
+++ b/apps/bridge-ssr-demo/.gitignore
@@ -0,0 +1 @@
+**/dist/
diff --git a/apps/bridge-ssr-demo/README.md b/apps/bridge-ssr-demo/README.md
new file mode 100644
index 00000000000..80b134c5fdf
--- /dev/null
+++ b/apps/bridge-ssr-demo/README.md
@@ -0,0 +1,17 @@
+# Bridge SSR V1 acceptance demo
+
+Rsbuild demo for application-level Bridge SSR in both directions:
+
+- React 18 host → Vue 3 remote
+- Vue 3 host → React 18 remote
+
+Contract details: [Bridge SSR guide](../website-new/docs/en/guide/bridge/ssr.mdx).
+
+Run from the repository root:
+
+```bash
+pnpm run e2e:bridge:ssr
+pnpm run e2e:bridge:ssr:production
+```
+
+These suites cover no-JS HTML, single-copy transport, hydration, deep routes, navigation, CSR revisits after consumption, multiple instances, and browser-bundle exclusion of server-only renderers.
diff --git a/apps/bridge-ssr-demo/host-vue/package.json b/apps/bridge-ssr-demo/host-vue/package.json
new file mode 100644
index 00000000000..50dd19c626d
--- /dev/null
+++ b/apps/bridge-ssr-demo/host-vue/package.json
@@ -0,0 +1,31 @@
+{
+ "name": "bridge-ssr-host-vue",
+ "private": true,
+ "version": "0.0.1",
+ "scripts": {
+ "dev": "NODE_OPTIONS=--experimental-vm-modules node ./server.mjs",
+ "build": "rsbuild build",
+ "serve:production": "NODE_OPTIONS=--experimental-vm-modules BRIDGE_SSR_MODE=production node ./server.mjs",
+ "e2e:ssr:ci": "node ../../../scripts/ensure-playwright.js && playwright test --config playwright.config.ts --reporter=line"
+ },
+ "dependencies": {
+ "@module-federation/bridge-react": "workspace:*",
+ "@module-federation/bridge-shared": "workspace:*",
+ "@module-federation/bridge-vue3": "workspace:*",
+ "@module-federation/enhanced": "workspace:*",
+ "@module-federation/runtime": "workspace:*",
+ "@vue/server-renderer": "^3.5.30",
+ "express": "^4.21.2",
+ "react": "18.3.1",
+ "react-dom": "18.3.1",
+ "vue": "^3.5.30",
+ "vue-router": "4.4.5"
+ },
+ "devDependencies": {
+ "@module-federation/rsbuild-plugin": "workspace:*",
+ "@playwright/test": "1.57.0",
+ "@rsbuild/core": "1.7.3",
+ "@rsbuild/plugin-vue": "^1.2.6",
+ "typescript": "~5.0.4"
+ }
+}
diff --git a/apps/bridge-ssr-demo/host-vue/playwright.config.ts b/apps/bridge-ssr-demo/host-vue/playwright.config.ts
new file mode 100644
index 00000000000..db2de20c99a
--- /dev/null
+++ b/apps/bridge-ssr-demo/host-vue/playwright.config.ts
@@ -0,0 +1,25 @@
+import { defineConfig, devices } from '@playwright/test';
+
+export default defineConfig({
+ testDir: '../host/e2e',
+ timeout: 60_000,
+ fullyParallel: false,
+ retries: process.env.CI ? 2 : 0,
+ workers: 1,
+ use: {
+ baseURL: process.env.BRIDGE_SSR_VUE_BASE_URL ?? 'http://localhost:2303',
+ trace: 'on-first-retry',
+ ...devices['Desktop Chrome'],
+ },
+ projects: [
+ {
+ name: 'vue-host-nojs',
+ testMatch: /bridge-ssr-nojs\.spec\.ts/,
+ use: { javaScriptEnabled: false },
+ },
+ {
+ name: 'vue-host-browser',
+ testMatch: /bridge-ssr-browser\.spec\.ts/,
+ },
+ ],
+});
diff --git a/apps/bridge-ssr-demo/host-vue/rsbuild.config.ts b/apps/bridge-ssr-demo/host-vue/rsbuild.config.ts
new file mode 100644
index 00000000000..318329e9621
--- /dev/null
+++ b/apps/bridge-ssr-demo/host-vue/rsbuild.config.ts
@@ -0,0 +1,49 @@
+import { defineConfig } from '@rsbuild/core';
+import { pluginVue } from '@rsbuild/plugin-vue';
+import { pluginModuleFederation } from '@module-federation/rsbuild-plugin';
+import {
+ bridgeSsrHostUrl,
+ bridgeSsrRemotePort,
+ bridgeSsrServerManifestPath,
+} from '../shared/devHost';
+
+const reactOrigin = bridgeSsrHostUrl(bridgeSsrRemotePort('react'));
+const webRemotes = {
+ bridge_ssr_react: `bridge_ssr_react@${reactOrigin}/mf-manifest.json`,
+};
+const ssrRemotes = {
+ bridge_ssr_react: `bridge_ssr_react@${reactOrigin}/${bridgeSsrServerManifestPath}`,
+};
+const shared = {
+ vue: { singleton: true, eager: true },
+ 'vue-router': { singleton: true, eager: true },
+ react: { singleton: true, eager: true },
+ 'react-dom': { singleton: true, eager: true },
+};
+
+export default defineConfig({
+ performance: { buildCache: false },
+ server: { port: 2303, cors: true, middlewareMode: true },
+ environments: {
+ client: {
+ source: { entry: { index: './src/entry.client.ts' } },
+ html: { template: './src/index.html' },
+ output: { distPath: { root: 'dist' } },
+ },
+ ssr: {
+ source: { entry: { index: './src/entry.server.ts' } },
+ output: { target: 'node', distPath: { root: 'dist/ssr' } },
+ },
+ },
+ plugins: [
+ pluginVue(),
+ pluginModuleFederation(
+ { name: 'bridge_ssr_vue_host', remotes: webRemotes, shared },
+ { environment: 'client' },
+ ),
+ pluginModuleFederation(
+ { name: 'bridge_ssr_vue_host', remotes: ssrRemotes, shared },
+ { target: 'node', environment: 'ssr' },
+ ),
+ ],
+});
diff --git a/apps/bridge-ssr-demo/host-vue/server.mjs b/apps/bridge-ssr-demo/host-vue/server.mjs
new file mode 100644
index 00000000000..69bf42e65b0
--- /dev/null
+++ b/apps/bridge-ssr-demo/host-vue/server.mjs
@@ -0,0 +1,11 @@
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { startBridgeHost } from '../shared/createHostServer.mjs';
+
+const rootDir = path.dirname(fileURLToPath(import.meta.url));
+startBridgeHost({ rootDir, port: 2303, label: 'bridge-ssr-vue-host' }).catch(
+ (error) => {
+ console.error('[bridge-ssr-vue-host] failed:', error);
+ process.exitCode = 1;
+ },
+);
diff --git a/apps/bridge-ssr-demo/host-vue/src/App.vue b/apps/bridge-ssr-demo/host-vue/src/App.vue
new file mode 100644
index 00000000000..7df3e1cc223
--- /dev/null
+++ b/apps/bridge-ssr-demo/host-vue/src/App.vue
@@ -0,0 +1,76 @@
+
+
+
+
+
+
+
+ React Remote Pair
+
+
+
+
+ React Remote
+
+
+
+ Bridge SSR Host
+ Vue outer host consuming a React application remote.
+
+
+
diff --git a/apps/bridge-ssr-demo/host-vue/src/entry.client.ts b/apps/bridge-ssr-demo/host-vue/src/entry.client.ts
new file mode 100644
index 00000000000..3d95da30f37
--- /dev/null
+++ b/apps/bridge-ssr-demo/host-vue/src/entry.client.ts
@@ -0,0 +1,16 @@
+import { createSSRApp } from 'vue';
+import { createRouter, createWebHistory } from 'vue-router';
+import { createBridgeHydrationRegistry } from '@module-federation/bridge-shared';
+import { provideBridgeHydrationRegistry } from '@module-federation/bridge-vue3';
+import App from './App.vue';
+import { readHostSSRContext } from './ssrContext';
+
+const router = createRouter({
+ history: createWebHistory(),
+ routes: [{ path: '/:pathMatch(.*)*', component: App }],
+});
+const app = createSSRApp(App, { ssrContext: readHostSSRContext() });
+app.use(router);
+provideBridgeHydrationRegistry(app, createBridgeHydrationRegistry(document));
+await router.isReady();
+app.mount('#root');
diff --git a/apps/bridge-ssr-demo/host-vue/src/entry.server.ts b/apps/bridge-ssr-demo/host-vue/src/entry.server.ts
new file mode 100644
index 00000000000..818631b00e9
--- /dev/null
+++ b/apps/bridge-ssr-demo/host-vue/src/entry.server.ts
@@ -0,0 +1,34 @@
+import { createSSRApp } from 'vue';
+import { renderToString } from '@vue/server-renderer';
+import { createMemoryHistory, createRouter } from 'vue-router';
+import App from './App.vue';
+import { toHostHydrationContext } from './ssrContext';
+import {
+ prepareSSRContext,
+ type PrepareSSRContextOptions,
+} from './prepareSSRContext';
+
+async function createHostApp(url: string, options: PrepareSSRContextOptions) {
+ const ssrContext = await prepareSSRContext(url, options);
+ const router = createRouter({
+ history: createMemoryHistory(),
+ routes: [{ path: '/:pathMatch(.*)*', component: App }],
+ });
+ await router.push(url);
+ await router.isReady();
+ const app = createSSRApp(App, { ssrContext });
+ app.use(router);
+ return { app, ssrContext };
+}
+
+export async function render(
+ url: string,
+ options: PrepareSSRContextOptions = {},
+) {
+ const { app, ssrContext } = await createHostApp(url, options);
+ return {
+ html: await renderToString(app),
+ ssrContext,
+ hydrationContext: toHostHydrationContext(ssrContext),
+ };
+}
diff --git a/apps/bridge-ssr-demo/host-vue/src/env.d.ts b/apps/bridge-ssr-demo/host-vue/src/env.d.ts
new file mode 100644
index 00000000000..c736914276c
--- /dev/null
+++ b/apps/bridge-ssr-demo/host-vue/src/env.d.ts
@@ -0,0 +1,4 @@
+///
+
+declare module 'bridge_ssr_react/export-app';
+declare module 'bridge_ssr_vue/export-app';
diff --git a/apps/bridge-ssr-demo/host-vue/src/index.html b/apps/bridge-ssr-demo/host-vue/src/index.html
new file mode 100644
index 00000000000..edf442c4adf
--- /dev/null
+++ b/apps/bridge-ssr-demo/host-vue/src/index.html
@@ -0,0 +1,11 @@
+
+
+
+
+
+ Bridge SSR Vue Host
+
+
+
+
+
diff --git a/apps/bridge-ssr-demo/host-vue/src/prepareSSRContext.ts b/apps/bridge-ssr-demo/host-vue/src/prepareSSRContext.ts
new file mode 100644
index 00000000000..97af22acd50
--- /dev/null
+++ b/apps/bridge-ssr-demo/host-vue/src/prepareSSRContext.ts
@@ -0,0 +1,51 @@
+import { getInstance } from '@module-federation/runtime';
+import { renderRemoteBridge } from '@module-federation/bridge-shared';
+import { normalizeBridgeRequestUrl } from '../../host/src/lib/normalizeRequestUrl.mjs';
+import type { HostSSRContext } from './ssrContext';
+
+export type PrepareSSRContextOptions = { signal?: AbortSignal };
+
+export async function prepareSSRContext(
+ url: string,
+ options: PrepareSSRContextOptions = {},
+): Promise {
+ const requestUrl = normalizeBridgeRequestUrl(url);
+ const parsed = new URL(requestUrl, 'http://bridge-ssr.local');
+ const context: HostSSRContext = { url: requestUrl };
+ const instance = getInstance();
+ if (!instance)
+ throw new Error('Module Federation runtime is not initialized');
+ const request = new Request(parsed, { signal: options.signal });
+
+ const renderReact = (
+ instanceId: string,
+ basename: string,
+ name: string,
+ age: number,
+ ) =>
+ renderRemoteBridge({
+ loader: () =>
+ instance.loadRemote>(
+ 'bridge_ssr_react/export-app',
+ ),
+ moduleName: 'bridge_ssr_react',
+ instanceId,
+ request,
+ props: { basename, name, age },
+ });
+
+ if (parsed.pathname.startsWith('/react-pair')) {
+ context.reactPair = await Promise.all([
+ renderReact('react-pair-left', '/react-pair', 'Left', 1),
+ renderReact('react-pair-right', '/react-pair', 'Right', 2),
+ ]);
+ } else if (parsed.pathname.startsWith('/react-remote')) {
+ context.reactRemote = await renderReact(
+ 'react-primary',
+ '/react-remote',
+ 'Ming',
+ 12,
+ );
+ }
+ return context;
+}
diff --git a/apps/bridge-ssr-demo/host-vue/src/remoteApps.ts b/apps/bridge-ssr-demo/host-vue/src/remoteApps.ts
new file mode 100644
index 00000000000..0ffa9858501
--- /dev/null
+++ b/apps/bridge-ssr-demo/host-vue/src/remoteApps.ts
@@ -0,0 +1,5 @@
+import { createRemoteAppComponent } from '@module-federation/bridge-vue3';
+
+export const RemoteReactApp = createRemoteAppComponent({
+ loader: () => import('bridge_ssr_react/export-app'),
+});
diff --git a/apps/bridge-ssr-demo/host-vue/src/ssrContext.ts b/apps/bridge-ssr-demo/host-vue/src/ssrContext.ts
new file mode 100644
index 00000000000..b6a605f44d7
--- /dev/null
+++ b/apps/bridge-ssr-demo/host-vue/src/ssrContext.ts
@@ -0,0 +1,57 @@
+import {
+ assertBridgeSSRReference,
+ toBridgeSSRReference,
+ type BridgeSSRReference,
+ type BridgeSSRResult,
+} from '@module-federation/bridge-shared';
+
+export type RemoteSSRData = BridgeSSRResult | BridgeSSRReference;
+
+export type HostSSRContext = {
+ url: string;
+ reactRemote?: RemoteSSRData;
+ reactPair?: [RemoteSSRData, RemoteSSRData];
+};
+
+export type HostHydrationContext = {
+ url: string;
+ reactRemote?: BridgeSSRReference;
+ reactPair?: [BridgeSSRReference, BridgeSSRReference];
+};
+
+function toReference(data: RemoteSSRData): BridgeSSRReference {
+ if ('html' in data) return toBridgeSSRReference(data);
+ assertBridgeSSRReference(data);
+ return data;
+}
+
+export function toHostHydrationContext(
+ context: HostSSRContext,
+): HostHydrationContext {
+ return {
+ url: context.url,
+ ...(context.reactRemote
+ ? { reactRemote: toReference(context.reactRemote) }
+ : {}),
+ ...(context.reactPair
+ ? {
+ reactPair: context.reactPair.map(toReference) as [
+ BridgeSSRReference,
+ BridgeSSRReference,
+ ],
+ }
+ : {}),
+ };
+}
+
+export const HOST_SSR_CONTEXT_SCRIPT_ID = 'bridge-ssr-host-context';
+
+export function readHostSSRContext() {
+ const script = document.getElementById(HOST_SSR_CONTEXT_SCRIPT_ID);
+ if (!script?.textContent) return undefined;
+ try {
+ return JSON.parse(script.textContent) as HostHydrationContext;
+ } catch {
+ return undefined;
+ }
+}
diff --git a/apps/bridge-ssr-demo/host-vue/tsconfig.json b/apps/bridge-ssr-demo/host-vue/tsconfig.json
new file mode 100644
index 00000000000..8a26c51abfb
--- /dev/null
+++ b/apps/bridge-ssr-demo/host-vue/tsconfig.json
@@ -0,0 +1,12 @@
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "module": "ESNext",
+ "moduleResolution": "Bundler",
+ "strict": true,
+ "skipLibCheck": true,
+ "allowJs": true,
+ "types": ["node"]
+ },
+ "include": ["src", "rsbuild.config.ts", "playwright.config.ts"]
+}
diff --git a/apps/bridge-ssr-demo/host/e2e/bridge-ssr-browser.spec.ts b/apps/bridge-ssr-demo/host/e2e/bridge-ssr-browser.spec.ts
new file mode 100644
index 00000000000..be739f4e1e5
--- /dev/null
+++ b/apps/bridge-ssr-demo/host/e2e/bridge-ssr-browser.spec.ts
@@ -0,0 +1,118 @@
+import { expect, test } from './fixtures';
+
+const direction = (projectName: string) =>
+ projectName.startsWith('vue-host')
+ ? {
+ route: '/react-remote',
+ detail: '/react-remote/detail',
+ homeHeading: 'React Remote Home',
+ detailHeading: 'React Remote Detail',
+ counter: '.react-remote-counter',
+ detailLink: '.react-remote-detail-link',
+ hostLink: '.host-react-remote-link',
+ }
+ : {
+ route: '/vue-remote',
+ detail: '/vue-remote/detail',
+ homeHeading: 'Vue Remote Home',
+ detailHeading: 'Vue Remote Detail',
+ counter: '.vue-remote-counter',
+ detailLink: '.vue-remote-detail-link',
+ hostLink: '.host-vue-remote-link',
+ };
+
+test('hydrates the direct SSR island and remains interactive', async ({
+ page,
+}, testInfo) => {
+ const remote = direction(testInfo.project.name);
+ await page.goto(remote.route);
+ await expect(
+ page.getByRole('heading', { name: remote.homeHeading }),
+ ).toBeVisible();
+ const mount = page.locator('[data-mf-bridge-ssr="true"]');
+ await expect(mount).toHaveCount(1);
+ const counter = page.locator(remote.counter);
+ await counter.click();
+ await expect(counter).toHaveText(/Count: 1/);
+});
+
+test('server-renders a deep route and remote navigation updates the URL', async ({
+ page,
+}, testInfo) => {
+ const remote = direction(testInfo.project.name);
+ await page.goto(`${remote.detail}?source=e2e#bridge-state`);
+ await expect(page).toHaveURL(
+ new RegExp(`${remote.detail}\\?source=e2e#bridge-state$`),
+ );
+ await expect(
+ page.getByRole('heading', { name: remote.detailHeading }),
+ ).toBeVisible();
+ await page.goto(remote.route);
+ await page.locator(remote.detailLink).click();
+ await expect(page).toHaveURL(new RegExp(`${remote.detail}$`));
+ await expect(
+ page.getByRole('heading', { name: remote.detailHeading }),
+ ).toBeVisible();
+});
+
+test('host navigation enters, leaves, and returns through CSR without reload', async ({
+ page,
+}, testInfo) => {
+ const remote = direction(testInfo.project.name);
+ await page.goto('/');
+ await page.evaluate(() => {
+ (
+ window as Window & { __navigationSentinel?: string }
+ ).__navigationSentinel = 'alive';
+ });
+ await page.locator(remote.hostLink).first().click();
+ await expect(
+ page.getByRole('heading', { name: remote.homeHeading }),
+ ).toBeVisible();
+ await page.getByRole('link', { name: 'Home' }).first().click();
+ await expect(page).toHaveURL(/\/$/);
+ await page.locator(remote.hostLink).first().click();
+ await expect(
+ page.getByRole('heading', { name: remote.homeHeading }),
+ ).toBeVisible();
+ await expect
+ .poll(() => page.evaluate(() => (window as any).__navigationSentinel))
+ .toBe('alive');
+});
+
+test('consumes direct-visit SSR once and revisits through CSR', async ({
+ page,
+}, testInfo) => {
+ const remote = direction(testInfo.project.name);
+ await page.goto(remote.route);
+ await expect(page.locator('[data-mf-bridge-ssr="true"]')).toHaveCount(1);
+ await expect(
+ page.getByRole('heading', { name: remote.homeHeading }),
+ ).toBeVisible();
+
+ await page.getByRole('link', { name: 'Home' }).first().click();
+ await page.locator(remote.hostLink).first().click();
+ await expect(
+ page.getByRole('heading', { name: remote.homeHeading }),
+ ).toBeVisible();
+ await expect(page.locator('[data-mf-bridge-ssr="true"]')).toHaveCount(0);
+});
+
+test('two instances of one React remote hydrate independently', async ({
+ page,
+}, testInfo) => {
+ test.skip(!testInfo.project.name.startsWith('vue-host'));
+ await page.goto('/react-pair');
+ const mounts = page.locator('[data-mf-bridge-ssr="true"]');
+ await expect(mounts).toHaveCount(2);
+ const ids = await mounts.evaluateAll((nodes) =>
+ nodes.map((node) => node.getAttribute('data-mf-bridge-instance')),
+ );
+ expect(new Set(ids).size).toBe(2);
+ const counters = page.locator('.react-remote-counter');
+ await counters.nth(0).click();
+ await expect(counters.nth(0)).toHaveText('Count: 1');
+ await expect(counters.nth(1)).toHaveText('Count: 0');
+ await expect(page.getByText('name: Left, age: 1')).toBeVisible();
+ await expect(page.getByText('name: Right, age: 2')).toBeVisible();
+});
diff --git a/apps/bridge-ssr-demo/host/e2e/bridge-ssr-nojs.spec.ts b/apps/bridge-ssr-demo/host/e2e/bridge-ssr-nojs.spec.ts
new file mode 100644
index 00000000000..db31282b8c9
--- /dev/null
+++ b/apps/bridge-ssr-demo/host/e2e/bridge-ssr-nojs.spec.ts
@@ -0,0 +1,30 @@
+import { expect, test } from '@playwright/test';
+
+test('raw response contains the cross-framework remote route', async ({
+ page,
+ request,
+}, testInfo) => {
+ const vueHost = testInfo.project.name.startsWith('vue-host');
+ const route = vueHost ? '/react-remote/detail' : '/vue-remote/detail';
+ const heading = vueHost ? 'React Remote Detail' : 'Vue Remote Detail';
+ const response = await request.get(route);
+ const html = await response.text();
+ expect(response.ok()).toBeTruthy();
+ expect(html).toContain(heading);
+ expect(html).toContain('data-mf-bridge-ssr="true"');
+ expect(html).toContain('data-mf-bridge-slot="true"');
+ expect(html).toContain('data-mf-bridge-state="true"');
+ expect(html).toContain('bridge-ssr-host-context');
+ expect(html.match(new RegExp(heading, 'g'))).toHaveLength(1);
+
+ const contextMatch = html.match(
+ /
diff --git a/apps/bridge-ssr-demo/remote-vue/src/export-app.server.ts b/apps/bridge-ssr-demo/remote-vue/src/export-app.server.ts
new file mode 100644
index 00000000000..c90e02a653d
--- /dev/null
+++ b/apps/bridge-ssr-demo/remote-vue/src/export-app.server.ts
@@ -0,0 +1,32 @@
+import App from './RemoteVueApplication.vue';
+import { createBridgeComponent } from '@module-federation/bridge-vue3';
+import { createMemoryHistory, createRouter } from 'vue-router';
+import Home from './pages/Home.vue';
+import Detail from './pages/Detail.vue';
+
+export default createBridgeComponent({
+ rootComponent: App,
+ appOptions: () => ({
+ router: createRouter({
+ history: createMemoryHistory(),
+ routes: [
+ { path: '/', component: Home },
+ { path: '/detail', component: Detail },
+ ],
+ }),
+ }),
+ ssr: {
+ prepare(context) {
+ const props = context.props as { test?: string; basename?: string };
+ return {
+ props: {
+ ...(typeof props.test === 'string' ? { test: props.test } : {}),
+ ...(typeof props.basename === 'string'
+ ? { basename: props.basename }
+ : {}),
+ },
+ dehydratedState: { test: props.test ?? null },
+ };
+ },
+ },
+});
diff --git a/apps/bridge-ssr-demo/remote-vue/src/export-app.ts b/apps/bridge-ssr-demo/remote-vue/src/export-app.ts
new file mode 100644
index 00000000000..fcb14c40b0c
--- /dev/null
+++ b/apps/bridge-ssr-demo/remote-vue/src/export-app.ts
@@ -0,0 +1,26 @@
+import App from './RemoteVueApplication.vue';
+import { createBridgeComponent } from '@module-federation/bridge-vue3';
+import { createRouter, createWebHistory } from 'vue-router';
+import Home from './pages/Home.vue';
+import Detail from './pages/Detail.vue';
+
+export default createBridgeComponent({
+ rootComponent: App,
+ appOptions: () => ({
+ router: createRouter({
+ history: createWebHistory(),
+ routes: [
+ { path: '/', component: Home },
+ { path: '/detail', component: Detail },
+ ],
+ }),
+ }),
+ ssr: {
+ hydrate(state) {
+ if (!state || Array.isArray(state) || typeof state !== 'object')
+ return {};
+ const test = (state as Record).test;
+ return typeof test === 'string' ? { test } : {};
+ },
+ },
+});
diff --git a/apps/bridge-ssr-demo/remote-vue/src/index.html b/apps/bridge-ssr-demo/remote-vue/src/index.html
new file mode 100644
index 00000000000..c7fe0adc4c3
--- /dev/null
+++ b/apps/bridge-ssr-demo/remote-vue/src/index.html
@@ -0,0 +1,11 @@
+
+
+
+
+
+ Bridge SSR Vue Remote
+
+
+
+
+
diff --git a/apps/bridge-ssr-demo/remote-vue/src/pages/Detail.vue b/apps/bridge-ssr-demo/remote-vue/src/pages/Detail.vue
new file mode 100644
index 00000000000..2e05e520e52
--- /dev/null
+++ b/apps/bridge-ssr-demo/remote-vue/src/pages/Detail.vue
@@ -0,0 +1,6 @@
+
+
+
Vue Remote Detail
+
SSR detail page content
+
+
diff --git a/apps/bridge-ssr-demo/remote-vue/src/pages/Home.vue b/apps/bridge-ssr-demo/remote-vue/src/pages/Home.vue
new file mode 100644
index 00000000000..8373d64d00d
--- /dev/null
+++ b/apps/bridge-ssr-demo/remote-vue/src/pages/Home.vue
@@ -0,0 +1,16 @@
+
+
+
Vue Remote Home
+
test prop: {{ test }}
+
+
+
+
+
diff --git a/apps/bridge-ssr-demo/remote-vue/src/ssr-env.ts b/apps/bridge-ssr-demo/remote-vue/src/ssr-env.ts
new file mode 100644
index 00000000000..ffee3d7483a
--- /dev/null
+++ b/apps/bridge-ssr-demo/remote-vue/src/ssr-env.ts
@@ -0,0 +1,6 @@
+/**
+ * Node MF environment entry for the Vue remote.
+ * Rsbuild builds this to dist/ssr/ for the host to consume via
+ * bridge_ssr_vue@/ssr/mf-manifest.json at request time.
+ */
+export {};
diff --git a/apps/bridge-ssr-demo/remote-vue/src/standalone.ts b/apps/bridge-ssr-demo/remote-vue/src/standalone.ts
new file mode 100644
index 00000000000..4f6adaaeb2d
--- /dev/null
+++ b/apps/bridge-ssr-demo/remote-vue/src/standalone.ts
@@ -0,0 +1,15 @@
+import { createApp } from 'vue';
+import App from './RemoteVueApplication.vue';
+import { createRouter, createWebHistory } from 'vue-router';
+import Home from './pages/Home.vue';
+import Detail from './pages/Detail.vue';
+
+const router = createRouter({
+ history: createWebHistory(),
+ routes: [
+ { path: '/', component: Home },
+ { path: '/detail', component: Detail },
+ ],
+});
+
+createApp(App).use(router).mount('#root');
diff --git a/apps/bridge-ssr-demo/remote-vue/tsconfig.json b/apps/bridge-ssr-demo/remote-vue/tsconfig.json
new file mode 100644
index 00000000000..ac97d6272cc
--- /dev/null
+++ b/apps/bridge-ssr-demo/remote-vue/tsconfig.json
@@ -0,0 +1,13 @@
+{
+ "compilerOptions": {
+ "target": "ES2020",
+ "module": "ESNext",
+ "moduleResolution": "bundler",
+ "strict": true,
+ "jsx": "preserve",
+ "paths": {
+ "@/*": ["./src/*"]
+ }
+ },
+ "include": ["src", "rsbuild.config.ts"]
+}
diff --git a/apps/bridge-ssr-demo/shared/createHostServer.mjs b/apps/bridge-ssr-demo/shared/createHostServer.mjs
new file mode 100644
index 00000000000..cb81f843efb
--- /dev/null
+++ b/apps/bridge-ssr-demo/shared/createHostServer.mjs
@@ -0,0 +1,108 @@
+import express from 'express';
+import { createRsbuild, loadConfig } from '@rsbuild/core';
+import { readFile } from 'node:fs/promises';
+import path from 'node:path';
+import { pathToFileURL } from 'node:url';
+import { serializeBridgeJSON } from '@module-federation/bridge-shared/dist/index.es.js';
+
+const shouldRender = (url) => {
+ if (url.startsWith('/@') || url.startsWith('/__rsbuild')) return false;
+ const pathname = url.split('?')[0];
+ return !pathname.includes('.') || pathname.endsWith('.html');
+};
+
+function assembleDocument(template, html, ssrContext) {
+ const marker = '';
+ if (!template.includes(marker)) {
+ throw new Error(`Bridge SSR host template is missing ${marker}`);
+ }
+ const payload = ``;
+ return template.replace(marker, html).replace('