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 @@ + + + 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 @@ + 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 @@ + + + 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('', `${payload}`); +} + +function registerSSRRoute(app, getBundle, getTemplate, label) { + app.get('*', async (req, res, next) => { + if (!shouldRender(req.originalUrl)) return next(); + const controller = new AbortController(); + const disconnect = () => controller.abort(new Error('Client disconnected')); + req.once('aborted', disconnect); + try { + const [bundle, template] = await Promise.all([ + getBundle(), + getTemplate(), + ]); + const rendered = await bundle.render(req.originalUrl, { + signal: controller.signal, + }); + if (controller.signal.aborted) return; + res + .status(200) + .type('html') + .set('Cache-Control', 'no-store') + .send( + assembleDocument(template, rendered.html, rendered.hydrationContext), + ); + } catch (error) { + if (!controller.signal.aborted) { + console.error(`[${label}] SSR request failed`, error, error?.cause); + next(error); + } + } finally { + req.off('aborted', disconnect); + } + }); +} + +export async function startBridgeHost({ rootDir, port, label }) { + const host = + process.env.HOST ?? process.env.BRIDGE_SSR_DEV_HOST ?? 'localhost'; + const app = express(); + if (process.env.BRIDGE_SSR_MODE === 'production') { + const dist = path.join(rootDir, 'dist'); + const template = await readFile(path.join(dist, 'index.html'), 'utf8'); + const imported = await import( + `${pathToFileURL(path.join(dist, 'ssr', 'index.js')).href}?${Date.now()}` + ); + registerSSRRoute( + app, + async () => imported.default ?? imported, + async () => template, + label, + ); + app.use(express.static(dist)); + } else { + const { content } = await loadConfig({ cwd: rootDir }); + const rsbuild = await createRsbuild({ + cwd: rootDir, + rsbuildConfig: content, + }); + const server = await rsbuild.createDevServer(); + let bundlePromise; + const getBundle = () => { + bundlePromise ??= server.environments.ssr + .loadBundle('index') + .catch((error) => { + bundlePromise = undefined; + throw error; + }); + return bundlePromise; + }; + registerSSRRoute( + app, + getBundle, + () => server.environments.client.getTransformedHtml('index'), + label, + ); + app.use(server.middlewares); + const httpServer = app.listen(port, host, async () => { + await server.afterListen(); + console.log(`[${label}] ready at http://${host}:${port}`); + }); + server.connectWebSocket({ server: httpServer }); + return; + } + + app.listen(port, host, () => { + console.log(`[${label}] production ready at http://${host}:${port}`); + }); +} diff --git a/apps/bridge-ssr-demo/shared/devHost.ts b/apps/bridge-ssr-demo/shared/devHost.ts new file mode 100644 index 00000000000..b15ab803537 --- /dev/null +++ b/apps/bridge-ssr-demo/shared/devHost.ts @@ -0,0 +1,10 @@ +export const BRIDGE_SSR_DEV_HOST = + process.env.BRIDGE_SSR_DEV_HOST ?? 'localhost'; + +export const bridgeSsrRemotePort = (framework: 'react' | 'vue') => + framework === 'react' ? 2301 : 2302; + +export const bridgeSsrServerManifestPath = 'ssr/mf-manifest.json'; + +export const bridgeSsrHostUrl = (port: number) => + `http://${BRIDGE_SSR_DEV_HOST}:${port}`; diff --git a/apps/bridge-ssr-demo/shared/package.json b/apps/bridge-ssr-demo/shared/package.json new file mode 100644 index 00000000000..99cc4bbc4db --- /dev/null +++ b/apps/bridge-ssr-demo/shared/package.json @@ -0,0 +1,11 @@ +{ + "name": "bridge-ssr-demo-shared", + "private": true, + "version": "0.0.1", + "type": "module", + "dependencies": { + "@module-federation/bridge-shared": "workspace:*", + "@rsbuild/core": "1.7.3", + "express": "^4.21.2" + } +} diff --git a/apps/bridge-ssr-demo/shared/pluginServeSsrDist.ts b/apps/bridge-ssr-demo/shared/pluginServeSsrDist.ts new file mode 100644 index 00000000000..fa6c1f40146 --- /dev/null +++ b/apps/bridge-ssr-demo/shared/pluginServeSsrDist.ts @@ -0,0 +1,63 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import type { RsbuildPlugin } from '@rsbuild/core'; + +function getContentType(filePath: string) { + if (filePath.endsWith('.json')) { + return 'application/json'; + } + if (filePath.endsWith('.js')) { + return 'application/javascript'; + } + if (filePath.endsWith('.map')) { + return 'application/json'; + } + return 'application/octet-stream'; +} + +export function pluginServeSsrDist(rootDir = process.cwd()): RsbuildPlugin { + const ssrDir = path.join(rootDir, 'dist', 'ssr'); + + return { + name: 'plugin-serve-ssr-dist', + setup(api) { + api.modifyRsbuildConfig((config) => { + config.dev ??= {}; + const previous = config.dev.setupMiddlewares; + + config.dev.setupMiddlewares = (middlewares, server) => { + if (typeof previous === 'function') { + previous(middlewares, server); + } else if (Array.isArray(previous)) { + previous.forEach((setup) => setup(middlewares, server)); + } + + middlewares.unshift((req, res, next) => { + const url = req.url?.split('?')[0] ?? ''; + if (!url.startsWith('/ssr/')) { + next(); + return; + } + + const relativePath = decodeURIComponent(url.slice('/ssr/'.length)); + const filePath = path.join(ssrDir, relativePath); + + if ( + !filePath.startsWith(ssrDir) || + !fs.existsSync(filePath) || + fs.statSync(filePath).isDirectory() + ) { + next(); + return; + } + + res.statusCode = 200; + res.setHeader('Content-Type', getContentType(filePath)); + res.setHeader('Access-Control-Allow-Origin', '*'); + fs.createReadStream(filePath).pipe(res); + }); + }; + }); + }, + }; +} diff --git a/apps/bridge-ssr-demo/shared/routerAlias.ts b/apps/bridge-ssr-demo/shared/routerAlias.ts new file mode 100644 index 00000000000..6ce885d8c98 --- /dev/null +++ b/apps/bridge-ssr-demo/shared/routerAlias.ts @@ -0,0 +1,14 @@ +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const directory = path.dirname(fileURLToPath(import.meta.url)); + +export function createBridgeRouterAlias(reactRouterDomPath: string) { + return { + 'react-router-dom$': path.resolve( + directory, + '../../../packages/bridge/bridge-react/dist/router-v6.es.js', + ), + 'react-router-dom/dist/index.js': reactRouterDomPath, + }; +} diff --git a/apps/bridge-ssr-demo/shared/serveDist.mjs b/apps/bridge-ssr-demo/shared/serveDist.mjs new file mode 100644 index 00000000000..292ec4f14fa --- /dev/null +++ b/apps/bridge-ssr-demo/shared/serveDist.mjs @@ -0,0 +1,19 @@ +import express from 'express'; +import path from 'node:path'; + +const [distDir, portValue] = process.argv.slice(2); +const port = Number(portValue); + +if (!distDir || !Number.isInteger(port)) { + throw new Error('Usage: serveDist.mjs '); +} + +const app = express(); +app.use((_req, res, next) => { + res.setHeader('Access-Control-Allow-Origin', '*'); + next(); +}); +app.use(express.static(path.resolve(distDir))); +app.listen(port, process.env.HOST ?? 'localhost', () => { + console.log(`[bridge-ssr-static] serving ${distDir} on port ${port}`); +}); diff --git a/apps/metro-example-host/Gemfile.lock b/apps/metro-example-host/Gemfile.lock index bc6c33ca423..bafb966e449 100644 --- a/apps/metro-example-host/Gemfile.lock +++ b/apps/metro-example-host/Gemfile.lock @@ -77,7 +77,7 @@ GEM mutex_m i18n (1.14.8) concurrent-ruby (~> 1.0) - json (2.19.4) + json (2.19.9) logger (1.7.0) minitest (5.27.0) molinillo (0.8.0) diff --git a/apps/next-app-router/next-app-router-4000/package.json b/apps/next-app-router/next-app-router-4000/package.json index d8428e10d25..7f78c1464d8 100644 --- a/apps/next-app-router/next-app-router-4000/package.json +++ b/apps/next-app-router/next-app-router-4000/package.json @@ -50,7 +50,7 @@ "eslint": "9.26.0", "eslint-config-next": "16.0.10", "lint-staged": "15.2.2", - "postcss": "8.5.10", + "postcss": "8.5.23", "prettier": "3.2.5", "prettier-plugin-tailwindcss": "0.5.14", "tailwindcss": "3.4.3", diff --git a/apps/next-app-router/next-app-router-4001/package.json b/apps/next-app-router/next-app-router-4001/package.json index 180d8ef78c2..8711ceb6e52 100644 --- a/apps/next-app-router/next-app-router-4001/package.json +++ b/apps/next-app-router/next-app-router-4001/package.json @@ -50,7 +50,7 @@ "eslint": "9.26.0", "eslint-config-next": "16.0.10", "lint-staged": "15.2.2", - "postcss": "8.5.10", + "postcss": "8.5.23", "prettier": "3.2.5", "prettier-plugin-tailwindcss": "0.5.14", "tailwindcss": "3.4.3", diff --git a/apps/node-dynamic-remote-new-version/CHANGELOG.md b/apps/node-dynamic-remote-new-version/CHANGELOG.md index fa46fdd48f8..ab0ba94c68d 100644 --- a/apps/node-dynamic-remote-new-version/CHANGELOG.md +++ b/apps/node-dynamic-remote-new-version/CHANGELOG.md @@ -1,5 +1,11 @@ # node-dynamic-remote-new-version +## 1.0.16 + +### Patch Changes + +- @module-federation/node@2.7.49 + ## 1.0.15 ### Patch Changes diff --git a/apps/node-dynamic-remote-new-version/package.json b/apps/node-dynamic-remote-new-version/package.json index f4ac81d1866..04c00b3782b 100644 --- a/apps/node-dynamic-remote-new-version/package.json +++ b/apps/node-dynamic-remote-new-version/package.json @@ -6,7 +6,7 @@ "@module-federation/node": "workspace:*", "lodash": "4.18.1" }, - "version": "1.0.15", + "version": "1.0.16", "scripts": { "build": "NODE_OPTIONS=--max_old_space_size=4096 pnpm exec webpack-cli --config webpack.config.js --output-path dist --mode production", "build:development": "NODE_OPTIONS=--max_old_space_size=4096 pnpm exec webpack-cli --config webpack.config.js --output-path dist --mode development", diff --git a/apps/node-dynamic-remote/CHANGELOG.md b/apps/node-dynamic-remote/CHANGELOG.md index e471deafb17..5360a4959b6 100644 --- a/apps/node-dynamic-remote/CHANGELOG.md +++ b/apps/node-dynamic-remote/CHANGELOG.md @@ -1,5 +1,11 @@ # node-dynamic-remote +## 1.0.16 + +### Patch Changes + +- @module-federation/node@2.7.49 + ## 1.0.15 ### Patch Changes diff --git a/apps/node-dynamic-remote/package.json b/apps/node-dynamic-remote/package.json index 1c8e4396778..8a604034867 100644 --- a/apps/node-dynamic-remote/package.json +++ b/apps/node-dynamic-remote/package.json @@ -6,7 +6,7 @@ "@module-federation/node": "workspace:*", "lodash": "4.18.1" }, - "version": "1.0.15", + "version": "1.0.16", "scripts": { "build": "NODE_OPTIONS=--max_old_space_size=4096 pnpm exec webpack-cli --config webpack.config.js --output-path dist --mode production", "build:development": "NODE_OPTIONS=--max_old_space_size=4096 pnpm exec webpack-cli --config webpack.config.js --output-path dist --mode development", diff --git a/apps/router-demo/router-remote5-2005/CHANGELOG.md b/apps/router-demo/router-remote5-2005/CHANGELOG.md index 6005af769e3..de43d9e0099 100644 --- a/apps/router-demo/router-remote5-2005/CHANGELOG.md +++ b/apps/router-demo/router-remote5-2005/CHANGELOG.md @@ -1,5 +1,13 @@ # remote5 +## 2.0.18 + +### Patch Changes + +- Updated dependencies [179caf4] + - @module-federation/bridge-react@2.8.2 + - @module-federation/rsbuild-plugin@2.8.2 + ## 2.0.17 ### Patch Changes diff --git a/apps/router-demo/router-remote5-2005/package.json b/apps/router-demo/router-remote5-2005/package.json index 262737212b2..67fa166e785 100644 --- a/apps/router-demo/router-remote5-2005/package.json +++ b/apps/router-demo/router-remote5-2005/package.json @@ -1,7 +1,7 @@ { "name": "remote5", "private": true, - "version": "2.0.17", + "version": "2.0.18", "scripts": { "dev": "rsbuild dev", "build": "rsbuild build", diff --git a/apps/router-demo/router-remote6-2006/CHANGELOG.md b/apps/router-demo/router-remote6-2006/CHANGELOG.md index 39ea21d27b0..fce7b0edd08 100644 --- a/apps/router-demo/router-remote6-2006/CHANGELOG.md +++ b/apps/router-demo/router-remote6-2006/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## 2.0.18 + +### Patch Changes + +- Updated dependencies [179caf4] + - @module-federation/bridge-react@2.8.2 + - @module-federation/rsbuild-plugin@2.8.2 + ## 2.0.17 ### Patch Changes diff --git a/apps/router-demo/router-remote6-2006/package.json b/apps/router-demo/router-remote6-2006/package.json index e1e9172f88d..50347ae84bf 100644 --- a/apps/router-demo/router-remote6-2006/package.json +++ b/apps/router-demo/router-remote6-2006/package.json @@ -1,7 +1,7 @@ { "name": "remote6", "private": true, - "version": "2.0.17", + "version": "2.0.18", "scripts": { "dev": "rsbuild dev", "build": "rsbuild build", @@ -17,7 +17,7 @@ "antd": "^5.17.0", "react": "18.3.1", "react-dom": "18.3.1", - "react-router": "7.16.0" + "react-router": "7.18.2" }, "devDependencies": { "@rsbuild/core": "2.1.4", diff --git a/apps/rstest-federation-host/browser-esm-module-federation.config.ts b/apps/rstest-federation-host/browser-esm-module-federation.config.ts new file mode 100644 index 00000000000..b959c2a96d3 --- /dev/null +++ b/apps/rstest-federation-host/browser-esm-module-federation.config.ts @@ -0,0 +1,17 @@ +import { createModuleFederationConfig } from '@module-federation/rsbuild-plugin'; + +export default createModuleFederationConfig({ + name: 'rstest_federation_browser_esm_host', + remoteType: 'module', + remotes: { + catalogRemote: 'http://127.0.0.1:3301/browser-esm/remoteEntry.mjs', + profileRemote: 'http://127.0.0.1:3302/browser-esm/remoteEntry.mjs', + }, + shared: { + react: { eager: true, singleton: true }, + 'react-dom': { eager: true, singleton: true }, + }, + dts: false, + manifest: false, + dev: false, +}); diff --git a/apps/rstest-federation-host/browser-module-federation.config.ts b/apps/rstest-federation-host/browser-module-federation.config.ts new file mode 100644 index 00000000000..21b4f27fee6 --- /dev/null +++ b/apps/rstest-federation-host/browser-module-federation.config.ts @@ -0,0 +1,18 @@ +import { createModuleFederationConfig } from '@module-federation/rsbuild-plugin'; + +export default createModuleFederationConfig({ + name: 'rstest_federation_browser_host', + remotes: { + catalogRemote: + 'rstest_federation_catalog_browser@http://127.0.0.1:3301/browser/remoteEntry.js', + profileRemote: + 'rstest_federation_profile_browser@http://127.0.0.1:3302/browser/remoteEntry.js', + }, + shared: { + react: { eager: true, singleton: true }, + 'react-dom': { eager: true, singleton: true }, + }, + dts: false, + manifest: false, + dev: false, +}); diff --git a/apps/rstest-federation-host/module-federation.config.ts b/apps/rstest-federation-host/module-federation.config.ts new file mode 100644 index 00000000000..f65f467aac9 --- /dev/null +++ b/apps/rstest-federation-host/module-federation.config.ts @@ -0,0 +1,23 @@ +import path from 'node:path'; +import { createModuleFederationConfig } from '@module-federation/rsbuild-plugin'; + +const localRemoteEntryPath = path.resolve( + import.meta.dirname, + '../rstest-federation-profile-remote/dist/local/remoteEntry.cjs', +); + +export default createModuleFederationConfig({ + name: 'rstest_federation_host', + remotes: { + catalogRemote: + 'rstest_federation_catalog_node@http://127.0.0.1:3301/node/remoteEntry.cjs', + profileRemote: `commonjs ${localRemoteEntryPath}`, + }, + shared: { + react: { singleton: true }, + 'react-dom': { singleton: true }, + }, + dts: false, + manifest: false, + dev: false, +}); diff --git a/apps/rstest-federation-host/package.json b/apps/rstest-federation-host/package.json new file mode 100644 index 00000000000..4d49f2b6405 --- /dev/null +++ b/apps/rstest-federation-host/package.json @@ -0,0 +1,34 @@ +{ + "name": "rstest-federation-host", + "version": "0.0.0", + "private": true, + "type": "module", + "description": "Rsbuild host that composes two remotes in Rstest E2E.", + "scripts": { + "build": "rsbuild build --environment node", + "e2e": "pnpm run e2e:node && node ../../scripts/ensure-playwright.js && pnpm run e2e:browser && pnpm run e2e:browser-esm", + "e2e:browser": "node run-browser-tests.mjs rstest.browser.config.ts", + "e2e:browser-esm": "node run-browser-tests.mjs rstest.browser-esm.config.ts", + "e2e:node": "pnpm exec rstest run -c rstest.config.ts" + }, + "devDependencies": { + "@module-federation/rsbuild-plugin": "workspace:*", + "@module-federation/rstest": "workspace:*", + "@rsbuild/core": "2.1.4", + "@rsbuild/plugin-react": "2.1.0", + "@rstest/browser": "0.11.6", + "@rstest/browser-react": "0.11.6", + "@rstest/core": "0.11.6", + "@testing-library/dom": "10.4.1", + "@testing-library/react": "15.0.7", + "@types/react": "18.3.11", + "@types/react-dom": "18.3.0", + "playwright": "1.57.0", + "react": "18.3.1", + "react-dom": "18.3.1", + "rstest-federation-profile-remote": "workspace:*", + "rstest-federation-remote": "workspace:*", + "sirv": "3.0.2", + "typescript": "6.0.3" + } +} diff --git a/apps/rstest-federation-host/remote-servers.mjs b/apps/rstest-federation-host/remote-servers.mjs new file mode 100644 index 00000000000..b9cc9b69c31 --- /dev/null +++ b/apps/rstest-federation-host/remote-servers.mjs @@ -0,0 +1,65 @@ +import { stat } from 'node:fs/promises'; +import { createServer } from 'node:http'; +import path from 'node:path'; +import sirv from 'sirv'; + +const HOST = '127.0.0.1'; +const REMOTES = [ + { + directory: path.resolve( + import.meta.dirname, + '../rstest-federation-remote/dist', + ), + port: 3301, + }, + { + directory: path.resolve( + import.meta.dirname, + '../rstest-federation-profile-remote/dist', + ), + port: 3302, + }, +]; + +const servers = []; + +export const setup = async () => { + try { + for (const remote of REMOTES) { + const remoteStats = await stat(remote.directory).catch(() => undefined); + if (!remoteStats?.isDirectory()) { + throw new Error( + `Expected the built federation remote at ${remote.directory}.`, + ); + } + + const serveRemote = sirv(remote.directory, { + dev: true, + setHeaders(response) { + response.setHeader('Access-Control-Allow-Origin', '*'); + }, + }); + const server = createServer(serveRemote); + await new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(remote.port, HOST, resolve); + }); + servers.push(server); + } + } catch (error) { + await teardown(); + throw error; + } +}; + +export const teardown = async () => { + const activeServers = servers.splice(0); + await Promise.all( + activeServers.map( + (server) => + new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + }), + ), + ); +}; diff --git a/apps/rstest-federation-host/rsbuild.config.ts b/apps/rstest-federation-host/rsbuild.config.ts new file mode 100644 index 00000000000..9802f08eb44 --- /dev/null +++ b/apps/rstest-federation-host/rsbuild.config.ts @@ -0,0 +1,28 @@ +import { pluginModuleFederation } from '@module-federation/rsbuild-plugin'; +import { defineConfig } from '@rsbuild/core'; +import federationOptions from './module-federation.config'; + +export default defineConfig({ + environments: { + node: { + source: { + entry: { + index: './src/index.ts', + }, + }, + output: { + cleanDistPath: true, + distPath: { + root: 'dist', + }, + target: 'node', + }, + }, + }, + plugins: [ + pluginModuleFederation(federationOptions, { + environment: 'node', + target: 'node', + }), + ], +}); diff --git a/apps/rstest-federation-host/rstest.browser-esm.config.ts b/apps/rstest-federation-host/rstest.browser-esm.config.ts new file mode 100644 index 00000000000..e232ab96a27 --- /dev/null +++ b/apps/rstest-federation-host/rstest.browser-esm.config.ts @@ -0,0 +1,23 @@ +import { pluginModuleFederation } from '@module-federation/rsbuild-plugin'; +import { pluginReact } from '@rsbuild/plugin-react'; +import { federation } from '@module-federation/rstest'; +import { defineConfig } from '@rstest/core'; +import federationOptions from './browser-esm-module-federation.config'; + +export default defineConfig({ + browser: { + enabled: true, + provider: 'playwright', + headless: true, + }, + include: ['./tests/browser-esm/*.test.tsx'], + testTimeout: 30_000, + plugins: [ + pluginReact(), + pluginModuleFederation(federationOptions, { + environment: 'rstest', + target: 'web', + }), + federation(), + ], +}); diff --git a/apps/rstest-federation-host/rstest.browser.config.ts b/apps/rstest-federation-host/rstest.browser.config.ts new file mode 100644 index 00000000000..4df9d30ff5c --- /dev/null +++ b/apps/rstest-federation-host/rstest.browser.config.ts @@ -0,0 +1,23 @@ +import { pluginModuleFederation } from '@module-federation/rsbuild-plugin'; +import { pluginReact } from '@rsbuild/plugin-react'; +import { federation } from '@module-federation/rstest'; +import { defineConfig } from '@rstest/core'; +import federationOptions from './browser-module-federation.config'; + +export default defineConfig({ + browser: { + enabled: true, + provider: 'playwright', + headless: true, + }, + include: ['./tests/browser/*.test.tsx'], + testTimeout: 30_000, + plugins: [ + pluginReact(), + pluginModuleFederation(federationOptions, { + environment: 'rstest', + target: 'web', + }), + federation(), + ], +}); diff --git a/apps/rstest-federation-host/rstest.config.ts b/apps/rstest-federation-host/rstest.config.ts new file mode 100644 index 00000000000..87833b9bc1c --- /dev/null +++ b/apps/rstest-federation-host/rstest.config.ts @@ -0,0 +1,26 @@ +import path from 'node:path'; +import { pluginModuleFederation } from '@module-federation/rsbuild-plugin'; +import { pluginReact } from '@rsbuild/plugin-react'; +import { federation } from '@module-federation/rstest'; +import { defineConfig } from '@rstest/core'; +import federationOptions from './module-federation.config'; + +const appDirectory = import.meta.dirname; + +export default defineConfig({ + globalSetup: [path.resolve(appDirectory, 'remote-servers.mjs')], + include: [ + path.resolve(appDirectory, 'tests/*.test.ts'), + path.resolve(appDirectory, 'tests/*.test.tsx'), + ], + testEnvironment: 'jsdom', + testTimeout: 30_000, + plugins: [ + pluginReact(), + pluginModuleFederation(federationOptions, { + environment: 'rstest', + target: 'node', + }), + federation(), + ], +}); diff --git a/apps/rstest-federation-host/run-browser-tests.mjs b/apps/rstest-federation-host/run-browser-tests.mjs new file mode 100644 index 00000000000..966b8f2517b --- /dev/null +++ b/apps/rstest-federation-host/run-browser-tests.mjs @@ -0,0 +1,22 @@ +import { spawn } from 'node:child_process'; +import { setup, teardown } from './remote-servers.mjs'; + +const configFile = process.argv[2]; +if (!configFile) { + throw new Error('Expected an Rstest config file.'); +} + +await setup(); +try { + const exitCode = await new Promise((resolve, reject) => { + const child = spawn('pnpm', ['exec', 'rstest', 'run', '-c', configFile], { + stdio: 'inherit', + }); + child.once('error', reject); + child.once('exit', (code) => resolve(code ?? 1)); + }); + + process.exitCode = exitCode; +} finally { + await teardown(); +} diff --git a/apps/rstest-federation-host/src/App.tsx b/apps/rstest-federation-host/src/App.tsx new file mode 100644 index 00000000000..e9b181719c6 --- /dev/null +++ b/apps/rstest-federation-host/src/App.tsx @@ -0,0 +1,12 @@ +import ProductPanel from 'catalogRemote/ProductPanel'; +import ProfileCard from 'profileRemote/ProfileCard'; + +export default function App() { + return ( +
+

