Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ This repository contains comprehensive examples that demonstrate the Rstack ecos
| [Rspress](https://github.com/web-infra-dev/rspress) | <a href="https://github.com/web-infra-dev/rspress" target="blank"><img src="https://assets.rspack.rs/rspress/rspress-banner.png" width="400" /></a> | [Examples](./rspress/) | [Document](https://rspress.rs/) |
| [Rsdoctor](https://github.com/web-infra-dev/rsdoctor) | <a href="https://github.com/web-infra-dev/rsdoctor" target="blank"><img src="https://assets.rspack.rs/rsdoctor/rsdoctor-banner.png" width="400" /></a> | [Examples](./rsdoctor/) | [Document](https://rsdoctor.rs/) |
| [Rslib](https://github.com/web-infra-dev/rslib) | <a href="https://github.com/web-infra-dev/rslib" target="blank"><img src="https://assets.rspack.rs/rslib/rslib-banner.png" width="400" /></a> | [Examples](./rslib) | [Document](https://rslib.rs/) |
| [Rstest](https://github.com/web-infra-dev/rstest) | <a href="https://github.com/web-infra-dev/rstest" target="blank"><img src="https://assets.rspack.rs/rstest/rstest-banner.png" width="400" /></a> | [Examples](./rstest) | [Document](https://rstest.rs/) |

## How to Use

Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
"lint": "rs lint && rs fmt --check \"*.{json,jsonc,md,yml,yaml,ts}\" \".vscode/*.json\" \"{rspack,rsbuild,rspress,rsdoctor,rslib,rstest}/**/*.{js,ts,cjs,mjs,d.cts,d.mts,jsx,tsx,json,jsonc}\"",
"lint:write": "rs lint --fix && rs fmt \"*.{json,jsonc,md,yml,yaml,ts}\" \".vscode/*.json\" \"{rspack,rsbuild,rspress,rsdoctor,rslib,rstest}/**/*.{js,ts,cjs,mjs,d.cts,d.mts,jsx,tsx,json,jsonc}\"",
"prepare": "rs hooks",
"sort-package-json": "rs fmt \"rspack/*/package.json\" \"rsbuild/*/package.json\" \"rspress/*/package.json\" \"rsdoctor/*/package.json\" \"rslib/*/package.json\" \"rstest/*/package.json\"",
"sort-package-json": "rs fmt \"rspack/*/package.json\" \"rsbuild/*/package.json\" \"rspress/*/package.json\" \"rsdoctor/*/package.json\" \"rslib/*/package.json\" \"rstest/*/package.json\" \"rstest/*/*/package.json\"",
"test": "pnpm run test:rspack && pnpm run test:rstest",
"test:rspack": "pnpm --filter \"./rspack/**\" --stream test",
"test:rspack:eco-ci": "pnpm --filter \"./rspack/**\" --filter \"!./rspack/next-rspack-app-router\" --filter \"!./rspack/next-rspack-page-router\" --stream test",
Expand Down
1,498 changes: 1,173 additions & 325 deletions pnpm-lock.yaml

Large diffs are not rendered by default.

32 changes: 32 additions & 0 deletions rstest/module-federation-browser/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
# Rstest Module Federation (Browser) Example

Test a federated React component in [Rstest Browser Mode](https://rstest.rs/guide/browser-testing).

- `remote/` builds a React library as ESM with declarations and exposes `remote/Button` through Rslib's `mf` format on port 3001.
- `host/` consumes the component in a React app on port 3002 and tests it in Chromium.

## What it tests

- A federated React component loaded over HTTP renders in a real Chromium page and responds to clicks: `host/tests/button.test.tsx` renders `remote/Button` with `@rstest/browser-react`, clicks it, and asserts the counter text with `expect.element`.
- Host and remote share one React copy: the counter state only works because `react` and `react-dom` are singletons, with [`shareStrategy: 'loaded-first'`](https://rslib.rs/guide/advanced/module-federation#faqs) preferring the host's React over the production remote's.

## Run the tests

From `host/`:

```bash
pnpm install
pnpm exec playwright install chromium # One-time browser installation
pnpm test
```

## Run the apps in the browser

From this directory, start the remote and host in separate terminals:

```bash
pnpm --dir remote dev
pnpm --dir host dev
```

Open `http://localhost:3002` and click the federated button to increment its counter.
35 changes: 35 additions & 0 deletions rstest/module-federation-browser/host/global-setup.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { access, readFile } from 'node:fs/promises';
import { createServer, type Server } from 'node:http';
import { resolve } from 'node:path';

const remoteDir = resolve(__dirname, '../remote/dist');
const remoteEntry = `${remoteDir}/mf/remoteEntry.js`;
let server: Server;

export async function setup() {
await access(remoteEntry).catch(() => {
throw new Error(
`Remote entry not found: ${remoteEntry}. Run \`pnpm install\` or \`pnpm --dir ../remote build\`.`,
);
});
server = createServer(async (req, res) => {
try {
const body = await readFile(`${remoteDir}${req.url}`);
res.setHeader('access-control-allow-origin', '*');
if (req.url?.endsWith('.js')) res.setHeader('content-type', 'text/javascript');
res.writeHead(200).end(body);
} catch {
res.writeHead(404).end('Not found');
}
});
await new Promise<void>((resolveListen, rejectListen) => {
server.once('error', rejectListen);
server.listen(3001, resolveListen);
});
}

export async function teardown() {
await new Promise<void>((resolveClose, rejectClose) => {
server.close((error) => (error ? rejectClose(error) : resolveClose()));
});
}
12 changes: 12 additions & 0 deletions rstest/module-federation-browser/host/module-federation.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { dependencies } from './package.json';

export const mfConfig = {
name: 'host',
remotes: { remote: 'remote@http://localhost:3001/mf/remoteEntry.js' },
shared: {
react: { singleton: true, requiredVersion: dependencies.react },
'react-dom': { singleton: true, requiredVersion: dependencies['react-dom'] },
},
// Prefer the host's already loaded React; the remote is a production build. See https://rslib.rs/guide/advanced/module-federation#faqs
shareStrategy: 'loaded-first',
} as const;
29 changes: 29 additions & 0 deletions rstest/module-federation-browser/host/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
{
"name": "@rstest-example/module-federation-browser-host",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"build": "rsbuild build",
"dev": "rsbuild dev",
"test": "rstest"
},
"dependencies": {
"react": "^19.2.7",
"react-dom": "^19.2.7"
},
"devDependencies": {
"@module-federation/rsbuild-plugin": "^2.9.0",
"@module-federation/rstest": "^2.9.0",
"@rsbuild/core": "2.2.0",
"@rsbuild/plugin-react": "^2.1.0",
"@rstest/browser": "^0.11.12",
"@rstest/browser-react": "^0.11.12",
"@rstest/core": "^0.11.12",
"@types/node": "^24.12.4",
"@types/react": "^19.2.15",
"@types/react-dom": "^19.2.3",
"playwright": "^1.60.0",
"typescript": "^5.9.3"
}
}
11 changes: 11 additions & 0 deletions rstest/module-federation-browser/host/rsbuild.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { pluginModuleFederation } from '@module-federation/rsbuild-plugin';
import { defineConfig } from '@rsbuild/core';
import { pluginReact } from '@rsbuild/plugin-react';
import { mfConfig } from './module-federation.config';

export default defineConfig({
server: {
port: 3002,
},
plugins: [pluginReact(), pluginModuleFederation(mfConfig)],
});
31 changes: 31 additions & 0 deletions rstest/module-federation-browser/host/rstest.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { federation } from '@module-federation/rstest';
import { pluginReact } from '@rsbuild/plugin-react';
import { defineConfig } from '@rstest/core';
import { mfConfig } from './module-federation.config';

export default defineConfig({
// Required for globalSetup to run when @module-federation/rstest is used in browser mode; see README.
// https://github.com/web-infra-dev/rstest/blob/main/examples/federation/main-app/rstest.browser.config.ts
federation: true,
globalSetup: './global-setup.ts',
browser: {
enabled: true,
provider: 'playwright',
browser: 'chromium',
port: 3014,
providerOptions: process.env.GITHUB_ACTIONS
? {
launch: {
channel: 'chrome',
},
}
: undefined,
},
plugins: [
pluginReact(),
federation({
...mfConfig,
remoteType: 'script',
}),
],
});
14 changes: 14 additions & 0 deletions rstest/module-federation-browser/host/src/App.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { Suspense, lazy } from 'react';

const RemoteButton = lazy(() => import('remote/Button'));

export default function App() {
return (
<main>
<h1>Module Federation Host</h1>
<Suspense fallback={<p>Loading remote…</p>}>
<RemoteButton label="Federated button" />
</Suspense>
</main>
);
}
4 changes: 4 additions & 0 deletions rstest/module-federation-browser/host/src/bootstrap.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import { createRoot } from 'react-dom/client';
import App from './App';

createRoot(document.getElementById('root')!).render(<App />);
2 changes: 2 additions & 0 deletions rstest/module-federation-browser/host/src/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
// Async boundary so shared modules are ready before the app renders.
import('./bootstrap');
6 changes: 6 additions & 0 deletions rstest/module-federation-browser/host/src/remotes.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
declare module 'remote/Button' {
import type { ComponentType } from 'react';

const Button: ComponentType<{ label?: string }>;
export default Button;
}
15 changes: 15 additions & 0 deletions rstest/module-federation-browser/host/tests/button.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { page } from '@rstest/browser';
import { render } from '@rstest/browser-react';
import { expect, it } from '@rstest/core';
import Button from 'remote/Button';

it('renders and clicks the federated Button in the browser', { timeout: 15_000 }, async () => {
await render(<Button label="Federated button" />);

const button = page.getByRole('button', { name: /Federated button/ });
await expect.element(button).toHaveText('Federated button (clicked 0)');

await button.click();

await expect.element(button).toHaveText('Federated button (clicked 1)');
});
24 changes: 24 additions & 0 deletions rstest/module-federation-browser/host/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
{
"compilerOptions": {
"target": "ES2020",
"lib": ["DOM", "ES2020"],
"module": "ESNext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"jsx": "react-jsx",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"isolatedModules": true,
"noEmit": true,
"types": ["@rsbuild/core/types", "node", "@rstest/core"]
},
"include": [
"src",
"tests",
"global-setup.ts",
"module-federation.config.ts",
"rsbuild.config.ts",
"rstest.config.ts"
]
}
31 changes: 31 additions & 0 deletions rstest/module-federation-browser/remote/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
{
"name": "@rstest-example/module-federation-browser-remote",
"version": "1.0.0",
"private": true,
"type": "module",
"exports": {
".": {
"types": "./dist/esm/index.d.ts",
"import": "./dist/esm/index.js"
}
},
"module": "./dist/esm/index.js",
"types": "./dist/esm/index.d.ts",
"scripts": {
"build": "rslib",
"dev": "rslib mf-dev",
"prepare": "pnpm run build"
},
"devDependencies": {
"@module-federation/rsbuild-plugin": "^2.9.0",
"@rsbuild/plugin-react": "^2.1.0",
"@rslib/core": "^1.0.0",
"@types/react": "^19.2.15",
"react": "^19.2.7",
"react-dom": "^19.2.7",
"typescript": "^5.9.3"
},
"peerDependencies": {
"react": "*"
}
}
53 changes: 53 additions & 0 deletions rstest/module-federation-browser/remote/rslib.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { pluginModuleFederation } from '@module-federation/rsbuild-plugin';
import { pluginReact } from '@rsbuild/plugin-react';
import { defineConfig } from '@rslib/core';
import { devDependencies } from './package.json';

export default defineConfig({
lib: [
{
format: 'esm',
dts: true,
output: {
distPath: './dist/esm',
},
},
{
format: 'mf',
output: {
distPath: './dist/mf',
// Chunks are fetched by the host page, so they must resolve against
// the remote's own origin instead of the host's.
assetPrefix: 'http://localhost:3001/mf',
},
// `rslib mf-dev` serves the container from the same URL.
dev: {
assetPrefix: 'http://localhost:3001/mf',
},
plugins: [
pluginModuleFederation(
{
name: 'remote',
filename: 'remoteEntry.js',
exposes: {
'./Button': './src/Button.tsx',
},
shared: {
react: { singleton: true, requiredVersion: devDependencies.react },
'react-dom': { singleton: true, requiredVersion: devDependencies['react-dom'] },
},
},
{},
),
],
},
],
output: {
target: 'web',
},
// just for dev
server: {
port: 3001,
},
plugins: [pluginReact()],
});
15 changes: 15 additions & 0 deletions rstest/module-federation-browser/remote/src/Button.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { useState } from 'react';

export interface ButtonProps {
label?: string;
}

export default function Button({ label = 'Federated button' }: ButtonProps) {
const [count, setCount] = useState(0);

return (
<button type="button" className="remote-button" onClick={() => setCount((value) => value + 1)}>
{label} (clicked {count})
</button>
);
}
2 changes: 2 additions & 0 deletions rstest/module-federation-browser/remote/src/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export { default as Button } from './Button';
export type { ButtonProps } from './Button';
15 changes: 15 additions & 0 deletions rstest/module-federation-browser/remote/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
{
"compilerOptions": {
"lib": ["DOM", "ES2022"],
"module": "ESNext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"jsx": "react-jsx",
"noEmit": true,
"strict": true,
"skipLibCheck": true,
"isolatedModules": true,
"types": ["@rslib/core/types"]
},
"include": ["src"]
}
22 changes: 22 additions & 0 deletions rstest/module-federation-node/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# Rstest Module Federation (Node) Example

Test a local CommonJS remote with [`@module-federation/rstest`](https://module-federation.io/), without an HTTP server.

- `remote/` builds an ESM library with declarations and uses Rslib's `mf` format to expose `remote/formatPrice` and `remote/math`.
- `host/` loads the built container from disk and tests the exposed functions.

## What it tests

- Functions exposed by a Node Module Federation remote can be called from an Rstest test without an HTTP server: `host/tests/federated-modules.test.ts` imports `remote/formatPrice` and `remote/math` and checks their results.
- Federated modules are loaded with dynamic `import()` through their specifiers, so the Module Federation runtime initializes the container first, matching upstream's [`NodeLocal.dynamic.test.tsx`](https://github.com/web-infra-dev/rstest/blob/main/examples/federation/main-app/test/NodeLocal.dynamic.test.tsx).

## Run the example

From `host/`:

```bash
pnpm install
pnpm test
```

For a remote served over HTTP to a real browser, see the [browser example](../module-federation-browser).
Loading
Loading