Federated shop

+ + +
+ ); +} diff --git a/apps/rstest-federation-host/src/index.ts b/apps/rstest-federation-host/src/index.ts new file mode 100644 index 00000000000..6cbe57f1d0e --- /dev/null +++ b/apps/rstest-federation-host/src/index.ts @@ -0,0 +1,2 @@ +export { default as App } from './App'; +export { loadProductDetails } from './load-product-details'; diff --git a/apps/rstest-federation-host/src/load-product-details.ts b/apps/rstest-federation-host/src/load-product-details.ts new file mode 100644 index 00000000000..b7e07daa545 --- /dev/null +++ b/apps/rstest-federation-host/src/load-product-details.ts @@ -0,0 +1,6 @@ +export const loadProductDetails = async (): Promise => { + const { default: getProductDetails } = + await import('catalogRemote/product-details'); + + return getProductDetails(); +}; diff --git a/apps/rstest-federation-host/tests/app.test.tsx b/apps/rstest-federation-host/tests/app.test.tsx new file mode 100644 index 00000000000..fc049d1c6c6 --- /dev/null +++ b/apps/rstest-federation-host/tests/app.test.tsx @@ -0,0 +1,15 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import { expect, it } from '@rstest/core'; +import App from '../src/App'; + +it('composes and interacts with both federation remotes in JSDOM', () => { + render(); + + expect(screen.getByRole('heading', { name: 'Federated shop' })).toBeTruthy(); + expect(screen.getByText('Ada Lovelace')).toBeTruthy(); + expect(screen.getByText('Member since 1843')).toBeTruthy(); + + const button = screen.getByRole('button', { name: 'Add item (0)' }); + fireEvent.click(button); + expect(screen.getByRole('button', { name: 'Add item (1)' })).toBeTruthy(); +}); diff --git a/apps/rstest-federation-host/tests/browser-esm/app.test.tsx b/apps/rstest-federation-host/tests/browser-esm/app.test.tsx new file mode 100644 index 00000000000..4070c062d7f --- /dev/null +++ b/apps/rstest-federation-host/tests/browser-esm/app.test.tsx @@ -0,0 +1,20 @@ +import { page } from '@rstest/browser'; +import { render } from '@rstest/browser-react'; +import { expect, it } from '@rstest/core'; +import App from '../../src/App'; + +it('composes both browser ESM federation remotes', async () => { + await render(); + + await expect + .element(page.getByRole('heading', { name: 'Federated shop' })) + .toBeVisible(); + await expect.element(page.getByText('Ada Lovelace')).toBeVisible(); + await expect.element(page.getByText('Member since 1843')).toBeVisible(); + + await page.getByRole('button', { name: 'Add item (0)' }).click(); + + await expect + .element(page.getByRole('button', { name: 'Add item (1)' })) + .toBeVisible(); +}); diff --git a/apps/rstest-federation-host/tests/browser/app.test.tsx b/apps/rstest-federation-host/tests/browser/app.test.tsx new file mode 100644 index 00000000000..ea99acf8312 --- /dev/null +++ b/apps/rstest-federation-host/tests/browser/app.test.tsx @@ -0,0 +1,20 @@ +import { page } from '@rstest/browser'; +import { render } from '@rstest/browser-react'; +import { expect, it } from '@rstest/core'; +import App from '../../src/App'; + +it('composes both browser federation remotes', async () => { + await render(); + + await expect + .element(page.getByRole('heading', { name: 'Federated shop' })) + .toBeVisible(); + await expect.element(page.getByText('Ada Lovelace')).toBeVisible(); + await expect.element(page.getByText('Member since 1843')).toBeVisible(); + + await page.getByRole('button', { name: 'Add item (0)' }).click(); + + await expect + .element(page.getByRole('button', { name: 'Add item (1)' })) + .toBeVisible(); +}); diff --git a/apps/rstest-federation-host/tests/product-details.test.ts b/apps/rstest-federation-host/tests/product-details.test.ts new file mode 100644 index 00000000000..3cff3b70713 --- /dev/null +++ b/apps/rstest-federation-host/tests/product-details.test.ts @@ -0,0 +1,8 @@ +import { expect, it } from '@rstest/core'; +import { loadProductDetails } from '../src/load-product-details'; + +it('loads product details dynamically from the HTTP catalog remote', async () => { + await expect(loadProductDetails()).resolves.toBe( + 'The catalog is served by the HTTP remote.', + ); +}); diff --git a/apps/rstest-federation-profile-remote/module-federation-browser-esm.config.ts b/apps/rstest-federation-profile-remote/module-federation-browser-esm.config.ts new file mode 100644 index 00000000000..61672813b22 --- /dev/null +++ b/apps/rstest-federation-profile-remote/module-federation-browser-esm.config.ts @@ -0,0 +1,19 @@ +import { createModuleFederationConfig } from '@module-federation/rsbuild-plugin'; + +export default createModuleFederationConfig({ + name: 'rstest_federation_profile_browser_esm', + filename: 'remoteEntry.mjs', + library: { + type: 'module', + }, + exposes: { + './ProfileCard': './src/ProfileCard.tsx', + }, + shared: { + react: { eager: true, singleton: true }, + 'react-dom': { eager: true, singleton: true }, + }, + dts: false, + manifest: false, + dev: false, +}); diff --git a/apps/rstest-federation-profile-remote/module-federation-browser.config.ts b/apps/rstest-federation-profile-remote/module-federation-browser.config.ts new file mode 100644 index 00000000000..d923022c1b4 --- /dev/null +++ b/apps/rstest-federation-profile-remote/module-federation-browser.config.ts @@ -0,0 +1,16 @@ +import { createModuleFederationConfig } from '@module-federation/rsbuild-plugin'; + +export default createModuleFederationConfig({ + name: 'rstest_federation_profile_browser', + filename: 'remoteEntry.js', + exposes: { + './ProfileCard': './src/ProfileCard.tsx', + }, + shared: { + react: { eager: true, singleton: true }, + 'react-dom': { eager: true, singleton: true }, + }, + dts: false, + manifest: false, + dev: false, +}); diff --git a/apps/rstest-federation-profile-remote/module-federation.config.ts b/apps/rstest-federation-profile-remote/module-federation.config.ts new file mode 100644 index 00000000000..ad802555ac8 --- /dev/null +++ b/apps/rstest-federation-profile-remote/module-federation.config.ts @@ -0,0 +1,16 @@ +import { createModuleFederationConfig } from '@module-federation/rsbuild-plugin'; + +export default createModuleFederationConfig({ + name: 'rstest_federation_profile_local', + filename: 'remoteEntry.cjs', + exposes: { + './ProfileCard': './src/ProfileCard.tsx', + }, + shared: { + react: { singleton: true }, + 'react-dom': { singleton: true }, + }, + dts: false, + manifest: false, + dev: false, +}); diff --git a/apps/rstest-federation-profile-remote/package.json b/apps/rstest-federation-profile-remote/package.json new file mode 100644 index 00000000000..805ae6bd254 --- /dev/null +++ b/apps/rstest-federation-profile-remote/package.json @@ -0,0 +1,21 @@ +{ + "name": "rstest-federation-profile-remote", + "version": "0.0.0", + "private": true, + "type": "module", + "description": "Rsbuild profile remote for Rstest federation E2E.", + "scripts": { + "build": "rsbuild build" + }, + "devDependencies": { + "@module-federation/node": "workspace:*", + "@module-federation/rsbuild-plugin": "workspace:*", + "@rsbuild/core": "2.1.4", + "@rsbuild/plugin-react": "2.1.0", + "@types/react": "18.3.11", + "@types/react-dom": "18.3.0", + "react": "18.3.1", + "react-dom": "18.3.1", + "typescript": "6.0.3" + } +} diff --git a/apps/rstest-federation-profile-remote/rsbuild.config.ts b/apps/rstest-federation-profile-remote/rsbuild.config.ts new file mode 100644 index 00000000000..77148132d70 --- /dev/null +++ b/apps/rstest-federation-profile-remote/rsbuild.config.ts @@ -0,0 +1,80 @@ +import { pluginModuleFederation } from '@module-federation/rsbuild-plugin'; +import { defineConfig } from '@rsbuild/core'; +import { pluginReact } from '@rsbuild/plugin-react'; +import browserEsmFederationOptions from './module-federation-browser-esm.config'; +import browserFederationOptions from './module-federation-browser.config'; +import localFederationOptions from './module-federation.config'; + +export default defineConfig({ + mode: 'development', + environments: { + local: { + source: { + entry: { + index: './src/index.ts', + }, + }, + output: { + cleanDistPath: true, + distPath: { + root: 'dist/local', + }, + target: 'node', + }, + }, + browser: { + dev: { + assetPrefix: 'http://127.0.0.1:3302/browser/', + }, + source: { + entry: { + index: './src/index.ts', + }, + }, + output: { + assetPrefix: 'http://127.0.0.1:3302/browser/', + cleanDistPath: true, + distPath: { + root: 'dist/browser', + }, + filenameHash: false, + target: 'web', + }, + }, + browserEsm: { + dev: { + assetPrefix: 'http://127.0.0.1:3302/browser-esm/', + }, + source: { + entry: { + index: './src/index.ts', + }, + }, + output: { + assetPrefix: 'http://127.0.0.1:3302/browser-esm/', + cleanDistPath: true, + distPath: { + root: 'dist/browser-esm', + }, + filenameHash: false, + module: true, + target: 'web', + }, + }, + }, + plugins: [ + pluginReact({ fastRefresh: false }), + pluginModuleFederation(localFederationOptions, { + environment: 'local', + target: 'node', + }), + pluginModuleFederation(browserFederationOptions, { + environment: 'browser', + target: 'web', + }), + pluginModuleFederation(browserEsmFederationOptions, { + environment: 'browserEsm', + target: 'web', + }), + ], +}); diff --git a/apps/rstest-federation-profile-remote/src/ProfileCard.tsx b/apps/rstest-federation-profile-remote/src/ProfileCard.tsx new file mode 100644 index 00000000000..8ac4a6acf2b --- /dev/null +++ b/apps/rstest-federation-profile-remote/src/ProfileCard.tsx @@ -0,0 +1,9 @@ +export default function ProfileCard() { + return ( + + ); +} diff --git a/apps/rstest-federation-profile-remote/src/index.ts b/apps/rstest-federation-profile-remote/src/index.ts new file mode 100644 index 00000000000..cb0ff5c3b54 --- /dev/null +++ b/apps/rstest-federation-profile-remote/src/index.ts @@ -0,0 +1 @@ +export {}; diff --git a/apps/rstest-federation-remote/module-federation-browser-esm.config.ts b/apps/rstest-federation-remote/module-federation-browser-esm.config.ts new file mode 100644 index 00000000000..50ebcf0aadd --- /dev/null +++ b/apps/rstest-federation-remote/module-federation-browser-esm.config.ts @@ -0,0 +1,25 @@ +import { createModuleFederationConfig } from '@module-federation/rsbuild-plugin'; + +export default createModuleFederationConfig({ + name: 'rstest_federation_catalog_browser_esm', + filename: 'remoteEntry.mjs', + library: { + type: 'module', + }, + exposes: { + './ProductPanel': './src/ProductPanel.tsx', + }, + shared: { + react: { + eager: true, + singleton: true, + }, + 'react-dom': { + eager: true, + singleton: true, + }, + }, + dts: false, + manifest: false, + dev: false, +}); diff --git a/apps/rstest-federation-remote/module-federation-browser.config.ts b/apps/rstest-federation-remote/module-federation-browser.config.ts new file mode 100644 index 00000000000..7b49221c079 --- /dev/null +++ b/apps/rstest-federation-remote/module-federation-browser.config.ts @@ -0,0 +1,22 @@ +import { createModuleFederationConfig } from '@module-federation/rsbuild-plugin'; + +export default createModuleFederationConfig({ + name: 'rstest_federation_catalog_browser', + filename: 'remoteEntry.js', + exposes: { + './ProductPanel': './src/ProductPanel.tsx', + }, + shared: { + react: { + eager: true, + singleton: true, + }, + 'react-dom': { + eager: true, + singleton: true, + }, + }, + dts: false, + manifest: false, + dev: false, +}); diff --git a/apps/rstest-federation-remote/module-federation-http.config.ts b/apps/rstest-federation-remote/module-federation-http.config.ts new file mode 100644 index 00000000000..129c4bf68cc --- /dev/null +++ b/apps/rstest-federation-remote/module-federation-http.config.ts @@ -0,0 +1,21 @@ +import { createModuleFederationConfig } from '@module-federation/rsbuild-plugin'; + +export default createModuleFederationConfig({ + name: 'rstest_federation_catalog_node', + filename: 'remoteEntry.cjs', + exposes: { + './ProductPanel': './src/ProductPanel.tsx', + './product-details': './src/product-details.ts', + }, + shared: { + react: { + singleton: true, + }, + 'react-dom': { + singleton: true, + }, + }, + dts: false, + manifest: false, + dev: false, +}); diff --git a/apps/rstest-federation-remote/package.json b/apps/rstest-federation-remote/package.json new file mode 100644 index 00000000000..a2a721908ab --- /dev/null +++ b/apps/rstest-federation-remote/package.json @@ -0,0 +1,21 @@ +{ + "name": "rstest-federation-remote", + "version": "0.0.0", + "private": true, + "type": "module", + "description": "Rsbuild catalog remote for Rstest federation E2E.", + "scripts": { + "build": "rsbuild build" + }, + "devDependencies": { + "@module-federation/node": "workspace:*", + "@module-federation/rsbuild-plugin": "workspace:*", + "@rsbuild/core": "2.1.4", + "@rsbuild/plugin-react": "2.1.0", + "@types/react": "18.3.11", + "@types/react-dom": "18.3.0", + "react": "18.3.1", + "react-dom": "18.3.1", + "typescript": "6.0.3" + } +} diff --git a/apps/rstest-federation-remote/rsbuild.config.ts b/apps/rstest-federation-remote/rsbuild.config.ts new file mode 100644 index 00000000000..a9e94970f92 --- /dev/null +++ b/apps/rstest-federation-remote/rsbuild.config.ts @@ -0,0 +1,85 @@ +import { pluginModuleFederation } from '@module-federation/rsbuild-plugin'; +import { defineConfig } from '@rsbuild/core'; +import { pluginReact } from '@rsbuild/plugin-react'; +import browserEsmFederationOptions from './module-federation-browser-esm.config'; +import browserFederationOptions from './module-federation-browser.config'; +import httpFederationOptions from './module-federation-http.config'; + +export default defineConfig({ + mode: 'development', + environments: { + node: { + dev: { + assetPrefix: 'http://127.0.0.1:3301/node/', + }, + source: { + entry: { + index: './src/index.ts', + }, + }, + output: { + assetPrefix: 'http://127.0.0.1:3301/node/', + cleanDistPath: true, + distPath: { + root: 'dist/node', + }, + filenameHash: false, + target: 'node', + }, + }, + browser: { + dev: { + assetPrefix: 'http://127.0.0.1:3301/browser/', + }, + source: { + entry: { + index: './src/index.ts', + }, + }, + output: { + assetPrefix: 'http://127.0.0.1:3301/browser/', + cleanDistPath: true, + distPath: { + root: 'dist/browser', + }, + filenameHash: false, + target: 'web', + }, + }, + browserEsm: { + dev: { + assetPrefix: 'http://127.0.0.1:3301/browser-esm/', + }, + source: { + entry: { + index: './src/index.ts', + }, + }, + output: { + assetPrefix: 'http://127.0.0.1:3301/browser-esm/', + cleanDistPath: true, + distPath: { + root: 'dist/browser-esm', + }, + filenameHash: false, + module: true, + target: 'web', + }, + }, + }, + plugins: [ + pluginReact({ fastRefresh: false }), + pluginModuleFederation(httpFederationOptions, { + environment: 'node', + target: 'node', + }), + pluginModuleFederation(browserFederationOptions, { + environment: 'browser', + target: 'web', + }), + pluginModuleFederation(browserEsmFederationOptions, { + environment: 'browserEsm', + target: 'web', + }), + ], +}); diff --git a/apps/rstest-federation-remote/src/ProductPanel.tsx b/apps/rstest-federation-remote/src/ProductPanel.tsx new file mode 100644 index 00000000000..5596bef7bcc --- /dev/null +++ b/apps/rstest-federation-remote/src/ProductPanel.tsx @@ -0,0 +1,15 @@ +import { useState } from 'react'; + +export default function ProductPanel() { + const [items, setItems] = useState(0); + + return ( +
+

Remote catalog

+

Analytical Engine starter kit

+ +
+ ); +} diff --git a/apps/rstest-federation-remote/src/index.ts b/apps/rstest-federation-remote/src/index.ts new file mode 100644 index 00000000000..cb0ff5c3b54 --- /dev/null +++ b/apps/rstest-federation-remote/src/index.ts @@ -0,0 +1 @@ +export {}; diff --git a/apps/rstest-federation-remote/src/product-details.ts b/apps/rstest-federation-remote/src/product-details.ts new file mode 100644 index 00000000000..d66919f2959 --- /dev/null +++ b/apps/rstest-federation-remote/src/product-details.ts @@ -0,0 +1,3 @@ +export default function getProductDetails(): string { + return 'The catalog is served by the HTTP remote.'; +} diff --git a/apps/runtime-demo/3005-runtime-host/cypress/e2e/app.cy.ts b/apps/runtime-demo/3005-runtime-host/cypress/e2e/app.cy.ts index f37b336bb8a..24b8c378504 100644 --- a/apps/runtime-demo/3005-runtime-host/cypress/e2e/app.cy.ts +++ b/apps/runtime-demo/3005-runtime-host/cypress/e2e/app.cy.ts @@ -123,6 +123,25 @@ describe('3005-runtime-host/', () => { }); }); + describe('ESM remote retry', () => { + it('re-requests a failed module entry with the default retry config', () => { + const requestUrls: string[] = []; + cy.intercept('GET', '**/esm-retry-fixture/remoteEntry.js*', (request) => { + requestUrls.push(request.url); + }); + cy.visit('/esm-retry'); + + cy.get('[data-testid="esm-retry-load"]').click(); + cy.get('[data-testid="esm-retry-status"]') + .should('contain', 'ESM retry recovered') + .then(() => { + expect(requestUrls).to.have.length(2); + expect(requestUrls[0]).not.to.contain('retryCount'); + expect(requestUrls[1]).to.contain('retryCount=1'); + }); + }); + }); + describe('observability demo fixture', () => { beforeEach(() => { cy.visit('/observability'); diff --git a/apps/runtime-demo/3005-runtime-host/package.json b/apps/runtime-demo/3005-runtime-host/package.json index 91cad0cc326..337b7928198 100644 --- a/apps/runtime-demo/3005-runtime-host/package.json +++ b/apps/runtime-demo/3005-runtime-host/package.json @@ -5,6 +5,7 @@ "devDependencies": { "@module-federation/core": "workspace:*", "@module-federation/observability-plugin": "workspace:*", + "@module-federation/retry-plugin": "workspace:*", "@module-federation/runtime": "workspace:*", "@module-federation/typescript": "workspace:*", "@module-federation/enhanced": "workspace:*", diff --git a/apps/runtime-demo/3005-runtime-host/src/App.tsx b/apps/runtime-demo/3005-runtime-host/src/App.tsx index 5b3bcd1bc0e..347dd8b04ef 100644 --- a/apps/runtime-demo/3005-runtime-host/src/App.tsx +++ b/apps/runtime-demo/3005-runtime-host/src/App.tsx @@ -8,6 +8,7 @@ import { } from 'react-router-dom'; import ObservabilityDemo from './ObservabilityDemo'; import ObservabilityShowcase from './ObservabilityShowcase'; +import EsmRetryDemo from './EsmRetryDemo'; import Root from './Root'; import Remote1 from './Remote1'; import Remote2 from './Remote2'; @@ -37,6 +38,9 @@ const AppRoutes = () => {
  • observability showcase
  • +
  • + ESM retry +
  • ) : null} @@ -49,6 +53,7 @@ const AppRoutes = () => { path="/observability-showcase/*" element={} /> + } /> ); diff --git a/apps/runtime-demo/3005-runtime-host/src/EsmRetryDemo.tsx b/apps/runtime-demo/3005-runtime-host/src/EsmRetryDemo.tsx new file mode 100644 index 00000000000..9a11454afbe --- /dev/null +++ b/apps/runtime-demo/3005-runtime-host/src/EsmRetryDemo.tsx @@ -0,0 +1,48 @@ +import React, { useCallback, useState } from 'react'; +import { RetryPlugin } from '@module-federation/retry-plugin'; +import { createInstance } from '@module-federation/runtime'; + +type RetryRemote = { + default: string; +}; + +const EsmRetryDemo = () => { + const [status, setStatus] = useState('idle'); + + const loadRemote = useCallback(async () => { + setStatus('loading'); + await fetch('/esm-retry-fixture/reset', { method: 'POST' }); + + const runtime = createInstance({ + name: `esm_retry_host_${Date.now()}`, + remotes: [ + { + name: 'esm_retry_remote', + entry: 'http://127.0.0.1:3005/esm-retry-fixture/remoteEntry.js', + type: 'module', + }, + ], + plugins: [RetryPlugin({ retryTimes: 1, retryDelay: 0 })], + }); + + try { + const remote = await runtime.loadRemote( + 'esm_retry_remote/message', + ); + setStatus(remote?.default ?? 'missing result'); + } catch (error) { + setStatus(error instanceof Error ? error.message : String(error)); + } + }, []); + + return ( +
    + +

    {status}

    +
    + ); +}; + +export default EsmRetryDemo; diff --git a/apps/runtime-demo/3005-runtime-host/webpack.config.js b/apps/runtime-demo/3005-runtime-host/webpack.config.js index 18d57b4adb8..f1fd4395d11 100644 --- a/apps/runtime-demo/3005-runtime-host/webpack.config.js +++ b/apps/runtime-demo/3005-runtime-host/webpack.config.js @@ -173,6 +173,7 @@ module.exports = (_env, argv = {}) => { response.setHeader('Content-Type', 'application/javascript'); response.end(body); }; + let esmRetryRequestCount = 0; const createManifest = ({ name, globalName, publicPath }) => ({ id: name, name, @@ -219,6 +220,35 @@ module.exports = (_env, argv = {}) => { ], }); + devServer.app.post('/esm-retry-fixture/reset', (_request, response) => { + esmRetryRequestCount = 0; + response.statusCode = 204; + response.end(); + }); + devServer.app.get( + '/esm-retry-fixture/remoteEntry.js', + (_request, response) => { + esmRetryRequestCount += 1; + response.setHeader('Cache-Control', 'no-store'); + + if (esmRetryRequestCount === 1) { + response.statusCode = 503; + response.end('ESM remote unavailable'); + return; + } + + sendJs( + response, + [ + 'export function init() {}', + 'export function get() {', + " return Promise.resolve(function() { return { default: 'ESM retry recovered' }; });", + '}', + ].join('\n'), + ); + }, + ); + devServer.app.get( '/observability-fixtures/missing-fields/mf-manifest.json', (_request, response) => { diff --git a/apps/shared-tree-shaking/no-server/host/CHANGELOG.md b/apps/shared-tree-shaking/no-server/host/CHANGELOG.md index bc1dcdda0f1..218fc6408d2 100644 --- a/apps/shared-tree-shaking/no-server/host/CHANGELOG.md +++ b/apps/shared-tree-shaking/no-server/host/CHANGELOG.md @@ -1,5 +1,11 @@ # modernjs-ssr-nested-remote +## 1.0.18 + +### Patch Changes + +- @module-federation/enhanced@2.8.2 + ## 1.0.17 ### Patch Changes diff --git a/apps/shared-tree-shaking/no-server/host/package.json b/apps/shared-tree-shaking/no-server/host/package.json index 852697f0e41..7dbf4645183 100644 --- a/apps/shared-tree-shaking/no-server/host/package.json +++ b/apps/shared-tree-shaking/no-server/host/package.json @@ -1,7 +1,7 @@ { "name": "shared-tree-shaking-no-server-host", "private": true, - "version": "1.0.17", + "version": "1.0.18", "scripts": { "reset": "npx rimraf ./**/node_modules", "dev": "TS_NODE_COMPILER=typescript-compiler modern dev", diff --git a/apps/shared-tree-shaking/no-server/provider/CHANGELOG.md b/apps/shared-tree-shaking/no-server/provider/CHANGELOG.md index bc1dcdda0f1..218fc6408d2 100644 --- a/apps/shared-tree-shaking/no-server/provider/CHANGELOG.md +++ b/apps/shared-tree-shaking/no-server/provider/CHANGELOG.md @@ -1,5 +1,11 @@ # modernjs-ssr-nested-remote +## 1.0.18 + +### Patch Changes + +- @module-federation/enhanced@2.8.2 + ## 1.0.17 ### Patch Changes diff --git a/apps/shared-tree-shaking/no-server/provider/package.json b/apps/shared-tree-shaking/no-server/provider/package.json index 2a9979496cd..7a1aa9237b4 100644 --- a/apps/shared-tree-shaking/no-server/provider/package.json +++ b/apps/shared-tree-shaking/no-server/provider/package.json @@ -1,7 +1,7 @@ { "name": "shared-tree-shaking-no-server-provider", "private": true, - "version": "1.0.17", + "version": "1.0.18", "scripts": { "reset": "npx rimraf ./**/node_modules", "dev": "TS_NODE_COMPILER=typescript-compiler modern dev", diff --git a/apps/shared-tree-shaking/with-server/host/CHANGELOG.md b/apps/shared-tree-shaking/with-server/host/CHANGELOG.md index b6796ae320f..04694be4858 100644 --- a/apps/shared-tree-shaking/with-server/host/CHANGELOG.md +++ b/apps/shared-tree-shaking/with-server/host/CHANGELOG.md @@ -1,5 +1,11 @@ # modernjs-ssr-nested-remote +## 1.0.18 + +### Patch Changes + +- @module-federation/modern-js-v3@2.8.2 + ## 1.0.17 ### Patch Changes diff --git a/apps/shared-tree-shaking/with-server/host/package.json b/apps/shared-tree-shaking/with-server/host/package.json index e944e676c51..9a98d804e1f 100644 --- a/apps/shared-tree-shaking/with-server/host/package.json +++ b/apps/shared-tree-shaking/with-server/host/package.json @@ -1,7 +1,7 @@ { "name": "shared-tree-shaking-with-server-host", "private": true, - "version": "1.0.17", + "version": "1.0.18", "scripts": { "reset": "npx rimraf ./**/node_modules", "dev": "TS_NODE_COMPILER=typescript-compiler modern dev", diff --git a/apps/shared-tree-shaking/with-server/provider/CHANGELOG.md b/apps/shared-tree-shaking/with-server/provider/CHANGELOG.md index b6796ae320f..04694be4858 100644 --- a/apps/shared-tree-shaking/with-server/provider/CHANGELOG.md +++ b/apps/shared-tree-shaking/with-server/provider/CHANGELOG.md @@ -1,5 +1,11 @@ # modernjs-ssr-nested-remote +## 1.0.18 + +### Patch Changes + +- @module-federation/modern-js-v3@2.8.2 + ## 1.0.17 ### Patch Changes diff --git a/apps/shared-tree-shaking/with-server/provider/package.json b/apps/shared-tree-shaking/with-server/provider/package.json index c6367733167..d717099fe6c 100644 --- a/apps/shared-tree-shaking/with-server/provider/package.json +++ b/apps/shared-tree-shaking/with-server/provider/package.json @@ -1,7 +1,7 @@ { "name": "shared-tree-shaking-with-server-provider", "private": true, - "version": "1.0.17", + "version": "1.0.18", "scripts": { "reset": "npx rimraf ./**/node_modules", "dev": "TS_NODE_COMPILER=typescript-compiler modern dev", diff --git a/apps/website-new/CHANGELOG.md b/apps/website-new/CHANGELOG.md index b783762e706..4e3ff6b8961 100644 --- a/apps/website-new/CHANGELOG.md +++ b/apps/website-new/CHANGELOG.md @@ -1,5 +1,13 @@ # website-new +## 1.3.30 + +### Patch Changes + +- @module-federation/runtime@2.8.2 +- @module-federation/rspress-plugin@2.8.2 +- @module-federation/error-codes@2.8.2 + ## 1.3.29 ### Patch Changes diff --git a/apps/website-new/docs/en/_nav.json b/apps/website-new/docs/en/_nav.json index 86ab2335c40..5f7a84e927f 100644 --- a/apps/website-new/docs/en/_nav.json +++ b/apps/website-new/docs/en/_nav.json @@ -26,6 +26,10 @@ "text": "Rslib", "link": "/integrations/build-tool/rslib" }, + { + "text": "Rstest", + "link": "/integrations/build-tool/rstest" + }, { "text": "Vite", "link": "/integrations/build-tool/vite" diff --git a/apps/website-new/docs/en/guide/bridge/_meta.json b/apps/website-new/docs/en/guide/bridge/_meta.json index b7f300763b2..9703deaca9e 100644 --- a/apps/website-new/docs/en/guide/bridge/_meta.json +++ b/apps/website-new/docs/en/guide/bridge/_meta.json @@ -10,5 +10,10 @@ "label": "React", "collapsible": true, "collapsed": true + }, + { + "type": "file", + "name": "ssr", + "label": "Server rendering" } ] diff --git a/apps/website-new/docs/en/guide/bridge/ssr.mdx b/apps/website-new/docs/en/guide/bridge/ssr.mdx new file mode 100644 index 00000000000..a9d5c515922 --- /dev/null +++ b/apps/website-new/docs/en/guide/bridge/ssr.mdx @@ -0,0 +1,84 @@ +# Server rendering Bridge applications + +Bridge SSR V1 lets a federated application provider return a buffered HTML island on the server and hydrate that same island through the existing client lifecycle. + +## Supported scope + +- React 18 with declarative React Router v6 +- Vue 3 with Vue Router 4 +- Full server-side results and identity-only client hydration references +- Multiple instances of the same remote through document-unique instance IDs + +React data routers and other React or Router versions keep their existing client-rendering support but are not supported for SSR in V1. + +React Router v6 remotes must alias `react-router-dom$` to `@module-federation/bridge-react/dist/router-v6.es.js` in both their browser and Node builds. The default `router.es.js` adapter is CSR-only. Attempting to use the default adapter or `RouterProvider` during SSR throws a configuration error. + +## Remote provider + +Set `ssr: true` when no request preparation is needed. If preparation is required, map the same federated export key to separate browser and Node source files. The browser file supplies `hydrate`; the Node file supplies `prepare`. This keeps server renderers and request-only code out of browser chunks. + +```ts +// export-app.server.ts +export default createBridgeComponent({ + rootComponent: App, + ssr: { + prepare({ props }) { + return { + props, + dehydratedState: { initialValue: props.initialValue }, + }; + }, + }, +}); +``` + +The React Node entry owns `react-dom/server`; the Vue Node entry owns `@vue/server-renderer`. Framework elements, routers, application instances, and renderers never cross `@module-federation/bridge-shared`. + +## Host rendering + +Use `renderRemoteBridge` with an intentional, sanitized standard `Request`, stable module name, document-unique instance ID, and explicit props: + +```ts +const result = await renderRemoteBridge({ + loader: () => federationInstance.loadRemote('catalog/export-app'), + moduleName: 'catalog', + instanceId: 'catalog-sidebar', + request, + props: { basename: '/catalog' }, +}); +``` + +Pass the complete result to `createRemoteAppComponent` as `ssr` during server rendering. The adapter emits one instance-scoped slot containing exactly one marked mount child with the remote HTML and one adjacent `application/json` state child. The state child contains the versioned identity and optional dehydrated state, serialized with `serializeBridgeJSON` so executable HTML sequences are escaped. + +Only serialize an identity reference into the host hydration context: + +```ts +import { toBridgeSSRReference } from '@module-federation/bridge-shared'; + +const hydrationContext = { + catalog: toBridgeSSRReference(result), +}; +``` + +On the client, create the registry from the server-rendered document and install it before React `hydrateRoot` or the Vue host application mount. Pass the identity reference, not the full result, as `ssr`. + +```tsx +const registry = createBridgeHydrationRegistry(document); + +hydrateRoot( + document.getElementById('root')!, + + + , +); +``` + +Vue hosts call `provideBridgeHydrationRegistry(app, registry)` before `app.mount(...)`. A referenced slot is single-use: successful hydration consumes its snapshot, so navigating away and returning through the SPA takes the normal CSR path instead of rehydrating stale markup. A missing referenced slot also takes the CSR path. Malformed, duplicated, or identity-conflicting slots throw a contextual hydration error instead of silently mounting over invalid server markup. + +`renderRemoteBridge` rejects provider-load, unsupported-contract, identity, and rendering failures with contextual errors. Response policy remains host-owned. + +Bridge SSR V1 does not provide a shared load/render timeout or CSR fallback for server failures. A slow or broken remote can delay or fail the host response; hosts that need another policy must apply it around `renderRemoteBridge`. + +## Not included in V1 + +Assets/CSP/head management, status and header conflict policy, shared timeouts and server fallbacks, React data routers, additional React/Router versions, Nuxt or Modern.js adapters, portals/Teleports, streaming, backpressure, and benchmarks are follow-up work. diff --git a/apps/website-new/docs/en/integrations/build-tool/_meta.json b/apps/website-new/docs/en/integrations/build-tool/_meta.json index e0da6374d13..56fd32bd400 100644 --- a/apps/website-new/docs/en/integrations/build-tool/_meta.json +++ b/apps/website-new/docs/en/integrations/build-tool/_meta.json @@ -9,6 +9,11 @@ "name": "rslib", "label": "Rslib" }, + { + "type": "file", + "name": "rstest", + "label": "Rstest" + }, { "type": "file", "name": "vite", diff --git a/apps/website-new/docs/en/integrations/build-tool/rstest.mdx b/apps/website-new/docs/en/integrations/build-tool/rstest.mdx new file mode 100644 index 00000000000..bc8bcad2751 --- /dev/null +++ b/apps/website-new/docs/en/integrations/build-tool/rstest.mdx @@ -0,0 +1,219 @@ +# Rstest + +`@module-federation/rstest` enables Module Federation inside +[Rstest](https://rstest.rs/) test builds. Use it when your tests should load +real federated remotes instead of replacing the federation boundary with mocks. + +## Supports + +- Rstest Node and JSDOM test environments. +- Rstest browser mode. +- URL remotes such as `remote@http://localhost:3001/remoteEntry.js`. +- `commonjs ...` path remotes for Node-based integration tests. +- Automatic Rstest federation compatibility mode for Node-targeted test builds. + +## Requirements + +:::tip + +Use `@rstest/core@0.11.4` or newer. Rstest's federation support shipped in +`0.11.4`. + +::: + +Rstest versions before `0.11.4` do not include the `federation` compatibility +mode this plugin enables for Node test builds. + +## Quick Start + +### Installation + +import { PackageManagerTabs } from '@theme'; + + + +### Register Plugin + +```ts title='rstest.config.ts' +import { federation } from '@module-federation/rstest'; +import { defineConfig } from '@rstest/core'; + +export default defineConfig({ + plugins: [ + federation({ + name: 'host', + remotes: { + 'component-app': 'component_app@http://localhost:3001/remoteEntry.js', + }, + shared: { + react: { singleton: true }, + 'react-dom': { singleton: true }, + }, + }), + ], +}); +``` + +:::warning + +Do not set `federation: true` in `rstest.config.ts` when using this plugin. For +Node and JSDOM test environments, `@module-federation/rstest` enables Rstest's +federation compatibility mode automatically. + +::: + +Run your tests with Rstest as usual: + +```bash +npx rstest run +``` + +### Reuse an Rsbuild Federation Configuration + +When the test project already registers `@module-federation/rsbuild-plugin`, +pass no options to `federation()`: + +```ts title='rstest.config.ts' +import { + createModuleFederationConfig, + pluginModuleFederation, +} from '@module-federation/rsbuild-plugin'; +import { federation } from '@module-federation/rstest'; +import { defineConfig } from '@rstest/core'; + +const options = createModuleFederationConfig({ + name: 'host', + remotes: { + 'component-app': 'component_app@http://localhost:3001/remoteEntry.js', + }, +}); + +export default defineConfig({ + plugins: [ + pluginModuleFederation(options, { + environment: 'rstest', + target: 'node', + }), + federation(), + ], +}); +``` + +The plugin order is significant: register `pluginModuleFederation` first so +`federation()` can normalize the same typed options. This creates one +`ModuleFederationPlugin` and avoids duplicating the remote configuration. + +## Test a Federated Remote + +Build or start the remote in Rstest's `globalSetup`, then import exposed modules +the same way application code does: + +```ts title='remote.test.ts' +import { expect, it } from '@rstest/core'; +import Button from 'component-app/Button'; + +it('loads a remote through a static import', () => { + expect(Button).toBeDefined(); +}); + +it('loads a remote through a dynamic import', async () => { + const remote = await import('component-app/Button'); + expect(remote.default).toBeDefined(); +}); +``` + +The official +[Rstest federation example](https://github.com/web-infra-dev/rstest/tree/v0.11.4/examples/federation) +combines an HTTP component remote with a locally built `commonjs ...` remote +and runs both Node and JSDOM projects. + +## How It Works + +For both Node and browser targets, `dts`, `manifest`, and `dev` default to +`false`; explicit values are preserved. + +### Node Test Defaults + +For Node-targeted Rstest builds, the plugin applies Module Federation defaults +that match the Node runtime: + +- `target: async-node` +- `experiments.asyncStartup = true` +- CommonJS container output; `module` and `modern-module` library types are + normalized +- `@module-federation/node/runtimePlugin` +- Node-targeted federation optimization +- Script remote transport by default + +Standard URL remotes use `remoteType: 'script'`. Inline transport prefixes such +as `commonjs ...` override that default. + +### Browser Mode + +Rstest browser mode is detected automatically from its resolved +`browser.enabled` configuration: + +Install `@rstest/browser` and a Playwright browser first. See +[Rstest's Browser Mode setup](https://rstest.rs/guide/browser-testing/getting-started). + +```ts title='rstest.config.ts' +import { federation } from '@module-federation/rstest'; +import { defineConfig } from '@rstest/core'; + +export default defineConfig({ + browser: { + enabled: true, + provider: 'playwright', + }, + plugins: [ + federation({ + name: 'browser_host', + remotes: { + app2: 'app2@http://localhost:3001/remoteEntry.js', + }, + }), + ], +}); +``` + +Browser mode avoids node-only defaults while enabling +`experiments.asyncStartup` and applying the shared defaults above. Pass an +explicit `target` only to override the detected mode. + +## Configuration + +`federation(options, rstestOptions)` accepts the standard +[Module Federation configuration](/configure/index.html) as the first argument. + +```ts +federation(moduleFederationOptions, { + target: 'node', // or 'browser' +}); +``` + +### moduleFederationOptions + +[Module Federation Configuration](/configure/index.html) + +### rstestOptions + +- Type: `{ target?: 'node' | 'browser' }` +- Default: auto-detected from Rstest's resolved `browser.enabled` + configuration. Browser Mode defaults to `'browser'`; other builds default to + `'node'`. + +## Producer Builds + +Use `@module-federation/rsbuild-plugin` or another Module Federation build +plugin to build the remote application. Use `@module-federation/rstest` only in +the Rstest test project that consumes those remotes. + +For Rsbuild producers, continue with the +[Rsbuild plugin guide](/integrations/build-tool/rsbuild.html). diff --git a/apps/website-new/docs/en/plugin/plugins/retry-plugin.mdx b/apps/website-new/docs/en/plugin/plugins/retry-plugin.mdx index 620725e1e74..b5b4edc69bd 100644 --- a/apps/website-new/docs/en/plugin/plugins/retry-plugin.mdx +++ b/apps/website-new/docs/en/plugin/plugins/retry-plugin.mdx @@ -133,7 +133,9 @@ const mf = createInstance({ **addQuery** - Type: `boolean | ((context: { times: number; originalQuery: string }) => string)` - Optional -- Whether to append a query parameter when retrying, default is false +- Whether to append a query parameter when retrying +- Defaults to cache-busting for `esm` and `module` remote entry retries because browsers cache failed dynamic imports by URL. Other retry paths do not add a query parameter by default +- Set `addQuery: false` to opt out of the default ESM/module remote entry cache-busting - If a function is provided, it receives retry count and the original query string, and should return the new query string **fetchOptions** @@ -199,6 +201,16 @@ Order of attempts: Use `addQuery` to add query parameters during retries to avoid cache interference: +For remote entries with type `esm` or `module`, the plugin adds `retryCount` by default so each dynamic import retry uses a new URL. This default does not affect manifest, fetch, or classic script retries. Disable it explicitly if needed: + +```javascript +const retryPlugin = RetryPlugin({ + addQuery: false, +}); +``` + +To enable cache-busting for every retry path: + ```javascript const retryPlugin = RetryPlugin({ addQuery: true, // adds ?retryCount=1, ?retryCount=2, etc. diff --git a/apps/website-new/docs/zh/_nav.json b/apps/website-new/docs/zh/_nav.json index 2f2c44745a9..3dae6adcea5 100644 --- a/apps/website-new/docs/zh/_nav.json +++ b/apps/website-new/docs/zh/_nav.json @@ -26,6 +26,10 @@ "text": "Rslib", "link": "/integrations/build-tool/rslib" }, + { + "text": "Rstest", + "link": "/integrations/build-tool/rstest" + }, { "text": "Vite", "link": "/integrations/build-tool/vite" diff --git a/apps/website-new/docs/zh/integrations/build-tool/_meta.json b/apps/website-new/docs/zh/integrations/build-tool/_meta.json index e0da6374d13..56fd32bd400 100644 --- a/apps/website-new/docs/zh/integrations/build-tool/_meta.json +++ b/apps/website-new/docs/zh/integrations/build-tool/_meta.json @@ -9,6 +9,11 @@ "name": "rslib", "label": "Rslib" }, + { + "type": "file", + "name": "rstest", + "label": "Rstest" + }, { "type": "file", "name": "vite", diff --git a/apps/website-new/docs/zh/integrations/build-tool/rstest.mdx b/apps/website-new/docs/zh/integrations/build-tool/rstest.mdx new file mode 100644 index 00000000000..d7dc81c4e04 --- /dev/null +++ b/apps/website-new/docs/zh/integrations/build-tool/rstest.mdx @@ -0,0 +1,192 @@ +# Rstest + +`@module-federation/rstest` 可在 [Rstest](https://rstest.rs/) 测试构建中启用 Module Federation。当测试需要加载真实的联邦远程模块,而不是通过 mock 替代联邦边界时,可以使用该插件。 + +## 支持范围 + +- Rstest 的 Node 和 JSDOM 测试环境。 +- Rstest 浏览器模式。 +- URL 远程模块,例如 `remote@http://localhost:3001/remoteEntry.js`。 +- 用于基于 Node 的集成测试的 `commonjs ...` 路径远程模块。 +- 自动为面向 Node 的测试构建启用 Rstest 联邦兼容模式。 + +## 版本要求 + +:::tip + +请使用 `@rstest/core@0.11.4` 或更高版本。Rstest 的联邦支持从 `0.11.4` 开始提供。 + +::: + +早于 `0.11.4` 的 Rstest 版本不包含该插件为 Node 测试构建启用的 `federation` 兼容模式。 + +## 快速开始 + +### 安装 + +import { PackageManagerTabs } from '@theme'; + + + +### 注册插件 + +```ts title='rstest.config.ts' +import { federation } from '@module-federation/rstest'; +import { defineConfig } from '@rstest/core'; + +export default defineConfig({ + plugins: [ + federation({ + name: 'host', + remotes: { + 'component-app': 'component_app@http://localhost:3001/remoteEntry.js', + }, + shared: { + react: { singleton: true }, + 'react-dom': { singleton: true }, + }, + }), + ], +}); +``` + +:::warning + +使用该插件时,请勿在 `rstest.config.ts` 中设置 `federation: true`。对于 Node 和 JSDOM 测试环境,`@module-federation/rstest` 会自动启用 Rstest 的联邦兼容模式。 + +::: + +像往常一样使用 Rstest 运行测试: + +```bash +npx rstest run +``` + +### 复用 Rsbuild 联邦配置 + +如果测试项目已经注册了 `@module-federation/rsbuild-plugin`,调用 `federation()` 时无需传入任何选项: + +```ts title='rstest.config.ts' +import { + createModuleFederationConfig, + pluginModuleFederation, +} from '@module-federation/rsbuild-plugin'; +import { federation } from '@module-federation/rstest'; +import { defineConfig } from '@rstest/core'; + +const options = createModuleFederationConfig({ + name: 'host', + remotes: { + 'component-app': 'component_app@http://localhost:3001/remoteEntry.js', + }, +}); + +export default defineConfig({ + plugins: [ + pluginModuleFederation(options, { + environment: 'rstest', + target: 'node', + }), + federation(), + ], +}); +``` + +插件顺序很重要:请先注册 `pluginModuleFederation`,以便 `federation()` 能规范化同一组带类型的选项。这样只会创建一个 `ModuleFederationPlugin`,并避免重复配置远程模块。 + +## 测试联邦远程模块 + +在 Rstest 的 `globalSetup` 中构建或启动远程模块,然后像应用代码一样导入暴露的模块: + +```ts title='remote.test.ts' +import { expect, it } from '@rstest/core'; +import Button from 'component-app/Button'; + +it('loads a remote through a static import', () => { + expect(Button).toBeDefined(); +}); + +it('loads a remote through a dynamic import', async () => { + const remote = await import('component-app/Button'); + expect(remote.default).toBeDefined(); +}); +``` + +官方的 [Rstest 联邦示例](https://github.com/web-infra-dev/rstest/tree/v0.11.4/examples/federation)组合了一个 HTTP 组件远程模块和一个本地构建的 `commonjs ...` 远程模块,并同时运行 Node 和 JSDOM 项目。 + +## 工作原理 + +对于 Node 和浏览器目标,`dts`、`manifest` 和 `dev` 的默认值都是 `false`;显式设置的值会被保留。 + +### Node 测试默认配置 + +对于面向 Node 的 Rstest 构建,该插件会应用与 Node 运行时相匹配的 Module Federation 默认配置: + +- `target: async-node` +- `experiments.asyncStartup = true` +- CommonJS 容器输出;`module` 和 `modern-module` 库类型会被规范化 +- `@module-federation/node/runtimePlugin` +- 面向 Node 的联邦优化 +- 默认使用脚本远程传输方式 + +标准 URL 远程模块使用 `remoteType: 'script'`。`commonjs ...` 等内联传输前缀会覆盖该默认值。 + +### 浏览器模式 + +Rstest 浏览器模式会根据解析后的 `browser.enabled` 配置自动检测: + +请先安装 `@rstest/browser` 和 Playwright 浏览器。参阅 [Rstest 浏览器模式设置](https://rstest.rs/guide/browser-testing/getting-started)。 + +```ts title='rstest.config.ts' +import { federation } from '@module-federation/rstest'; +import { defineConfig } from '@rstest/core'; + +export default defineConfig({ + browser: { + enabled: true, + provider: 'playwright', + }, + plugins: [ + federation({ + name: 'browser_host', + remotes: { + app2: 'app2@http://localhost:3001/remoteEntry.js', + }, + }), + ], +}); +``` + +浏览器模式在启用 `experiments.asyncStartup` 并应用上述共享默认配置时,会避免使用仅适用于 Node 的默认配置。只有在需要覆盖自动检测到的模式时,才显式传入 `target`。 + +## 配置 + +`federation(options, rstestOptions)` 接受标准的 [Module Federation 配置](/configure/index.html)作为第一个参数。 + +```ts +federation(moduleFederationOptions, { + target: 'node', // or 'browser' +}); +``` + +### moduleFederationOptions + +[Module Federation 配置项](/configure/index.html) + +### rstestOptions + +- 类型:`{ target?: 'node' | 'browser' }` +- 默认值:根据 Rstest 解析后的 `browser.enabled` 配置自动检测。浏览器模式默认为 `'browser'`,其他构建默认为 `'node'`。 + +## 生产者构建 + +请使用 `@module-federation/rsbuild-plugin` 或其他 Module Federation 构建插件来构建远程应用。`@module-federation/rstest` 仅用于消费这些远程模块的 Rstest 测试项目。 + +对于 Rsbuild 生产者,请继续参阅 [Rsbuild 插件指南](/integrations/build-tool/rsbuild.html)。 diff --git a/apps/website-new/docs/zh/plugin/plugins/retry-plugin.mdx b/apps/website-new/docs/zh/plugin/plugins/retry-plugin.mdx index c923c68bb9f..9667737ad74 100644 --- a/apps/website-new/docs/zh/plugin/plugins/retry-plugin.mdx +++ b/apps/website-new/docs/zh/plugin/plugins/retry-plugin.mdx @@ -133,7 +133,9 @@ const mf = createInstance({ **addQuery** - 类型:`boolean | ((context: { times: number; originalQuery: string }) => string)` - 可选 -- 是否在重试时添加查询参数,默认为 false +- 是否在重试时添加查询参数 +- `esm` 和 `module` 类型的远程入口重试默认会添加缓存破坏参数,因为浏览器会按 URL 缓存失败的动态导入。其他重试路径默认不添加查询参数 +- 可设置 `addQuery: false` 关闭 ESM/module 远程入口的默认缓存破坏行为 - 当为函数时,接收重试次数和原始查询字符串,返回新的查询字符串 **fetchOptions** @@ -199,6 +201,16 @@ const retryPlugin = RetryPlugin({ 通过 `addQuery` 参数可以在重试时添加查询参数,避免浏览器缓存影响: +对于 `esm` 或 `module` 类型的远程入口,插件默认添加 `retryCount`,确保每次动态导入重试使用新的 URL。此默认行为不会影响 manifest、fetch 或传统 script 重试。如需关闭: + +```javascript +const retryPlugin = RetryPlugin({ + addQuery: false, +}); +``` + +如需为所有重试路径启用缓存破坏: + ```javascript const retryPlugin = RetryPlugin({ addQuery: true, // 添加 ?retryCount=1, ?retryCount=2 等 diff --git a/apps/website-new/package.json b/apps/website-new/package.json index d4d7022db88..0a54f30395e 100644 --- a/apps/website-new/package.json +++ b/apps/website-new/package.json @@ -1,6 +1,6 @@ { "name": "website-new", - "version": "1.3.29", + "version": "1.3.30", "private": true, "description": "Federation example for website-new", "scripts": { diff --git a/apps/website-new/rspress.config.ts b/apps/website-new/rspress.config.ts index 27a43a687f5..3e576666e90 100644 --- a/apps/website-new/rspress.config.ts +++ b/apps/website-new/rspress.config.ts @@ -14,6 +14,7 @@ const siteIcon = '/svg.svg'; const socialImageUrl = `${siteOrigin}/module-federation-social.svg`; const socialImageAlt = 'Module Federation icon'; const googleAnalyticsMeasurementId = 'G-DRPXW0EEVT'; +const enableZephyr = Boolean(process.env.CI || process.env.ZE_SECRET_TOKEN); export default defineConfig({ root: path.join(__dirname, 'docs'), @@ -86,7 +87,7 @@ export default defineConfig({ // wordsMapPath: 'words-map.json', // }), pluginModuleFederation(mfConfig), - withZephyr(), + ...(enableZephyr ? [withZephyr()] : []), ], builderConfig: { html: { diff --git a/package.json b/package.json index 4bee273fc77..69cdbac4353 100644 --- a/package.json +++ b/package.json @@ -37,6 +37,8 @@ "e2e:treeshake:frontend": "pnpm exec turbo run e2e --filter=@module-federation/treeshake-frontend", "e2e:modern:ssr": "node tools/scripts/run-modern-e2e.mjs --mode=manifest", "e2e:router": "node tools/scripts/run-router-e2e.mjs --mode=dev", + "e2e:bridge:ssr": "node tools/scripts/run-bridge-ssr-e2e.mjs", + "e2e:bridge:ssr:production": "BRIDGE_SSR_MODE=production node tools/scripts/run-bridge-ssr-e2e.mjs", "e2e:shared-tree-shaking:runtime-infer": "npx kill-port --port 3001,3002 && pnpm exec turbo run test:e2e --filter=shared-tree-shaking-no-server-host && lsof -ti tcp:3001,3002 | xargs kill", "e2e:shared-tree-shaking:server-calc": "npx kill-port --port 3001,3002,3003 && pnpm exec turbo run test:e2e --filter=shared-tree-shaking-with-server-host && lsof -ti tcp:3001,3002,3003 | xargs kill", "e2e:devtools:dev": "npx kill-port 3009 3010 3011 3012 3013 4001 && pnpm run app:manifest:dev & echo \"done\" && npx wait-on tcp:3009 tcp:3010 tcp:3011 tcp:3012 tcp:3013 && sleep 10 && pnpm exec turbo run test:e2e --filter=@module-federation/devtools", @@ -177,11 +179,11 @@ "openai": "^6.27.0", "rambda": "11.1.0", "regenerator-runtime": "0.14.1", - "sharp": "^0.34.5", + "sharp": "^0.35.0", "storybook": "8.6.17", "tapable": "2.3.0", "typedoc": "0.28.17", - "undici": "7.28.0", + "undici": "7.29.0", "unplugin": "3.0.0" }, "devDependencies": { @@ -259,7 +261,7 @@ "jest": "29.7.0", "jest-environment-jsdom": "29.7.0", "jiti": "2.6.1", - "js-yaml": "4.3.0", + "js-yaml": "4.3.1", "jsonc-eslint-parser": "^3.1.0", "kill-port": "^2.0.1", "mime-types": "2.1.35", diff --git a/packages/bridge/bridge-react-webpack-plugin/CHANGELOG.md b/packages/bridge/bridge-react-webpack-plugin/CHANGELOG.md index 9650344b019..349cf3c6d62 100644 --- a/packages/bridge/bridge-react-webpack-plugin/CHANGELOG.md +++ b/packages/bridge/bridge-react-webpack-plugin/CHANGELOG.md @@ -1,5 +1,11 @@ # @module-federation/bridge-react-webpack-plugin +## 2.8.2 + +### Patch Changes + +- @module-federation/sdk@2.8.2 + ## 2.8.1 ### Patch Changes diff --git a/packages/bridge/bridge-react-webpack-plugin/__tests__/mockRouterDir/router-v6/react-router-dom/package.json b/packages/bridge/bridge-react-webpack-plugin/__tests__/mockRouterDir/router-v6/react-router-dom/package.json index 70ce8f9714c..cf26d8ff723 100644 --- a/packages/bridge/bridge-react-webpack-plugin/__tests__/mockRouterDir/router-v6/react-router-dom/package.json +++ b/packages/bridge/bridge-react-webpack-plugin/__tests__/mockRouterDir/router-v6/react-router-dom/package.json @@ -23,7 +23,7 @@ "module": "./dist/index.js", "types": "./dist/index.d.ts", "dependencies": { - "@remix-run/router": "1.23.2", + "@remix-run/router": "1.23.3", "react-router": "6.30.4" }, "devDependencies": { diff --git a/packages/bridge/bridge-react-webpack-plugin/package.json b/packages/bridge/bridge-react-webpack-plugin/package.json index 2e8c8877a2a..22bfd842633 100644 --- a/packages/bridge/bridge-react-webpack-plugin/package.json +++ b/packages/bridge/bridge-react-webpack-plugin/package.json @@ -1,6 +1,6 @@ { "name": "@module-federation/bridge-react-webpack-plugin", - "version": "2.8.1", + "version": "2.8.2", "publishConfig": { "access": "public" }, diff --git a/packages/bridge/bridge-react/CHANGELOG.md b/packages/bridge/bridge-react/CHANGELOG.md index 5fea7feebc7..6b8c241d993 100644 --- a/packages/bridge/bridge-react/CHANGELOG.md +++ b/packages/bridge/bridge-react/CHANGELOG.md @@ -1,5 +1,12 @@ # @module-federation/bridge-react +## 2.8.2 + +### Patch Changes + +- 179caf4: Prevent duplicate bridge React stylesheets during hydration while preserving public SSR asset output and stylesheet ownership across document and shadow roots. + - @module-federation/sdk@2.8.2 + ## 2.8.1 ### Patch Changes diff --git a/packages/bridge/bridge-react/README.md b/packages/bridge/bridge-react/README.md index ee86f2bddea..009a4a1cf49 100644 --- a/packages/bridge/bridge-react/README.md +++ b/packages/bridge/bridge-react/README.md @@ -74,6 +74,27 @@ export default defineConfig({ }); ``` +For Bridge SSR V1 with declarative React Router v6, both the browser and Node +builds must alias `react-router-dom$` to the version-specific adapter instead: + +```js +alias: { + 'react-router-dom$': path.resolve( + __dirname, + 'node_modules/@module-federation/bridge-react/dist/router-v6.es.js', + ), +} +``` + +The default `router.es.js` adapter remains the multi-version CSR adapter. React +data routers and `RouterProvider` are not supported for Bridge SSR V1. + +For hydration, serialize only `toBridgeSSRReference(result)` in host JSON. Create +`createBridgeHydrationRegistry(document)` and wrap the host with +`BridgeHydrationProvider` before calling `hydrateRoot`. The full server result is +rendered once into its document slot; the client reference consumes that slot's +validated snapshot once. Missing slots use CSR, while malformed slots throw. + # 3. Load the module with routing ```js diff --git a/packages/bridge/bridge-react/__tests__/HydratedStylesheetAssets.spec.tsx b/packages/bridge/bridge-react/__tests__/HydratedStylesheetAssets.spec.tsx new file mode 100644 index 00000000000..5b625130f7d --- /dev/null +++ b/packages/bridge/bridge-react/__tests__/HydratedStylesheetAssets.spec.tsx @@ -0,0 +1,196 @@ +import { act, render, waitFor } from '@testing-library/react'; +import { hydrateRoot } from 'react-dom/client'; +import { renderToString } from 'react-dom/server'; +import { HydratedStylesheetAssets } from '../src/lazy/HydratedStylesheetAssets'; + +const href = 'https://cdn.example.com/remote.css'; + +describe('HydratedStylesheetAssets', () => { + beforeEach(() => { + document.head.innerHTML = ''; + document.body.innerHTML = ''; + }); + + it('hydrates the server link without a warning before suppressing a head duplicate', async () => { + document.head.innerHTML = ``; + const container = document.createElement('div'); + container.innerHTML = renderToString( + , + ); + document.body.append(container); + expect(container.querySelector('link')?.href).toBe(href); + + const onRecoverableError = jest.fn(); + let root: ReturnType; + await act(async () => { + root = hydrateRoot( + container, + , + { onRecoverableError }, + ); + }); + + await waitFor(() => expect(container.querySelector('link')).toBeNull()); + expect(onRecoverableError).not.toHaveBeenCalled(); + await act(async () => root!.unmount()); + }); + + it('keeps a matching stylesheet inside a shadow root', async () => { + document.head.innerHTML = ``; + const shadowRoot = document.body + .appendChild(document.createElement('div')) + .attachShadow({ mode: 'open' }); + const container = shadowRoot.appendChild(document.createElement('div')); + + render(, { container }); + + await waitFor(() => + expect(container.querySelector('link')?.href).toBe(href), + ); + }); + + it('uses the owner document base URI and ignores the parent document head', async () => { + document.head.innerHTML = ''; + const childDocument = document.implementation.createHTMLDocument('child'); + const base = childDocument.createElement('base'); + base.href = 'https://child.example.com/assets/'; + childDocument.head.append(base); + const container = childDocument.body.appendChild( + childDocument.createElement('div'), + ); + + render(, { + container, + baseElement: childDocument.body, + }); + + await waitFor(() => + expect(container.querySelector('link')?.href).toBe( + 'https://child.example.com/assets/remote.css', + ), + ); + + const childOwner = childDocument.createElement('link'); + childOwner.rel = 'stylesheet'; + childOwner.href = './remote.css'; + childDocument.head.append(childOwner); + await waitFor(() => expect(container.querySelector('link')).toBeNull()); + }); + + it('suppresses a late head owner and restores the link when it is removed', async () => { + const { container, rerender } = render( + , + ); + expect(container.querySelector('link')?.href).toBe(href); + + const owner = document.createElement('link'); + owner.rel = 'stylesheet'; + owner.href = href; + document.head.append(owner); + await waitFor(() => expect(container.querySelector('link')).toBeNull()); + + rerender(); + owner.remove(); + await waitFor(() => + expect(container.querySelector('link')?.href).toBe(href), + ); + }); + + it('keeps observing while another stylesheet group remains mounted', async () => { + const first = render(); + const second = render(); + const owner = document.createElement('link'); + owner.rel = 'stylesheet'; + owner.href = href; + document.head.append(owner); + + await waitFor(() => { + expect(first.container.querySelector('link')).toBeNull(); + expect(second.container.querySelector('link')).toBeNull(); + }); + + first.unmount(); + owner.remove(); + await waitFor(() => + expect(second.container.querySelector('link')?.href).toBe(href), + ); + }); + + it('disconnects the document observer after the last group unmounts', async () => { + const disconnect = jest.spyOn(MutationObserver.prototype, 'disconnect'); + try { + const rendered = render(); + await waitFor(() => + expect(rendered.container.querySelector('link')?.href).toBe(href), + ); + const callsBeforeUnmount = disconnect.mock.calls.length; + + rendered.unmount(); + + expect(disconnect.mock.calls.length).toBeGreaterThan(callsBeforeUnmount); + } finally { + disconnect.mockRestore(); + } + }); + + it('tracks changes that make a head owner active or inactive', async () => { + const owner = document.createElement('link'); + owner.rel = 'stylesheet'; + owner.href = href; + document.head.append(owner); + const { container } = render(); + await waitFor(() => expect(container.querySelector('link')).toBeNull()); + + owner.setAttribute('disabled', ''); + await waitFor(() => + expect(container.querySelector('link')?.href).toBe(href), + ); + + owner.removeAttribute('disabled'); + await waitFor(() => expect(container.querySelector('link')).toBeNull()); + + owner.type = 'text/plain'; + await waitFor(() => + expect(container.querySelector('link')?.href).toBe(href), + ); + + owner.type = 'text/css; charset=utf-8'; + await waitFor(() => expect(container.querySelector('link')).toBeNull()); + + owner.href = 'https://cdn.example.com/other.css'; + await waitFor(() => + expect(container.querySelector('link')?.href).toBe(href), + ); + }); + + it.each<[string, (link: HTMLLinkElement) => void]>([ + ['unsupported type', (link) => (link.type = 'text/plain')], + ['non-screen media', (link) => (link.media = 'print')], + ['alternate relation', (link) => (link.rel = 'alternate stylesheet')], + ['disabled state', (link) => link.setAttribute('disabled', '')], + ])('keeps the link for an inactive head stylesheet: %s', async (_, setup) => { + const inactive = document.createElement('link'); + inactive.rel = 'stylesheet'; + inactive.href = href; + setup(inactive); + document.head.append(inactive); + + const { container } = render(); + + await waitFor(() => + expect(container.querySelector('link')?.href).toBe(href), + ); + }); + + it('keeps React-owned stylesheets rendered in the document head', async () => { + render(, { + container: document.head, + }); + + await waitFor(() => + expect( + document.head.querySelectorAll(`link[href="${href}"]`), + ).toHaveLength(1), + ); + }); +}); diff --git a/packages/bridge/bridge-react/__tests__/createLazyComponent.spec.tsx b/packages/bridge/bridge-react/__tests__/createLazyComponent.spec.tsx index 9969cd0ca08..c351e3c2740 100644 --- a/packages/bridge/bridge-react/__tests__/createLazyComponent.spec.tsx +++ b/packages/bridge/bridge-react/__tests__/createLazyComponent.spec.tsx @@ -1,5 +1,6 @@ import React, { Suspense } from 'react'; import { render, screen, waitFor } from '@testing-library/react'; +import { renderToString } from 'react-dom/server'; import { createLazyComponent, collectSSRAssets, @@ -20,6 +21,22 @@ const MockComponent = () =>
    Mock Component
    ; const LoadingComponent = () =>
    Loading...
    ; const ErrorComponent = () =>
    Error!
    ; +const renderAssetsToFragment = (assets: React.ReactNode[]) => { + const template = document.createElement('template'); + template.innerHTML = renderToString(<>{assets}); + return template.content; +}; + +const getStylesheetHrefs = (root: ParentNode) => + Array.from( + root.querySelectorAll('link[rel="stylesheet"]'), + ).map((link) => link.href); + +const getScriptSrcs = (root: ParentNode) => + Array.from(root.querySelectorAll('script[src]')).map( + (script) => script.src, + ); + describe('createLazyComponent', () => { let mockInstance: any; @@ -124,13 +141,96 @@ describe('createLazyComponent', () => { ).toBeInTheDocument(); }); }); + + it('should suppress automatic SSR stylesheets owned by document head', async () => { + const previousFederationSSR = globalThis.FEDERATION_SSR; + let unmount: (() => void) | undefined; + globalThis.FEDERATION_SSR = true; + document.head.innerHTML = + ''; + mockFetchData.mockResolvedValue(undefined); + mockGetLoadedRemoteInfos.mockReturnValue({ + name: 'remoteApp', + alias: 'remote', + expose: './Component', + version: '1.0.0', + snapshot: { + modules: [ + { + modulePath: './Component', + assets: { + css: { sync: ['main.css'], async: [] }, + js: { sync: [], async: [] }, + }, + }, + ], + publicPath: 'http://localhost:3001/', + remoteEntry: 'remoteEntry.js', + }, + entryGlobalName: 'remoteApp', + }); + + const LazyComponent = createLazyComponent({ + loader: jest.fn().mockResolvedValue({ + default: MockComponent, + [Symbol.for('mf_module_id')]: 'remoteApp/Component', + }), + instance: mockInstance, + loading: , + fallback: , + injectLink: true, + }); + + try { + const rendered = render(); + const { container } = rendered; + unmount = rendered.unmount; + + await waitFor(() => { + expect(screen.getByText('Mock Component')).toBeInTheDocument(); + expect(getStylesheetHrefs(container)).toEqual([]); + }); + expect(getStylesheetHrefs(document.head)).toEqual([ + 'http://localhost:3001/main.css', + ]); + } finally { + unmount?.(); + globalThis.FEDERATION_SSR = previousFederationSSR; + document.head.innerHTML = ''; + } + }); }); describe('collectSSRAssets', () => { let mockInstance: any; + const mockRemoteAssets = ({ + css, + js = { sync: [], async: [] }, + }: { + css: { sync: string[]; async: string[] }; + js?: { sync: string[]; async: string[] }; + }) => { + mockGetLoadedRemoteInfos.mockReturnValue({ + name: 'remoteApp', + expose: './Component', + snapshot: { + publicPath: 'http://localhost:3001/', + remoteEntry: 'remoteEntry.js', + modules: [ + { + modulePath: './Component', + assets: { css, js }, + }, + ], + }, + }); + }; + beforeEach(() => { jest.clearAllMocks(); + document.head.innerHTML = ''; + document.body.innerHTML = ''; mockInstance = { name: 'host-app', options: { version: '1.0.0' }, @@ -156,22 +256,12 @@ describe('collectSSRAssets', () => { }); it('should collect CSS and JS assets for SSR', () => { - mockGetLoadedRemoteInfos.mockReturnValue({ - name: 'remoteApp', - expose: './Component', - snapshot: { - publicPath: 'http://localhost:3001/', - remoteEntry: 'remoteEntry.js', - modules: [ - { - modulePath: './Component', - assets: { - css: { sync: ['main.css'], async: ['extra.css'] }, - js: { sync: ['main.js'], async: [] }, - }, - }, - ], + mockRemoteAssets({ + css: { + sync: ['main.css'], + async: ['extra.css', 'main.css'], }, + js: { sync: ['main.js'], async: [] }, }); const assets = collectSSRAssets({ @@ -184,26 +274,63 @@ describe('collectSSRAssets', () => { expect(assets).toHaveLength(4); // 2 links, 2 scripts const links = assets.filter( - (asset) => (asset as React.ReactElement).type === 'link', + (asset): asset is React.ReactElement => + React.isValidElement(asset) && asset.type === 'link', ); const scripts = assets.filter( - (asset) => (asset as React.ReactElement).type === 'script', + (asset): asset is React.ReactElement => + React.isValidElement(asset) && asset.type === 'script', ); expect(links).toHaveLength(2); - expect((links[0] as React.ReactElement).props.href).toBe( + expect(links.map((link) => link.props.href)).toEqual([ 'http://localhost:3001/extra.css', - ); - expect((links[1] as React.ReactElement).props.href).toBe( 'http://localhost:3001/main.css', - ); - + ]); expect(scripts).toHaveLength(2); - expect((scripts[0] as React.ReactElement).props.src).toBe( + expect(scripts.map((script) => script.props.src)).toEqual([ 'http://localhost:3001/remoteEntry.js', - ); - expect((scripts[1] as React.ReactElement).props.src).toBe( 'http://localhost:3001/main.js', + ]); + + const fragment = renderAssetsToFragment(assets); + + expect(getStylesheetHrefs(fragment)).toEqual([ + 'http://localhost:3001/extra.css', + 'http://localhost:3001/main.css', + ]); + expect(getScriptSrcs(fragment)).toEqual([ + 'http://localhost:3001/remoteEntry.js', + 'http://localhost:3001/main.js', + ]); + }); + + it('should preserve stylesheet nodes when inserting an earlier asset', () => { + mockRemoteAssets({ + css: { sync: ['main.css'], async: [] }, + }); + + const options = { + id: 'remoteApp/Component', + instance: mockInstance, + injectLink: true, + }; + const { container, rerender } = render(<>{collectSSRAssets(options)}); + const originalMainLink = container.querySelector( + 'link[href="http://localhost:3001/main.css"]', ); + + mockRemoteAssets({ + css: { sync: ['main.css'], async: ['extra.css'] }, + }); + rerender(<>{collectSSRAssets(options)}); + + expect(getStylesheetHrefs(container)).toEqual([ + 'http://localhost:3001/extra.css', + 'http://localhost:3001/main.css', + ]); + expect( + container.querySelector('link[href="http://localhost:3001/main.css"]'), + ).toBe(originalMainLink); }); }); diff --git a/packages/bridge/bridge-react/__tests__/router.spec.tsx b/packages/bridge/bridge-react/__tests__/router.spec.tsx index 3892a863965..95f1cbfd0f0 100644 --- a/packages/bridge/bridge-react/__tests__/router.spec.tsx +++ b/packages/bridge/bridge-react/__tests__/router.spec.tsx @@ -13,6 +13,18 @@ import { RouterContext } from '../src/provider/context'; import { getHtml, getWindowImpl } from './util'; describe('react router proxy', () => { + it('fails clearly when the generic router adapter is used for SSR', () => { + expect(() => + render( + + +
    + + , + ), + ).toThrow(/router-v6 alias/); + }); + it('BrowserRouter not wraper context', async () => { let { container } = render( diff --git a/packages/bridge/bridge-react/package.json b/packages/bridge/bridge-react/package.json index a2d35d35dc6..0ab31d075ec 100644 --- a/packages/bridge/bridge-react/package.json +++ b/packages/bridge/bridge-react/package.json @@ -1,6 +1,6 @@ { "name": "@module-federation/bridge-react", - "version": "2.8.1", + "version": "2.8.2", "sideEffects": false, "publishConfig": { "access": "public" @@ -32,6 +32,14 @@ }, "./v18": { "types": "./dist/v18.d.ts", + "browser": { + "import": "./dist/v18.es.js", + "require": "./dist/v18.cjs.js" + }, + "node": { + "import": "./dist/v18.server.es.js", + "require": "./dist/v18.server.cjs.js" + }, "import": "./dist/v18.es.js", "require": "./dist/v18.cjs.js" }, @@ -184,11 +192,12 @@ "react": "18.3.1", "react-dom": "18.3.1", "react-router-dom": "6.30.3", - "react-router": "7.16.0", + "react-router": "7.18.2", "typescript": "7.0.2", "vite": "^5.4.21", "@module-federation/runtime": "workspace:*", "@module-federation/runtime-core": "workspace:*", - "hono": "4.12.26" + "hono": "4.12.34", + "jsdom": "20.0.3" } } diff --git a/packages/bridge/bridge-react/rstest.config.ts b/packages/bridge/bridge-react/rstest.config.ts index 1faeba420d4..31ea5beea0f 100644 --- a/packages/bridge/bridge-react/rstest.config.ts +++ b/packages/bridge/bridge-react/rstest.config.ts @@ -17,10 +17,7 @@ export default defineConfig({ extensions: ['.mjs', '.js', '.ts', '.jsx', '.tsx', '.json'], }, testEnvironment: 'jsdom', - include: [ - path.resolve(__dirname, 'src/**/*.spec.ts'), - path.resolve(__dirname, 'src/**/*.spec.tsx'), - ], + include: ['src/**/*.spec.ts', 'src/**/*.spec.tsx'], globals: true, testTimeout: 10000, setupFiles: [path.resolve(__dirname, '__tests__/setupTests.ts')], diff --git a/packages/bridge/bridge-react/src/hydration.spec.tsx b/packages/bridge/bridge-react/src/hydration.spec.tsx new file mode 100644 index 00000000000..d12b97a35a4 --- /dev/null +++ b/packages/bridge/bridge-react/src/hydration.spec.tsx @@ -0,0 +1,147 @@ +import React from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { describe, expect, it } from '@rstest/core'; +import { + createBridgeHydrationRegistry, + toBridgeSSRReference, +} from '@module-federation/bridge-shared'; +import { BridgeRemoteSlot } from './hydration'; + +const result = { + protocolVersion: 1 as const, + moduleName: 'remote/app', + instanceId: 'remote-1', + html: '

    server remote

    ', + dehydratedState: { ready: true }, +}; + +function installSlot() { + document.body.innerHTML = renderToStaticMarkup( + , + ); +} + +describe('Bridge hydration registry', () => { + it('recovers and consumes one immutable instance snapshot', () => { + installSlot(); + const registry = createBridgeHydrationRegistry(document); + const snapshot = registry.peek('remote/app', 'remote-1'); + + expect(snapshot).toEqual({ + protocolVersion: 1, + moduleName: 'remote/app', + instanceId: 'remote-1', + html: '

    server remote

    ', + state: { ready: true }, + }); + expect(Object.isFrozen(snapshot)).toBe(true); + expect(Object.isFrozen(snapshot?.state)).toBe(true); + expect(registry.consume('remote/app', 'remote-1')).toBe(snapshot); + expect(registry.peek('remote/app', 'remote-1')).toBeUndefined(); + }); + + it('renders a client reference from the recovered DOM without another payload copy', () => { + installSlot(); + const registry = createBridgeHydrationRegistry(document); + const snapshot = registry.peek('remote/app', 'remote-1'); + const reference = toBridgeSSRReference(result); + const clientMarkup = renderToStaticMarkup( + , + ); + + expect(clientMarkup).toBe(document.body.innerHTML); + expect(JSON.stringify(reference)).not.toContain('server remote'); + }); + + it('treats an absent slot as missing SSR data', () => { + document.body.innerHTML = ''; + expect( + createBridgeHydrationRegistry(document).peek('remote/app', 'remote-1'), + ).toBeUndefined(); + }); + + it('rejects malformed, mismatched, and duplicate slots', () => { + installSlot(); + const original = document.body.innerHTML; + document.body.innerHTML = original.replace( + '"moduleName":"remote/app"', + '"moduleName":"other/app"', + ); + expect(() => + createBridgeHydrationRegistry(document).peek('remote/app', 'remote-1'), + ).toThrow(/state metadata does not match/); + + document.body.innerHTML = original + original; + expect(() => + createBridgeHydrationRegistry(document).peek('remote/app', 'remote-1'), + ).toThrow(/Duplicate Bridge SSR identity/); + + document.body.innerHTML = original; + expect( + createBridgeHydrationRegistry(document).peek('other/app', 'remote-1'), + ).toBeUndefined(); + + document.body.innerHTML = original.replace( + '
    ', + 'unexpected', + ); + expect(() => + createBridgeHydrationRegistry(document).peek('remote/app', 'remote-1'), + ).toThrow(/only its direct mount and state children/); + + document.body.innerHTML = original.replace( + '"state":', + '"html":"duplicate","state":', + ); + expect(() => + 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

    ', + ); + }); + + it('fail clears a peeked snapshot so later peeks miss', () => { + installSlot(); + const registry = createBridgeHydrationRegistry(document); + expect(registry.peek('remote/app', 'remote-1')).toBeDefined(); + registry.fail('remote/app', 'remote-1'); + expect(registry.peek('remote/app', 'remote-1')).toBeUndefined(); + expect(registry.consume('remote/app', 'remote-1')).toBeUndefined(); + }); +}); diff --git a/packages/bridge/bridge-react/src/hydration.tsx b/packages/bridge/bridge-react/src/hydration.tsx new file mode 100644 index 00000000000..b131dfe162f --- /dev/null +++ b/packages/bridge/bridge-react/src/hydration.tsx @@ -0,0 +1,83 @@ +import React from 'react'; +import { + BRIDGE_SSR_PROTOCOL_VERSION, + MF_BRIDGE_STATE_ATTR, + getBridgeSSRContainerAttrs, + getBridgeSSRSlotAttrs, + serializeBridgeSSRStateEnvelope, + type BridgeHydrationRegistry, + type BridgeHydrationSnapshot, + type BridgeSSRResult, +} from '@module-federation/bridge-shared'; +import { getRootDomDefaultClassName } from './utils'; + +const BridgeHydrationContext = React.createContext< + BridgeHydrationRegistry | undefined +>(undefined); + +export function BridgeHydrationProvider({ + registry, + children, +}: { + registry: BridgeHydrationRegistry; + children: React.ReactNode; +}) { + return ( + + {children} + + ); +} + +export function useBridgeHydrationRegistry() { + return React.useContext(BridgeHydrationContext); +} + +export function BridgeRemoteSlot({ + moduleName, + instanceId, + payload, + snapshot, + className, + style, + mountRef, +}: { + moduleName: string; + instanceId: string; + payload?: BridgeSSRResult; + snapshot?: BridgeHydrationSnapshot; + className?: string; + style?: React.CSSProperties; + mountRef?: React.Ref; +}) { + const html = payload?.html ?? snapshot?.html ?? ''; + const state = payload?.dehydratedState ?? snapshot?.state; + const rootClassName = + `${getRootDomDefaultClassName(moduleName)} ${className || ''}`.trim(); + + return ( +
    +
    + \u2028\u2029' })).toBe( + '{"value":"\\u003c/script>\\u2028\\u2029"}', + ); + }); + + it('rejects cyclic and non-JSON state', () => { + const cyclic: Record = {}; + cyclic.self = cyclic; + expect(() => assertBridgeJSONValue(cyclic)).toThrow(/cyclic/); + 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', () => { + const result = { + protocolVersion: BRIDGE_SSR_PROTOCOL_VERSION, + moduleName: 'remote/app', + instanceId: 'remote-1', + html: '

    remote

    ', + } as const; + + expect( + getMatchingBridgeSSRResult(result, { + moduleName: 'remote/app', + instanceId: 'remote-1', + }), + ).toBe(result); + expect( + getMatchingBridgeSSRResult(result, { moduleName: 'other/app' }), + ).toBeUndefined(); + expect(() => + getMatchingBridgeSSRResult( + { ...result, protocolVersion: 2 }, + { moduleName: 'remote/app' }, + ), + ).toThrow(/incompatible result/); + }); + + it('creates an identity-only client reference', () => { + const result = { + protocolVersion: BRIDGE_SSR_PROTOCOL_VERSION, + moduleName: 'remote/app', + instanceId: 'remote-1', + html: '

    remote

    ', + dehydratedState: { ready: true }, + } as const; + const reference = toBridgeSSRReference(result); + + expect(reference).toEqual({ + protocolVersion: BRIDGE_SSR_PROTOCOL_VERSION, + moduleName: 'remote/app', + instanceId: 'remote-1', + }); + expect(reference).not.toHaveProperty('html'); + expect(reference).not.toHaveProperty('dehydratedState'); + expect(Object.isFrozen(reference)).toBe(true); + expect( + getMatchingBridgeSSRPayload(reference, { + moduleName: 'remote/app', + instanceId: 'remote-1', + }), + ).toBe(reference); + }); + + it('serializes state separately from remote HTML', () => { + expect( + serializeBridgeSSRStateEnvelope({ + protocolVersion: BRIDGE_SSR_PROTOCOL_VERSION, + moduleName: 'remote/app', + instanceId: 'remote-1', + state: { value: '\u2028\u2029' }, + }), + ).toBe( + '{"protocolVersion":1,"moduleName":"remote/app","instanceId":"remote-1","state":{"value":"\\u003c/script>\\u2028\\u2029"}}', + ); + expect(() => + serializeBridgeSSRStateEnvelope({ + protocolVersion: BRIDGE_SSR_PROTOCOL_VERSION, + moduleName: 'remote/app', + instanceId: 'remote-1', + html: '

    must not be serialized

    ', + } as any), + ).toThrow(/state envelope is incompatible/); + }); + + it('loads, renders, and validates a remote provider', async () => { + const renderServer = vi.fn(async (context) => ({ + protocolVersion: BRIDGE_SSR_PROTOCOL_VERSION, + moduleName: context.moduleName, + instanceId: context.instanceId, + html: '

    remote

    ', + dehydratedState: { ready: true }, + })); + const request = new Request('http://bridge.test/detail'); + await expect( + renderRemoteBridge({ + loader: async () => ({ default: () => ({ renderServer }) }), + moduleName: 'remote/app', + instanceId: 'remote-1', + request, + props: { intentional: true }, + }), + ).resolves.toMatchObject({ html: '

    remote

    ' }); + expect(renderServer).toHaveBeenCalledWith( + expect.objectContaining({ request, signal: request.signal }), + ); + }); + + it('adds context to load and contract failures', async () => { + await expect( + renderRemoteBridge({ + loader: async () => { + throw new Error('network'); + }, + moduleName: 'remote/app', + instanceId: 'remote-1', + request: new Request('http://bridge.test/'), + }), + ).rejects.toEqual( + expect.objectContaining({ + name: 'BridgeSSRError', + message: 'Unable to load Bridge remote remote/app', + }), + ); + + await expect( + renderRemoteBridge({ + loader: async () => ({ default: () => ({}) }), + moduleName: 'remote/app', + instanceId: 'remote-1', + request: new Request('http://bridge.test/'), + }), + ).rejects.toBeInstanceOf(BridgeSSRError); + }); +}); diff --git a/packages/bridge/bridge-shared/src/ssr.ts b/packages/bridge/bridge-shared/src/ssr.ts new file mode 100644 index 00000000000..10aca41c15a --- /dev/null +++ b/packages/bridge/bridge-shared/src/ssr.ts @@ -0,0 +1,500 @@ +import { + BRIDGE_SSR_PROTOCOL_VERSION, + BridgeSSRError, + type BridgeHydrationRegistry, + type BridgeHydrationSnapshot, + type BridgeJSONValue, + type BridgeSSRReference, + type BridgeSSRResult, + type BridgeSSRStateEnvelope, +} from './type'; + +export const MF_BRIDGE_SSR_ATTR = 'data-mf-bridge-ssr'; +export const MF_BRIDGE_VERSION_ATTR = 'data-mf-bridge-version'; +export const MF_BRIDGE_MODULE_ATTR = 'data-mf-bridge-module'; +export const MF_BRIDGE_INSTANCE_ATTR = 'data-mf-bridge-instance'; +export const MF_BRIDGE_SLOT_ATTR = 'data-mf-bridge-slot'; +export const MF_BRIDGE_MOUNT_ATTR = 'data-mf-bridge-mount'; +export const MF_BRIDGE_STATE_ATTR = 'data-mf-bridge-state'; + +function assertPrintable(value: string, label: string) { + if (!value || /[\u0000-\u001f\u007f]/.test(value)) { + throw new BridgeSSRError( + `Bridge SSR ${label} must be a non-empty printable string`, + ); + } +} + +export function assertBridgeSSRIdentity(value: { + moduleName: string; + instanceId: string; +}) { + assertPrintable(value.moduleName, 'moduleName'); + assertPrintable(value.instanceId, 'instanceId'); +} + +function validateJSON( + value: unknown, + path: string, + ancestors: WeakSet, +): void { + if ( + value === null || + typeof value === 'string' || + typeof value === 'boolean' + ) { + return; + } + if (typeof value === 'number') { + if (Number.isFinite(value)) return; + throw new BridgeSSRError(`Bridge SSR value at ${path} must be finite`); + } + if (typeof value !== 'object' || value === undefined) { + throw new BridgeSSRError( + `Bridge SSR value at ${path} must be JSON serializable`, + ); + } + if (ancestors.has(value)) { + throw new BridgeSSRError(`Bridge SSR value at ${path} must not be cyclic`); + } + ancestors.add(value); + if (Array.isArray(value)) { + for (let index = 0; index < value.length; index += 1) { + if (!Object.prototype.hasOwnProperty.call(value, index)) { + throw new BridgeSSRError( + `Bridge SSR array at ${path} must not be sparse`, + ); + } + validateJSON(value[index], `${path}[${index}]`, ancestors); + } + } else { + const prototype = Object.getPrototypeOf(value); + if (prototype !== Object.prototype && prototype !== null) { + throw new BridgeSSRError( + `Bridge SSR value at ${path} must use plain objects`, + ); + } + 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') + ) { + throw new BridgeSSRError( + `Bridge SSR value at ${path}.${key} must be an enumerable data property`, + ); + } + validateJSON(descriptor.value, `${path}.${key}`, ancestors); + } + if (Object.getOwnPropertySymbols(value).length > 0) { + throw new BridgeSSRError( + `Bridge SSR value at ${path} must not contain symbol keys`, + ); + } + } + ancestors.delete(value); +} + +export function assertBridgeJSONValue( + value: unknown, +): asserts value is BridgeJSONValue { + validateJSON(value, 'state', new WeakSet()); +} + +export function serializeBridgeJSON(value: BridgeJSONValue): string { + assertBridgeJSONValue(value); + return JSON.stringify(value) + .replace(/; + if ( + Object.keys(reference).some( + (key) => + key !== 'protocolVersion' && + key !== 'moduleName' && + key !== 'instanceId', + ) || + reference.protocolVersion !== BRIDGE_SSR_PROTOCOL_VERSION || + typeof reference.moduleName !== 'string' || + typeof reference.instanceId !== 'string' + ) { + throw new BridgeSSRError('Bridge SSR reference is incompatible'); + } + assertBridgeSSRIdentity({ + moduleName: reference.moduleName, + instanceId: reference.instanceId, + }); +} + +export function serializeBridgeSSRStateEnvelope( + envelope: BridgeSSRStateEnvelope, +): string { + const allowedKeys = new Set([ + 'protocolVersion', + 'moduleName', + 'instanceId', + 'state', + ]); + if ( + !envelope || + typeof envelope !== 'object' || + Array.isArray(envelope) || + Object.keys(envelope).some((key) => !allowedKeys.has(key)) + ) { + throw new BridgeSSRError('Bridge SSR state envelope is incompatible'); + } + assertBridgeSSRReference({ + protocolVersion: envelope.protocolVersion, + moduleName: envelope.moduleName, + instanceId: envelope.instanceId, + }); + if (envelope.state !== undefined) assertBridgeJSONValue(envelope.state); + return JSON.stringify(envelope) + .replace(/; + const allowedKeys = new Set([ + 'protocolVersion', + 'moduleName', + 'instanceId', + 'html', + 'dehydratedState', + ]); + if (Object.keys(result).some((key) => !allowedKeys.has(key))) { + throw new BridgeSSRError('Bridge SSR returned unsupported result fields'); + } + if ( + result.protocolVersion !== BRIDGE_SSR_PROTOCOL_VERSION || + typeof result.moduleName !== 'string' || + typeof result.instanceId !== 'string' || + typeof result.html !== 'string' + ) { + throw new BridgeSSRError('Bridge SSR returned an incompatible result'); + } + assertBridgeSSRIdentity({ + moduleName: result.moduleName, + instanceId: result.instanceId, + }); + if (result.dehydratedState !== undefined) { + assertBridgeJSONValue(result.dehydratedState); + } +} + +export function getMatchingBridgeSSRResult( + value: unknown, + expected: { moduleName?: string; instanceId?: string }, +): BridgeSSRResult | undefined { + if (value === undefined) return undefined; + assertBridgeSSRResult(value); + if ( + (expected.moduleName !== undefined && + value.moduleName !== expected.moduleName) || + (expected.instanceId !== undefined && + value.instanceId !== expected.instanceId) + ) { + return undefined; + } + return value; +} + +export function getMatchingBridgeSSRPayload( + value: unknown, + expected: { moduleName?: string; instanceId?: string }, +): BridgeSSRResult | BridgeSSRReference | undefined { + if (value === undefined) return undefined; + if (value && typeof value === 'object' && 'html' in value) { + assertBridgeSSRResult(value); + } else { + assertBridgeSSRReference(value); + } + if ( + (expected.moduleName !== undefined && + value.moduleName !== expected.moduleName) || + (expected.instanceId !== undefined && + value.instanceId !== expected.instanceId) + ) { + return undefined; + } + return value; +} + +export function getBridgeSSRContainerAttrs(value: { + moduleName: string; + instanceId: string; +}): Record { + assertBridgeSSRIdentity(value); + return { + [MF_BRIDGE_SSR_ATTR]: 'true', + [MF_BRIDGE_MOUNT_ATTR]: 'true', + [MF_BRIDGE_VERSION_ATTR]: String(BRIDGE_SSR_PROTOCOL_VERSION), + [MF_BRIDGE_MODULE_ATTR]: value.moduleName, + [MF_BRIDGE_INSTANCE_ATTR]: value.instanceId, + }; +} + +export function getBridgeSSRSlotAttrs(value: { + moduleName: string; + instanceId: string; +}): Record { + assertBridgeSSRIdentity(value); + return { + [MF_BRIDGE_SLOT_ATTR]: 'true', + [MF_BRIDGE_VERSION_ATTR]: String(BRIDGE_SSR_PROTOCOL_VERSION), + [MF_BRIDGE_MODULE_ATTR]: value.moduleName, + [MF_BRIDGE_INSTANCE_ATTR]: value.instanceId, + }; +} + +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) + ); +} + +function directChildrenWithAttribute( + parent: Element, + attribute: string, +): T[] { + return Array.from(parent.children).filter( + (child) => child.getAttribute(attribute) === 'true', + ) as T[]; +} + +function freezeJSONValue( + value: BridgeJSONValue | undefined, +): BridgeJSONValue | undefined { + if (!value || typeof value !== 'object' || Object.isFrozen(value)) { + return value; + } + if (Array.isArray(value)) value.forEach((item) => freezeJSONValue(item)); + else Object.values(value).forEach((item) => freezeJSONValue(item)); + Object.freeze(value); + return value; +} + +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 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 (versionAttr !== String(BRIDGE_SSR_PROTOCOL_VERSION)) { + throw hydrationError( + `Bridge SSR slot ${moduleName}:${instanceId} uses unsupported protocol version ${versionAttr}`, + ); + } + const protocolVersion = BRIDGE_SSR_PROTOCOL_VERSION; + + const mounts = directChildrenWithAttribute( + slot, + MF_BRIDGE_MOUNT_ATTR, + ); + const scripts = directChildrenWithAttribute( + slot, + MF_BRIDGE_STATE_ATTR, + ); + if (mounts.length !== 1 || scripts.length !== 1) { + throw hydrationError( + `Bridge SSR slot ${instanceId} must contain exactly one direct mount and state child`, + ); + } + + const [mount] = mounts; + const [script] = scripts; + if ( + slot.children.length !== 2 || + slot.children[0] !== mount || + slot.children[1] !== script || + script.tagName !== 'SCRIPT' + ) { + throw hydrationError( + `Bridge SSR slot ${instanceId} must contain only its direct mount and state children in order`, + ); + } + if ( + mount.getAttribute(MF_BRIDGE_MODULE_ATTR) !== moduleName || + mount.getAttribute(MF_BRIDGE_INSTANCE_ATTR) !== instanceId || + mount.getAttribute(MF_BRIDGE_VERSION_ATTR) !== String(protocolVersion) + ) { + throw hydrationError( + `Bridge SSR slot ${instanceId} mount metadata does not match its slot`, + ); + } + if (script.type !== 'application/json' || !script.textContent) { + throw hydrationError( + `Bridge SSR slot ${instanceId} has an invalid state envelope`, + ); + } + + let envelope: BridgeSSRStateEnvelope; + try { + envelope = JSON.parse(script.textContent) as BridgeSSRStateEnvelope; + } catch (error) { + throw hydrationError( + `Bridge SSR slot ${instanceId} state envelope is not valid JSON`, + error, + ); + } + const envelopeKeys = Object.keys(envelope); + if ( + !envelope || + typeof envelope !== 'object' || + Array.isArray(envelope) || + envelopeKeys.some( + (key) => + key !== 'protocolVersion' && + key !== 'moduleName' && + key !== 'instanceId' && + key !== 'state', + ) + ) { + throw hydrationError( + `Bridge SSR slot ${instanceId} has an incompatible state envelope`, + ); + } + if ( + envelope.protocolVersion !== protocolVersion || + envelope.moduleName !== moduleName || + envelope.instanceId !== instanceId + ) { + throw hydrationError( + `Bridge SSR slot ${instanceId} state metadata does not match its slot`, + ); + } + if (envelope.state !== undefined) assertBridgeJSONValue(envelope.state); + + return Object.freeze({ + protocolVersion: BRIDGE_SSR_PROTOCOL_VERSION, + moduleName, + instanceId, + html: mount.innerHTML, + state: freezeJSONValue(envelope.state), + }); +} + +export function createBridgeHydrationRegistry( + root?: ParentNode, +): BridgeHydrationRegistry { + const hydrationRoot = + root ?? (typeof document === 'undefined' ? undefined : document); + if (!hydrationRoot) { + throw hydrationError( + 'createBridgeHydrationRegistry requires a document root', + ); + } + const snapshots = new Map(); + const errors = new Map(); + + for (const slot of Array.from( + hydrationRoot.querySelectorAll( + `[${MF_BRIDGE_SLOT_ATTR}="true"]`, + ), + )) { + const moduleName = slot.getAttribute(MF_BRIDGE_MODULE_ATTR) || ''; + const instanceId = slot.getAttribute(MF_BRIDGE_INSTANCE_ATTR) || ''; + const key = hydrationIdentityKey(moduleName, instanceId); + if (snapshots.has(key) || errors.has(key)) { + snapshots.delete(key); + errors.set( + key, + hydrationError( + `Duplicate Bridge SSR identity ${moduleName}:${instanceId}`, + ), + ); + continue; + } + try { + const snapshot = readSlotSnapshot(slot); + snapshots.set( + hydrationIdentityKey(snapshot.moduleName, snapshot.instanceId), + snapshot, + ); + } catch (error) { + errors.set( + key, + error instanceof BridgeSSRError + ? error + : hydrationError( + `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, + consume(moduleName, instanceId) { + const key = hydrationIdentityKey(moduleName, instanceId); + const snapshot = peek(moduleName, instanceId); + snapshots.delete(key); + errors.delete(key); + return snapshot; + }, + fail(moduleName, 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 e788d81a6ac..4e1c86874ea 100644 --- a/packages/bridge/bridge-shared/src/type.ts +++ b/packages/bridge/bridge-shared/src/type.ts @@ -1,5 +1,88 @@ import type { CSSProperties } from 'react'; +export const BRIDGE_SSR_PROTOCOL_VERSION = 1 as const; + +export type BridgeJSONValue = + | null + | boolean + | number + | string + | BridgeJSONValue[] + | { [key: string]: BridgeJSONValue }; + +export type BridgeSSRPrepareResult

    > = { + props?: P; + dehydratedState?: BridgeJSONValue; +}; + +export type BridgeSSRConfig

    > = + | boolean + | { + prepare?: ( + context: BridgeServerRenderContext

    , + ) => + | void + | BridgeSSRPrepareResult

    + | Promise>; + hydrate?: ( + state: BridgeJSONValue | undefined, + ) => Partial

    | Record; + }; + +export type BridgeServerRenderContext

    > = { + moduleName: string; + instanceId: string; + request: Request; + signal: AbortSignal; + props: P; +}; + +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; +}; + +/** Identity-only payload safe to serialize into a host hydration context. */ +export type BridgeSSRReference = Pick< + BridgeSSRResult, + 'protocolVersion' | 'moduleName' | 'instanceId' +>; + +export type BridgeSSRStateEnvelope = BridgeSSRReference & { + state?: BridgeJSONValue; +}; + +export type BridgeHydrationSnapshot = BridgeSSRReference & { + html: string; + state?: BridgeJSONValue; +}; + +export interface BridgeHydrationRegistry { + peek( + moduleName: string, + instanceId: string, + ): BridgeHydrationSnapshot | undefined; + consume( + moduleName: string, + instanceId: string, + ): BridgeHydrationSnapshot | undefined; + fail(moduleName: string, instanceId: string): void; +} + +export class BridgeSSRError extends Error { + override readonly cause?: unknown; + + constructor(message: string, cause?: unknown) { + super(message); + this.name = 'BridgeSSRError'; + this.cause = cause; + } +} + export interface ProviderParams { moduleName?: string; basename?: string; @@ -11,4 +94,15 @@ export interface ProviderParams { export interface RenderFnParams extends ProviderParams { dom: HTMLElement; + instanceId?: string; + ssrState?: BridgeJSONValue; + signal?: AbortSignal; +} + +export interface BridgeProviderAPI { + render: (info: RenderFnParams) => void | Promise; + destroy: (info: { dom: HTMLElement }) => void; + renderServer?: ( + context: BridgeServerRenderContext, + ) => Promise; } diff --git a/packages/bridge/vue3-bridge/CHANGELOG.md b/packages/bridge/vue3-bridge/CHANGELOG.md index a2633bad400..49f5d15713d 100644 --- a/packages/bridge/vue3-bridge/CHANGELOG.md +++ b/packages/bridge/vue3-bridge/CHANGELOG.md @@ -1,5 +1,13 @@ # @module-federation/bridge-vue3 +## 2.8.2 + +### Patch Changes + +- @module-federation/runtime@2.8.2 +- @module-federation/sdk@2.8.2 +- @module-federation/bridge-shared@2.8.2 + ## 2.8.1 ### Patch Changes diff --git a/packages/bridge/vue3-bridge/README.md b/packages/bridge/vue3-bridge/README.md index 0bfecb08f8a..9728beb8e41 100644 --- a/packages/bridge/vue3-bridge/README.md +++ b/packages/bridge/vue3-bridge/README.md @@ -7,3 +7,7 @@ This template should help get you started developing with Vue 3 and TypeScript i - [VS Code](https://code.visualstudio.com/) + [Vue - Official](https://marketplace.visualstudio.com/items?itemName=Vue.volar) (previously Volar) and disable Vetur - Use [vue-tsc](https://github.com/vuejs/language-tools/tree/master/packages/tsc) for performing the same type checking from the command line, or for generating d.ts files for SFCs. + +## Bridge SSR + +See the [Bridge SSR guide](https://module-federation.io/guide/bridge/ssr) for hydration setup with Vue 3. diff --git a/packages/bridge/vue3-bridge/__tests__/remoteApp.ssr.test.ts b/packages/bridge/vue3-bridge/__tests__/remoteApp.ssr.test.ts new file mode 100644 index 00000000000..bf3b7604151 --- /dev/null +++ b/packages/bridge/vue3-bridge/__tests__/remoteApp.ssr.test.ts @@ -0,0 +1,341 @@ +import { afterEach, beforeEach, describe, expect, it, rs } from '@rstest/core'; +import { + createApp, + defineComponent, + h, + KeepAlive, + nextTick, + reactive, +} from 'vue'; +import { createMemoryHistory, createRouter, RouterView } from 'vue-router'; +import { + BRIDGE_SSR_PROTOCOL_VERSION, + createBridgeHydrationRegistry, + MF_BRIDGE_SSR_ATTR, + toBridgeSSRReference, +} from '@module-federation/bridge-shared'; +import RemoteApp from '../src/remoteApp'; +import { provideBridgeHydrationRegistry } from '../src/hydration'; + +rs.mock('@module-federation/runtime', () => ({ + getInstance: () => ({ + bridgeHook: { + lifecycle: { + beforeBridgeRender: { emit: rs.fn(async () => ({})) }, + afterBridgeRender: { emit: rs.fn() }, + beforeBridgeDestroy: { emit: rs.fn() }, + afterBridgeDestroy: { emit: rs.fn() }, + }, + }, + }), +})); + +const flushBridgeRender = async () => { + await nextTick(); + await Promise.resolve(); + await nextTick(); +}; + +const installSlot = () => { + document.body.innerHTML = + '

    ' + + '

    server remote

    ' + + '' + + '
    '; +}; + +describe('RemoteApp SSR lifecycle', () => { + let root: HTMLDivElement; + + beforeEach(() => { + document.body.innerHTML = ''; + root = document.createElement('div'); + }); + + afterEach(() => { + document.body.innerHTML = ''; + }); + + const mountApp = (app: ReturnType) => { + document.body.appendChild(root); + app.mount(root); + }; + + it('claims the registry snapshot before provider render', async () => { + const result = { + protocolVersion: BRIDGE_SSR_PROTOCOL_VERSION, + moduleName: 'ecApp', + instanceId: 'ec:1', + html: '

    server remote

    ', + dehydratedState: { ready: true }, + }; + installSlot(); + const registry = createBridgeHydrationRegistry(document); + expect(registry.peek('ecApp', 'ec:1')).toBeDefined(); + + const providerReturn = { + render: rs.fn(), + destroy: rs.fn(), + }; + const App = defineComponent({ + setup: () => () => + h(RemoteApp, { + moduleName: 'ecApp', + instanceId: 'ec:1', + ssr: toBridgeSSRReference(result), + providerInfo: () => providerReturn, + }), + }); + const app = createApp(App); + provideBridgeHydrationRegistry(app, registry); + // RemoteApp calls useRoute(); give it a router even for the non-route case. + const router = createRouter({ + history: createMemoryHistory(), + routes: [{ path: '/', component: { template: '
    ' } }], + }); + app.use(router); + await router.isReady(); + mountApp(app); + await flushBridgeRender(); + + expect(providerReturn.render).toHaveBeenCalledTimes(1); + expect(providerReturn.render.mock.calls[0][0].ssrState).toEqual({ + ready: true, + }); + expect(registry.peek('ecApp', 'ec:1')).toBeUndefined(); + app.unmount(); + }); + + it('claims SSR once under KeepAlive and reactivates through CSR', async () => { + const result = { + protocolVersion: BRIDGE_SSR_PROTOCOL_VERSION, + moduleName: 'ecApp', + instanceId: 'ec:1', + html: '

    server remote

    ', + dehydratedState: { ready: true }, + }; + installSlot(); + const registry = createBridgeHydrationRegistry(document); + const providerReturn = { + render: rs.fn(), + destroy: rs.fn(), + }; + const RemoteRoute = defineComponent({ + setup() { + return () => + h(RemoteApp, { + moduleName: 'ecApp', + basename: '/ec', + instanceId: 'ec:1', + ssr: toBridgeSSRReference(result), + providerInfo: () => providerReturn, + }); + }, + }); + + const router = createRouter({ + history: createMemoryHistory(), + routes: [ + { path: '/', component: { template: '
    home
    ' } }, + { path: '/ec/:pathMatch(.*)*', component: RemoteRoute }, + ], + }); + + const App = defineComponent({ + setup() { + return () => + h(RouterView, null, { + default: ({ Component }) => + h(KeepAlive, null, () => (Component ? h(Component) : null)), + }); + }, + }); + + const app = createApp(App); + provideBridgeHydrationRegistry(app, registry); + app.use(router); + + await router.push('/ec'); + await router.isReady(); + mountApp(app); + await flushBridgeRender(); + + expect(providerReturn.render).toHaveBeenCalledTimes(1); + expect(providerReturn.render.mock.calls[0][0].ssrState).toEqual({ + ready: true, + }); + expect(registry.peek('ecApp', 'ec:1')).toBeUndefined(); + + await router.push('/'); + await flushBridgeRender(); + expect(providerReturn.destroy).toHaveBeenCalledTimes(1); + + await router.push('/ec'); + await flushBridgeRender(); + expect(providerReturn.render).toHaveBeenCalledTimes(2); + expect(providerReturn.render.mock.calls[1][0].ssrState).toBeUndefined(); + const remountDom = providerReturn.render.mock.calls[1][0] + .dom as HTMLElement; + expect(remountDom.getAttribute(MF_BRIDGE_SSR_ATTR)).toBeNull(); + + app.unmount(); + }); + + it('releases an unclaimed SSR snapshot when unmounted before render settles', async () => { + const result = { + protocolVersion: BRIDGE_SSR_PROTOCOL_VERSION, + moduleName: 'ecApp', + instanceId: 'ec:1', + html: '

    server remote

    ', + dehydratedState: { ready: true }, + }; + installSlot(); + const registry = createBridgeHydrationRegistry(document); + expect(registry.peek('ecApp', 'ec:1')).toBeDefined(); + + const providerReturn = { + render: rs.fn( + () => + new Promise(() => { + /* never settles */ + }), + ), + destroy: rs.fn(), + }; + const App = defineComponent({ + setup: () => () => + h(RemoteApp, { + moduleName: 'ecApp', + instanceId: 'ec:1', + ssr: toBridgeSSRReference(result), + providerInfo: () => providerReturn, + }), + }); + const app = createApp(App); + provideBridgeHydrationRegistry(app, registry); + const router = createRouter({ + history: createMemoryHistory(), + routes: [{ path: '/', component: { template: '
    ' } }], + }); + app.use(router); + await router.isReady(); + mountApp(app); + // Unmount before the render queue microtask claims the snapshot. + app.unmount(); + await flushBridgeRender(); + expect(registry.peek('ecApp', 'ec:1')).toBeUndefined(); + }); + + it('drops SSR slot markup when ssr props clear without remount', async () => { + const result = { + protocolVersion: BRIDGE_SSR_PROTOCOL_VERSION, + moduleName: 'ecApp', + instanceId: 'ec:1', + html: '

    server remote

    ', + dehydratedState: { ready: true }, + }; + const providerReturn = { + render: rs.fn(async () => undefined), + destroy: rs.fn(), + }; + const state = reactive<{ + ssr: typeof result | undefined; + }>({ ssr: result }); + const App = defineComponent({ + setup: () => () => + h(RemoteApp, { + moduleName: 'ecApp', + instanceId: 'ec:1', + ssr: state.ssr, + providerInfo: () => providerReturn, + }), + }); + const app = createApp(App); + const router = createRouter({ + history: createMemoryHistory(), + routes: [{ path: '/', component: { template: '
    ' } }], + }); + app.use(router); + await router.isReady(); + mountApp(app); + await flushBridgeRender(); + + expect(root.querySelector('[data-mf-bridge-slot="true"]')).not.toBeNull(); + expect(root.innerHTML).toContain('server remote'); + expect(providerReturn.render).toHaveBeenCalledTimes(1); + expect(providerReturn.destroy).not.toHaveBeenCalled(); + + state.ssr = undefined; + await nextTick(); + await flushBridgeRender(); + + expect(root.querySelector('[data-mf-bridge-slot="true"]')).toBeNull(); + expect(root.querySelector(`[${MF_BRIDGE_SSR_ATTR}="true"]`)).toBeNull(); + expect(providerReturn.destroy).toHaveBeenCalledTimes(1); + expect(providerReturn.render).toHaveBeenCalledTimes(2); + expect(providerReturn.render.mock.calls[1][0].ssrState).toBeUndefined(); + expect(providerReturn.render.mock.calls[1][0].dom?.isConnected).toBe(true); + app.unmount(); + }); + + it('replaces the provider when the SSR identity changes in place', async () => { + const first = { + protocolVersion: BRIDGE_SSR_PROTOCOL_VERSION, + moduleName: 'ecApp', + instanceId: 'ec:1', + html: '

    first remote

    ', + dehydratedState: { ready: 'first' }, + }; + const second = { + protocolVersion: BRIDGE_SSR_PROTOCOL_VERSION, + moduleName: 'ecApp', + instanceId: 'ec:2', + html: '

    second remote

    ', + dehydratedState: { ready: 'second' }, + }; + const providerReturn = { + render: rs.fn(async () => undefined), + destroy: rs.fn(), + }; + const state = reactive<{ + instanceId: string; + ssr: typeof first; + }>({ instanceId: first.instanceId, ssr: first }); + const App = defineComponent({ + setup: () => () => + h(RemoteApp, { + moduleName: 'ecApp', + instanceId: state.instanceId, + ssr: state.ssr, + providerInfo: () => providerReturn, + }), + }); + const app = createApp(App); + const router = createRouter({ + history: createMemoryHistory(), + routes: [{ path: '/', component: { template: '
    ' } }], + }); + app.use(router); + await router.isReady(); + mountApp(app); + await flushBridgeRender(); + + expect(providerReturn.render).toHaveBeenCalledTimes(1); + expect(providerReturn.render.mock.calls[0][0].ssrState).toEqual({ + ready: 'first', + }); + + state.instanceId = second.instanceId; + state.ssr = second; + await nextTick(); + await flushBridgeRender(); + + expect(providerReturn.destroy).toHaveBeenCalledTimes(1); + expect(providerReturn.render).toHaveBeenCalledTimes(2); + expect(providerReturn.render.mock.calls[1][0].ssrState).toEqual({ + ready: 'second', + }); + expect(root.innerHTML).toContain('second remote'); + app.unmount(); + }); +}); diff --git a/packages/bridge/vue3-bridge/__tests__/remoteApp.test.ts b/packages/bridge/vue3-bridge/__tests__/remoteApp.test.ts index ffc10f60dfd..e65634f2e8d 100644 --- a/packages/bridge/vue3-bridge/__tests__/remoteApp.test.ts +++ b/packages/bridge/vue3-bridge/__tests__/remoteApp.test.ts @@ -1,5 +1,12 @@ import { afterEach, beforeEach, describe, expect, it, rs } from '@rstest/core'; -import { createApp, defineComponent, h, KeepAlive, nextTick } from 'vue'; +import { + createApp, + defineComponent, + h, + KeepAlive, + nextTick, + ref as vueRef, +} from 'vue'; import { createMemoryHistory, createRouter, RouterView } from 'vue-router'; import RemoteApp from '../src/remoteApp'; @@ -9,6 +16,7 @@ const { dispatchPopstateEnv } = rs.hoisted(() => ({ rs.mock('@module-federation/bridge-shared', () => ({ dispatchPopstateEnv, + getMatchingBridgeSSRPayload: () => undefined, })); rs.mock('@module-federation/runtime', () => ({ @@ -96,4 +104,72 @@ describe('RemoteApp', () => { app.unmount(); }); + + it('forwards updated application props to the mounted provider', async () => { + const providerReturn = { + render: rs.fn(), + destroy: rs.fn(), + }; + const providerInfo = () => providerReturn; + const label = vueRef('first'); + const App = defineComponent({ + setup: () => () => + h(RemoteApp, { + moduleName: 'ecApp', + providerInfo, + label: label.value, + }), + }); + const app = createApp(App); + app.mount(root); + await flushBridgeRender(); + expect(providerReturn.render).toHaveBeenCalledTimes(1); + expect(providerReturn.render.mock.calls[0][0].label).toBe('first'); + + label.value = 'second'; + await flushBridgeRender(); + expect(providerReturn.render).toHaveBeenCalledTimes(2); + expect(providerReturn.render.mock.calls[1][0].label).toBe('second'); + app.unmount(); + }); + + it('replaces a provider that changes during an asynchronous mount', async () => { + let finishFirstRender!: () => void; + const firstProvider = { + render: rs.fn( + () => + new Promise((resolve) => { + finishFirstRender = resolve; + }), + ), + destroy: rs.fn(), + }; + const secondProvider = { + render: rs.fn(), + destroy: rs.fn(), + }; + const providerInfo = vueRef(() => firstProvider); + const App = defineComponent({ + setup: () => () => + h(RemoteApp, { + moduleName: 'ecApp', + providerInfo: providerInfo.value, + }), + }); + const app = createApp(App); + app.mount(root); + await nextTick(); + await Promise.resolve(); + expect(firstProvider.render).toHaveBeenCalledOnce(); + + providerInfo.value = () => secondProvider; + await nextTick(); + finishFirstRender(); + await flushBridgeRender(); + await flushBridgeRender(); + + expect(firstProvider.destroy).toHaveBeenCalledOnce(); + expect(secondProvider.render).toHaveBeenCalledOnce(); + app.unmount(); + }); }); diff --git a/packages/bridge/vue3-bridge/package.json b/packages/bridge/vue3-bridge/package.json index d769748278e..60e3ed10737 100644 --- a/packages/bridge/vue3-bridge/package.json +++ b/packages/bridge/vue3-bridge/package.json @@ -7,7 +7,7 @@ "url": "git+https://github.com/module-federation/core.git", "directory": "packages/bridge/vue3-bridge" }, - "version": "2.8.1", + "version": "2.8.2", "publishConfig": { "access": "public" }, @@ -15,6 +15,22 @@ "main": "./dist/index.cjs", "module": "./dist/index.js", "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "browser": { + "import": "./dist/index.js", + "require": "./dist/index.cjs" + }, + "node": { + "import": "./dist/index.server.js", + "require": "./dist/index.server.cjs" + }, + "import": "./dist/index.js", + "require": "./dist/index.cjs" + }, + "./*": "./*" + }, "files": [ "dist/", "package.json", @@ -28,7 +44,13 @@ }, "peerDependencies": { "vue": "=3", - "vue-router": "=4" + "vue-router": "=4", + "@vue/server-renderer": "^3.0.0" + }, + "peerDependenciesMeta": { + "@vue/server-renderer": { + "optional": true + } }, "dependencies": { "@module-federation/bridge-shared": "workspace:*", @@ -45,6 +67,7 @@ "vite": "^5.4.21", "vue": "^3.5.30", "vue-router": "4.4.5", + "@vue/server-renderer": "^3.5.30", "vue-tsc": "^2.2.12" } } diff --git a/packages/bridge/vue3-bridge/rstest.config.ts b/packages/bridge/vue3-bridge/rstest.config.ts index c24e78e6123..5d1bb8841bb 100644 --- a/packages/bridge/vue3-bridge/rstest.config.ts +++ b/packages/bridge/vue3-bridge/rstest.config.ts @@ -4,6 +4,11 @@ import { pluginVueJsx } from '@rsbuild/plugin-vue-jsx'; import { resolve } from 'path'; export default defineConfig({ + source: { + define: { + __APP_VERSION__: '"unknown"', + }, + }, plugins: [ pluginBabel({ include: /\.(?:jsx|tsx)$/, diff --git a/packages/bridge/vue3-bridge/src/create.ts b/packages/bridge/vue3-bridge/src/create.ts index 9fe415acee8..2c3033220dd 100644 --- a/packages/bridge/vue3-bridge/src/create.ts +++ b/packages/bridge/vue3-bridge/src/create.ts @@ -2,9 +2,18 @@ import { type AsyncComponentOptions, defineAsyncComponent, h } from 'vue'; import { useRoute } from 'vue-router'; import RemoteApp from './remoteApp.jsx'; import { LoggerInstance } from './utils.js'; +import type { + BridgeSSRReference, + BridgeSSRResult, +} from '@module-federation/bridge-shared'; declare const __APP_VERSION__: string; +export type RemoteAppSSRProps = { + ssr?: BridgeSSRResult | BridgeSSRReference; + instanceId?: string; +}; + export function createRemoteAppComponent(info: { loader: () => Promise; export?: string; diff --git a/packages/bridge/vue3-bridge/src/hydration.ts b/packages/bridge/vue3-bridge/src/hydration.ts new file mode 100644 index 00000000000..1375ac12bff --- /dev/null +++ b/packages/bridge/vue3-bridge/src/hydration.ts @@ -0,0 +1,17 @@ +import { inject, type App, type InjectionKey } from 'vue'; +import type { BridgeHydrationRegistry } from '@module-federation/bridge-shared'; + +export const BridgeHydrationRegistryKey: InjectionKey = + Symbol.for('mf.bridge.hydration-registry'); + +export function provideBridgeHydrationRegistry( + app: App, + registry: BridgeHydrationRegistry, +) { + app.provide(BridgeHydrationRegistryKey, registry); + return app; +} + +export function useBridgeHydrationRegistry() { + return inject(BridgeHydrationRegistryKey, undefined); +} diff --git a/packages/bridge/vue3-bridge/src/index.server.ts b/packages/bridge/vue3-bridge/src/index.server.ts new file mode 100644 index 00000000000..9743a3fb20a --- /dev/null +++ b/packages/bridge/vue3-bridge/src/index.server.ts @@ -0,0 +1,18 @@ +import { + createBridgeComponentWithServerRenderer, + type ProviderFnParams, +} from './provider'; +import { renderBridgeVueToString } from './server-renderer'; + +export function createBridgeComponent(bridgeInfo: ProviderFnParams) { + return createBridgeComponentWithServerRenderer( + bridgeInfo, + renderBridgeVueToString, + ); +} + +export type { ProviderFnParams } from './provider'; +export { createRemoteComponent, createRemoteAppComponent } from './create'; +export type { RemoteAppSSRProps } from './create'; +export { provideBridgeHydrationRegistry } from './hydration'; +export type { RenderFnParams } from '@module-federation/bridge-shared'; diff --git a/packages/bridge/vue3-bridge/src/index.ts b/packages/bridge/vue3-bridge/src/index.ts index fbf7a554075..1de0572548e 100644 --- a/packages/bridge/vue3-bridge/src/index.ts +++ b/packages/bridge/vue3-bridge/src/index.ts @@ -1,4 +1,6 @@ export { createBridgeComponent } from './provider'; export type { ProviderFnParams } from './provider'; export { createRemoteComponent, createRemoteAppComponent } from './create'; +export type { RemoteAppSSRProps } from './create'; +export { provideBridgeHydrationRegistry } from './hydration'; export type { RenderFnParams } from '@module-federation/bridge-shared'; diff --git a/packages/bridge/vue3-bridge/src/provider.spec.ts b/packages/bridge/vue3-bridge/src/provider.spec.ts new file mode 100644 index 00000000000..0979d3afc28 --- /dev/null +++ b/packages/bridge/vue3-bridge/src/provider.spec.ts @@ -0,0 +1,186 @@ +import { describe, expect, it, rs } from '@rstest/core'; +import { defineComponent, h } from 'vue'; +import { createMemoryHistory, createRouter } from 'vue-router'; +import { getBridgeSSRContainerAttrs } from '@module-federation/bridge-shared'; +import { createBridgeComponentWithServerRenderer } from './provider'; + +describe('Vue Bridge server provider', () => { + it('creates a request-local app and returns the thin V1 result', async () => { + const serverRenderer = rs.fn(async () => '

    vue remote

    '); + const factory = createBridgeComponentWithServerRenderer( + { + rootComponent: defineComponent(() => () => h('p', 'vue remote')), + appOptions: () => ({ + router: createRouter({ + history: createMemoryHistory(), + routes: [ + { + path: '/detail', + component: defineComponent(() => () => h('p')), + }, + ], + }), + }), + ssr: true, + }, + serverRenderer, + ); + const provider = factory(); + const request = new Request('http://bridge.test/detail'); + await expect( + provider.renderServer!({ + moduleName: 'vue/remote', + instanceId: 'vue-1', + request, + signal: request.signal, + props: {}, + }), + ).resolves.toEqual({ + protocolVersion: 1, + moduleName: 'vue/remote', + instanceId: 'vue-1', + html: '

    vue remote

    ', + dehydratedState: undefined, + }); + expect(serverRenderer).toHaveBeenCalledOnce(); + }); + + it('updates application props without remounting the app', async () => { + const provider = createBridgeComponentWithServerRenderer({ + rootComponent: defineComponent({ + props: { label: String }, + setup: (props) => () => h('p', props.label), + }), + appOptions: () => undefined, + })(); + const dom = document.createElement('div'); + document.body.appendChild(dom); + + await provider.render({ + dom, + moduleName: 'vue/remote', + label: 'first', + } as any); + const firstApp = (dom as HTMLElement & { __vue_app__?: any }).__vue_app__; + const unmount = rs.spyOn(firstApp, 'unmount'); + + await provider.render({ + dom, + moduleName: 'vue/remote', + label: 'second', + } as any); + await Promise.resolve(); + expect(unmount).not.toHaveBeenCalled(); + expect(dom.textContent).toBe('second'); + expect((dom as HTMLElement & { __vue_app__?: any }).__vue_app__).toBe( + firstApp, + ); + + await provider.render({ dom, moduleName: 'vue/remote' } as any); + await Promise.resolve(); + expect(dom.textContent).toBe(''); + + provider.destroy({ dom }); + expect(unmount).toHaveBeenCalledOnce(); + }); + + it('remounts when routing configuration changes', async () => { + const provider = createBridgeComponentWithServerRenderer({ + rootComponent: defineComponent(() => () => h('p', 'remote')), + appOptions: () => undefined, + })(); + const dom = document.createElement('div'); + document.body.appendChild(dom); + await provider.render({ + dom, + moduleName: 'vue/remote', + basename: '/first', + }); + const firstApp = (dom as HTMLElement & { __vue_app__?: any }).__vue_app__; + const unmount = rs.spyOn(firstApp, 'unmount'); + + await provider.render({ + dom, + moduleName: 'vue/remote', + basename: '/second', + }); + expect(unmount).toHaveBeenCalledOnce(); + expect((dom as HTMLElement & { __vue_app__?: any }).__vue_app__).not.toBe( + firstApp, + ); + }); + + it('applies dehydrated state only during the initial hydration mount', async () => { + const hydrate = rs.fn(() => ({ label: 'hydrated' })); + const provider = createBridgeComponentWithServerRenderer({ + rootComponent: defineComponent({ + props: { label: String }, + setup: (props) => () => h('p', props.label), + }), + appOptions: () => undefined, + ssr: { hydrate }, + })(); + const dom = document.createElement('div'); + for (const [name, value] of Object.entries( + getBridgeSSRContainerAttrs({ + moduleName: 'vue/remote', + instanceId: 'vue-1', + }), + )) { + dom.setAttribute(name, value); + } + dom.innerHTML = '

    hydrated

    '; + document.body.appendChild(dom); + + await provider.render({ + dom, + moduleName: 'vue/remote', + instanceId: 'vue-1', + ssrState: { label: 'server' }, + }); + await provider.render({ + dom, + moduleName: 'vue/remote', + instanceId: 'vue-1', + label: 'updated', + } as any); + + expect(hydrate).toHaveBeenCalledOnce(); + 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 6b3e66eee41..2e68ac3e6f7 100644 --- a/packages/bridge/vue3-bridge/src/provider.ts +++ b/packages/bridge/vue3-bridge/src/provider.ts @@ -1,6 +1,14 @@ import * as Vue from 'vue'; import * as VueRouter from 'vue-router'; -import { RenderFnParams } from '@module-federation/bridge-shared'; +import { + BRIDGE_SSR_PROTOCOL_VERSION, + hasBridgeSSRMarkup, + type BridgeSSRConfig, + type BridgeSSRPrepareResult, + type BridgeSSRResult, + type BridgeServerRenderContext, + type RenderFnParams, +} from '@module-federation/bridge-shared'; import { LoggerInstance } from './utils'; import { getInstance } from '@module-federation/runtime'; import { processRoutes } from './routeUtils'; @@ -11,6 +19,9 @@ type AddOptionsFnParams = { app: Vue.App; basename: RenderFnParams['basename']; memoryRoute: RenderFnParams['memoryRoute']; + request?: Request; + signal?: AbortSignal; + ssr: boolean; [key: string]: any; }; @@ -18,95 +29,239 @@ export type ProviderFnParams = { rootComponent: Vue.Component; appOptions: (params: AddOptionsFnParams) => { router?: VueRouter.Router; - /** Called with the bridge's internal router after creation but before navigation. - * Use this to register global guards (beforeEach, afterEach, etc.) that would - * otherwise be lost when the bridge recreates the router. */ afterRouterCreate?: (router: VueRouter.Router) => void; } | void; + ssr?: BridgeSSRConfig>; }; -export function createBridgeComponent(bridgeInfo: ProviderFnParams) { - const rootMap = new Map(); - const instance = getInstance(); +export type BridgeVueServerRenderer = ( + app: Vue.App, + context: Record, +) => string | Promise; + +type BridgeAppInfo = Omit & { + dom?: HTMLElement; + request?: Request; + url?: string; +}; + +async function setupBridgeApp( + bridgeInfo: ProviderFnParams, + info: BridgeAppInfo, + props: Record, + mode: 'ssr' | 'hydrate' | 'csr', +) { + if (info.signal?.aborted) throw info.signal.reason; + const ReactiveBridgeRoot = Vue.defineComponent({ + name: 'BridgeReactiveRoot', + setup: () => () => Vue.h(bridgeInfo.rootComponent, props), + }); + const app = (mode === 'csr' ? Vue.createApp : Vue.createSSRApp)( + ReactiveBridgeRoot, + ); + const options = bridgeInfo.appOptions({ + ...props, + app, + basename: info.basename, + memoryRoute: info.memoryRoute, + hashRoute: info.hashRoute, + request: info.request, + signal: info.signal, + ssr: mode === 'ssr', + }); + let router: VueRouter.Router | undefined; + if (options?.router) { + const effectiveMemoryRoute = + mode === 'ssr' ? { entryPath: info.url ?? '/' } : info.memoryRoute; + const processed = processRoutes({ + router: options.router, + basename: info.basename, + memoryRoute: effectiveMemoryRoute, + hashRoute: info.hashRoute, + }); + router = VueRouter.createRouter({ + ...options.router.options, + history: processed.history, + routes: processed.routes, + }); + processed.patchRouter?.(router); + options.afterRouterCreate?.(router); + if (effectiveMemoryRoute) { + await router.push(effectiveMemoryRoute.entryPath); + } + app.use(router); + if (mode !== 'csr') await router.isReady(); + } + if (info.signal?.aborted) throw info.signal.reason; + return { app, router }; +} + +function serverLocation(request: Request, basename?: string) { + const url = new URL(request.url); + let pathname = url.pathname || '/'; + const base = (basename || '/').replace(/\/$/, '') || '/'; + if (base !== '/' && (pathname === base || pathname.startsWith(`${base}/`))) { + pathname = pathname.slice(base.length) || '/'; + } + return `${pathname}${url.search}`; +} + +export function createBridgeComponentWithServerRenderer( + bridgeInfo: ProviderFnParams, + serverRenderer?: BridgeVueServerRenderer, +) { + const roots = new Map< + HTMLElement, + { + app: Vue.App; + props: Record; + routeKey: string; + } + >(); + const runtime = getInstance(); + return () => { - return { + const config = + typeof bridgeInfo.ssr === 'object' ? bridgeInfo.ssr : undefined; + const provider: { + __APP_VERSION__: string; + render: (info: RenderFnParams) => Promise; + destroy: (info: { dom: HTMLElement }) => void; + renderServer?: ( + context: BridgeServerRenderContext>, + ) => Promise; + } = { __APP_VERSION__, - async render(info: RenderFnParams) { - LoggerInstance.debug(`createBridgeComponent render Info`, info); + async render(info) { + if (info.signal?.aborted) return; const { moduleName, dom, basename, memoryRoute, hashRoute, - ...propsInfo + instanceId, + ssrState, + signal, + ...applicationProps } = info; - const app = Vue.createApp(bridgeInfo.rootComponent, propsInfo); - rootMap.set(dom, app); - const beforeBridgeRenderRes = - await instance?.bridgeHook?.lifecycle?.beforeBridgeRender?.emit(info); - + await runtime?.bridgeHook?.lifecycle?.beforeBridgeRender?.emit(info); const extraProps = beforeBridgeRenderRes && typeof beforeBridgeRenderRes === 'object' && - beforeBridgeRenderRes?.extraProps - ? beforeBridgeRenderRes?.extraProps + beforeBridgeRenderRes.extraProps + ? beforeBridgeRenderRes.extraProps : {}; - - const bridgeOptions = bridgeInfo.appOptions({ - app, - basename, - memoryRoute, - hashRoute, - ...propsInfo, - ...extraProps, - }); - if (bridgeOptions?.router) { - const { history, routes, patchRouter } = processRoutes({ - router: bridgeOptions.router, - basename: info.basename, - memoryRoute: info.memoryRoute, - hashRoute: info.hashRoute, - }); - - const router = VueRouter.createRouter({ - ...bridgeOptions.router.options, - history, - routes, - }); - - if (patchRouter) { - patchRouter(router); - } - - if (bridgeOptions.afterRouterCreate) { - bridgeOptions.afterRouterCreate(router); - } - - LoggerInstance.debug(`createBridgeComponent render router info>>>`, { + const routeKey = JSON.stringify([ + basename ?? null, + memoryRoute?.entryPath ?? null, + hashRoute ?? false, + ]); + const mounted = roots.get(dom); + const shouldHydrate = + !mounted && + hasBridgeSSRMarkup(dom, { moduleName, - router, + instanceId, }); - // memory route Initializes the route - if (memoryRoute) { - await router.push(memoryRoute.entryPath); + const hydrated = + shouldHydrate && config?.hydrate ? config.hydrate(ssrState) : {}; + const nextProps = { ...hydrated, ...applicationProps, ...extraProps }; + if (mounted?.routeKey === routeKey) { + for (const key of Object.keys(mounted.props)) { + if (!(key in nextProps)) delete mounted.props[key]; } - - app.use(router); + Object.assign(mounted.props, nextProps); + runtime?.bridgeHook?.lifecycle?.afterBridgeRender?.emit(info); + return; } - - app.mount(dom); - instance?.bridgeHook?.lifecycle?.afterBridgeRender?.emit(info); + if (mounted) { + mounted.app.unmount(); + roots.delete(dom); + } + const reactiveProps = Vue.shallowReactive(nextProps); + 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(); + return; + } + roots.set(dom, { app, props: reactiveProps, routeKey }); + runtime?.bridgeHook?.lifecycle?.afterBridgeRender?.emit(info); + LoggerInstance.debug('createBridgeComponent rendered', { moduleName }); }, - destroy(info: { dom: HTMLElement }) { - LoggerInstance.debug(`createBridgeComponent destroy Info`, info); - const root = rootMap.get(info?.dom); - - instance?.bridgeHook?.lifecycle?.beforeBridgeDestroy?.emit(info); - root?.unmount(); - instance?.bridgeHook?.lifecycle?.afterBridgeDestroy?.emit(info); + destroy({ dom }) { + const info = { dom }; + LoggerInstance.debug('createBridgeComponent destroy Info', info); + runtime?.bridgeHook?.lifecycle?.beforeBridgeDestroy?.emit(info); + roots.get(dom)?.app.unmount(); + roots.delete(dom); + runtime?.bridgeHook?.lifecycle?.afterBridgeDestroy?.emit(info); }, }; + + if (serverRenderer && bridgeInfo.ssr) { + 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 + >; + const renderInfo = prepared.props ?? context.props; + const { basename, memoryRoute, hashRoute, ...applicationProps } = + renderInfo; + delete applicationProps.moduleName; + const { app } = await setupBridgeApp( + bridgeInfo, + { + moduleName: context.moduleName, + basename: basename as string | undefined, + memoryRoute: memoryRoute as RenderFnParams['memoryRoute'], + hashRoute: Boolean(hashRoute), + request: context.request, + signal: context.signal, + url: serverLocation( + context.request, + basename as string | undefined, + ), + }, + 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; + return { + protocolVersion: BRIDGE_SSR_PROTOCOL_VERSION, + moduleName: context.moduleName, + instanceId: context.instanceId, + html, + dehydratedState: prepared.dehydratedState, + }; + }; + } + return provider; }; } + +export function createBridgeComponent(bridgeInfo: ProviderFnParams) { + return createBridgeComponentWithServerRenderer(bridgeInfo); +} diff --git a/packages/bridge/vue3-bridge/src/remoteApp.tsx b/packages/bridge/vue3-bridge/src/remoteApp.tsx index 3a04bb951f2..f431545f561 100644 --- a/packages/bridge/vue3-bridge/src/remoteApp.tsx +++ b/packages/bridge/vue3-bridge/src/remoteApp.tsx @@ -1,18 +1,45 @@ import { ref, + computed, onMounted, onBeforeUnmount, onActivated, onDeactivated, + onUpdated, watch, + watchEffect, defineComponent, useAttrs, nextTick, + type PropType, } from 'vue'; -import { dispatchPopstateEnv } from '@module-federation/bridge-shared'; +import { + BRIDGE_SSR_PROTOCOL_VERSION, + dispatchPopstateEnv, + getBridgeSSRContainerAttrs, + getBridgeSSRSlotAttrs, + getMatchingBridgeSSRPayload, + MF_BRIDGE_INSTANCE_ATTR, + MF_BRIDGE_MODULE_ATTR, + MF_BRIDGE_MOUNT_ATTR, + MF_BRIDGE_SSR_ATTR, + MF_BRIDGE_VERSION_ATTR, + serializeBridgeSSRStateEnvelope, + type BridgeSSRReference, + type BridgeSSRResult, +} from '@module-federation/bridge-shared'; import { useRoute } from 'vue-router'; import { LoggerInstance } from './utils'; import { getInstance } from '@module-federation/runtime'; +import { useBridgeHydrationRegistry } from './hydration'; + +function clearBridgeSSRMountAttrs(dom: HTMLElement) { + dom.removeAttribute(MF_BRIDGE_SSR_ATTR); + dom.removeAttribute(MF_BRIDGE_MOUNT_ATTR); + dom.removeAttribute(MF_BRIDGE_VERSION_ATTR); + dom.removeAttribute(MF_BRIDGE_MODULE_ATTR); + dom.removeAttribute(MF_BRIDGE_INSTANCE_ATTR); +} export default defineComponent({ name: 'RemoteApp', @@ -23,6 +50,8 @@ export default defineComponent({ hashRoute: Boolean, providerInfo: Function, rootAttrs: Object, + ssr: Object as PropType, + instanceId: String, }, inheritAttrs: false, setup(props) { @@ -32,52 +61,206 @@ export default defineComponent({ const isRendered = ref(false); const isActive = ref(false); const wasDeactivated = ref(false); + const renderError = ref(); + const controller = new AbortController(); + let renderQueue = Promise.resolve(); + let consumedSnapshot = false; + let hydratedOnce = false; + let csrOnly = false; + let providerGeneration = 0; const route = useRoute(); const hostInstance = getInstance(); const componentAttrs = useAttrs(); + let lastComponentAttrs = { ...componentAttrs }; + const ssrPayload = computed(() => + getMatchingBridgeSSRPayload(props.ssr, { + moduleName: props.moduleName, + instanceId: props.instanceId, + }), + ); + const instanceId = computed( + () => props.instanceId || ssrPayload.value?.instanceId, + ); + const serverPayload = computed(() => { + const payload = ssrPayload.value; + return payload && 'html' in payload + ? (payload as BridgeSSRResult) + : undefined; + }); + const reference = computed(() => { + const payload = ssrPayload.value; + return payload && !('html' in payload) + ? (payload as BridgeSSRReference) + : undefined; + }); + const registry = useBridgeHydrationRegistry(); + watchEffect(() => { + if (reference.value && !registry) { + throw new Error( + 'Bridge SSR references require provideBridgeHydrationRegistry before mount', + ); + } + }); + const snapshot = computed(() => { + const currentReference = reference.value; + const currentInstanceId = instanceId.value; + return currentReference && currentInstanceId + ? registry?.peek(currentReference.moduleName, currentInstanceId) + : undefined; + }); + const hasSSRPayload = computed(() => + Boolean((serverPayload.value || snapshot.value) && instanceId.value), + ); + const ssrIdentity = computed(() => { + const payload = ssrPayload.value; + const currentInstanceId = instanceId.value; + return payload && currentInstanceId + ? `${payload.moduleName}\0${currentInstanceId}` + : ''; + }); + const registryModuleName = computed( + () => + props.moduleName || + reference.value?.moduleName || + ssrPayload.value?.moduleName || + '', + ); + + const releaseUnclaimedSnapshot = () => { + const currentSnapshot = snapshot.value; + const currentInstanceId = instanceId.value; + if ( + !currentSnapshot || + !currentInstanceId || + consumedSnapshot || + !registry + ) { + return; + } + registry.fail(registryModuleName.value, currentInstanceId); + consumedSnapshot = true; + }; const getBridgeRenderProps = () => ({ - name: props.moduleName, + moduleName: props.moduleName, dom: rootRef.value, basename: props.basename, memoryRoute: props.memoryRoute, hashRoute: props.hashRoute, }); - const renderComponent = async () => { - if (!rootRef.value || isRendered.value) { - return; - } - const providerReturn = props.providerInfo?.(); - providerInfoRef.value = providerReturn; + const renderComponent = () => { + const pending = renderQueue.then(async () => { + const generation = providerGeneration; + const dom = rootRef.value as HTMLElement | null; + if ( + !dom || + !dom.isConnected || + !props.providerInfo || + controller.signal.aborted + ) { + return; + } + const providerReturn = providerInfoRef.value || props.providerInfo?.(); + providerInfoRef.value = providerReturn; + const wasRendered = isRendered.value; - let renderProps = { - ...componentAttrs, - moduleName: props.moduleName, - dom: rootRef.value, - basename: props.basename, - memoryRoute: props.memoryRoute, - hashRoute: props.hashRoute, - }; - LoggerInstance.debug( - `createRemoteAppComponent LazyComponent render >>>`, - renderProps, - ); + // Claim before any await so cancel cannot leave a peekable snapshot. + // After a claim (even if render aborts), never hydrate again — CSR only. + const currentSnapshot = snapshot.value; + const currentInstanceId = instanceId.value; + let ssrState = + wasRendered || hydratedOnce || consumedSnapshot + ? undefined + : serverPayload.value?.dehydratedState; + if ( + !wasRendered && + !hydratedOnce && + !consumedSnapshot && + currentSnapshot && + currentInstanceId && + registry + ) { + const claimed = registry.consume( + registryModuleName.value, + currentInstanceId, + ); + if (Object.is(claimed, currentSnapshot)) { + consumedSnapshot = true; + ssrState = currentSnapshot.state; + } else { + consumedSnapshot = true; + hydratedOnce = true; + ssrState = undefined; + clearBridgeSSRMountAttrs(dom); + } + } - const beforeBridgeRenderRes = - (await hostInstance?.bridgeHook?.lifecycle?.beforeBridgeRender?.emit( + let renderProps = { + ...componentAttrs, + moduleName: props.moduleName, + dom, + basename: props.basename, + memoryRoute: props.memoryRoute, + hashRoute: props.hashRoute, + instanceId: currentInstanceId, + ssrState, + signal: controller.signal, + }; + LoggerInstance.debug( + `createRemoteAppComponent LazyComponent render >>>`, renderProps, - )) || {}; + ); - renderProps = { ...renderProps, ...beforeBridgeRenderRes.extraProps }; - providerReturn.render(renderProps); - isRendered.value = true; - hostInstance?.bridgeHook?.lifecycle?.afterBridgeRender?.emit(renderProps); + const beforeBridgeRenderRes = + (await hostInstance?.bridgeHook?.lifecycle?.beforeBridgeRender?.emit( + renderProps, + )) || {}; + renderProps = { ...renderProps, ...beforeBridgeRenderRes.extraProps }; + await providerReturn.render(renderProps); + if ( + generation !== providerGeneration || + controller.signal.aborted || + wasDeactivated.value || + !dom.isConnected + ) { + // 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; + } + isRendered.value = true; + // One-shot hydrate for this instance. Keep SSR markers in the DOM so + // hosts/tests can observe them; strip only on KeepAlive deactivate. + if (snapshot.value || serverPayload.value) { + hydratedOnce = true; + } + hostInstance?.bridgeHook?.lifecycle?.afterBridgeRender?.emit( + renderProps, + ); + }); + renderQueue = pending.catch((error) => { + if (controller.signal.aborted || wasDeactivated.value) return; + const failedSnapshot = snapshot.value; + const failedInstanceId = instanceId.value; + if (failedSnapshot && failedInstanceId && !consumedSnapshot) { + registry?.fail(registryModuleName.value, failedInstanceId); + consumedSnapshot = true; + } + renderError.value = error; + }); + return renderQueue; }; 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( @@ -125,7 +308,83 @@ export default defineComponent({ onMounted(() => { isActive.value = true; - renderComponent(); + void renderComponent(); + }); + + watch( + hasSSRPayload, + (next, prev) => { + if (!prev || next) return; + // Host cleared SSR without remounting (SPA after seedConsumed). Destroy + // the provider on the current mount node before Vue drops the slot tree, + // then CSR-render into the replacement mount. + providerGeneration += 1; + csrOnly = true; + hydratedOnce = true; + const dom = rootRef.value as HTMLElement | null; + destroyComponent(); + if (dom) clearBridgeSSRMountAttrs(dom); + void nextTick(() => { + if (isActive.value && !controller.signal.aborted) { + void renderComponent(); + } + }); + }, + { flush: 'pre' }, + ); + + watch( + ssrIdentity, + (next, prev) => { + if (!prev || !next || next === prev) return; + + // A new SSR identity is a new remote instance. Tear down the old + // provider before Vue patches the slot, then hydrate/render the new + // payload after its mount node has been committed. + providerGeneration += 1; + consumedSnapshot = false; + hydratedOnce = false; + csrOnly = false; + destroyComponent(); + void nextTick(() => { + if (isActive.value && !controller.signal.aborted) { + void renderComponent(); + } + }); + }, + { flush: 'pre' }, + ); + + watch( + () => props.providerInfo, + () => { + providerGeneration += 1; + destroyComponent(); + providerInfoRef.value = null; + void renderComponent(); + }, + { flush: 'post' }, + ); + + watch( + () => [props.basename, props.memoryRoute, props.hashRoute], + () => { + if (isRendered.value) void renderComponent(); + }, + { deep: true, flush: 'post' }, + ); + + onUpdated(() => { + const nextComponentAttrs = { ...componentAttrs }; + const keys = Object.keys(nextComponentAttrs); + const attrsChanged = + keys.length !== Object.keys(lastComponentAttrs).length || + keys.some( + (key) => !Object.is(nextComponentAttrs[key], lastComponentAttrs[key]), + ); + if (!attrsChanged) return; + lastComponentAttrs = nextComponentAttrs; + if (isRendered.value) void renderComponent(); }); onActivated(async () => { @@ -135,20 +394,81 @@ export default defineComponent({ } wasDeactivated.value = false; await nextTick(); - renderComponent(); + await renderComponent(); }); onDeactivated(() => { isActive.value = false; wasDeactivated.value = true; + releaseUnclaimedSnapshot(); + if (consumedSnapshot) hydratedOnce = true; + csrOnly = true; + const dom = rootRef.value as HTMLElement | null; + if (dom) clearBridgeSSRMountAttrs(dom); destroyComponent(); }); onBeforeUnmount(() => { + controller.abort(); watchStopHandle(); + releaseUnclaimedSnapshot(); destroyComponent(); }); - return () =>
    ; + return () => { + if (renderError.value) throw renderError.value; + const currentInstanceId = instanceId.value; + const currentServerPayload = serverPayload.value; + const currentSnapshot = snapshot.value; + const currentSsrPayload = ssrPayload.value; + const showSSRMarkup = + hasSSRPayload.value && currentInstanceId && !csrOnly; + const mount = ( +
    + ); + if (!showSSRMarkup) return mount; + const moduleName = props.moduleName || currentSsrPayload!.moduleName; + return ( +
    + {mount} +