Skip to content
Merged
Show file tree
Hide file tree
Changes from 9 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
14 changes: 13 additions & 1 deletion e2e/helper/jsApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,11 @@ export type BuildOptions = CreateRsbuildOptions & {
* @default false
*/
runServer?: boolean;
/**
* Whether to start preview server after build.
* @default false
*/
preview?: boolean;
/**
* Playwright Page instance.
* This method will automatically run the server and goto the page.
Expand All @@ -182,6 +187,7 @@ export type BuildOptions = CreateRsbuildOptions & {
export async function build({
catchBuildError = false,
runServer = false,
preview = false,
watch = false,
page,
logHelper,
Expand Down Expand Up @@ -213,6 +219,12 @@ export async function build({
let port = 0;
let server = { close: noop };

if (preview) {
const result = await rsbuild.preview();
port = result.port;
server = result.server;
}

if (runServer) {
port = await getRandomPort();

Expand All @@ -234,7 +246,7 @@ export async function build({
resolve();
});
server = {
close() {
async close() {
theServer.close();
},
Comment thread
SyMind marked this conversation as resolved.
};
Expand Down
1 change: 1 addition & 0 deletions e2e/integration/counter-app/index.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import path from 'node:path';
import { type Build, type Dev, expect, test } from '@e2e/helper';
import type { Page } from 'playwright';

const PROJECT_DIR = path.resolve(
import.meta.dirname,
Expand Down
125 changes: 125 additions & 0 deletions e2e/integration/static-app/index.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
import path from 'node:path';
import { type Build, type Dev, expect, test } from '@e2e/helper';
import type { Page } from 'playwright';

const PROJECT_DIR = path.resolve(
import.meta.dirname,
'../../../examples/static',
);

const setup = async (dev: Dev, build: Build, page: Page) => {
const rsbuild =
process.env.TEST_MODE === 'dev'
? await dev({ cwd: PROJECT_DIR })
: await build({ cwd: PROJECT_DIR, preview: true });

await page.goto(`http://localhost:${rsbuild.port}`);
return rsbuild;
};

test('should load the page and display the title', async ({
page,
dev,
build,
}) => {
await setup(dev, build, page);

await expect(page).toHaveTitle('Static RSC');

const heading = page.locator('h1');
await expect(heading).toBeVisible();
await expect(heading).toHaveText('This is an RSC!');

const links = page.locator('link[rel="stylesheet"]');
await expect(links).toHaveCount(1);
});

test('should display and interact with Counter component', async ({
page,
dev,
build,
}) => {
await setup(dev, build, page);

const counterButton = page.locator('button:has-text("Count:")');
await expect(counterButton).toBeVisible();
await expect(counterButton).toHaveText('Count: 0');

await counterButton.click();
await expect(counterButton).toHaveText('Count: 1');

await counterButton.click();
await expect(counterButton).toHaveText('Count: 2');

await counterButton.click();
await expect(counterButton).toHaveText('Count: 3');
});

test('should navigate to Other page via client-side navigation', async ({
page,
dev,
build,
}) => {
await setup(dev, build, page);

// Click the "Other" nav link
const otherLink = page.locator('nav a:has-text("Other")');
await expect(otherLink).toBeVisible();
await otherLink.click();

// Verify heading changes
const heading = page.locator('h1');
await expect(heading).toHaveText('This is another RSC!');

// Verify URL changed
await expect(page).toHaveURL(/\/other$/);
});

test('should apply CSS styles to current nav link', async ({
page,
dev,
build,
}) => {
await setup(dev, build, page);

const currentLink = page.locator('nav a[aria-current="page"]');
await expect(currentLink).toBeVisible();

const backgroundColor = await currentLink.evaluate((el) =>
window.getComputedStyle(el).getPropertyValue('background-color'),
);
expect(backgroundColor).toBe('rgb(0, 0, 0)');

const color = await currentLink.evaluate((el) =>
window.getComputedStyle(el).getPropertyValue('color'),
);
expect(color).toBe('rgb(255, 255, 255)');
});

test('should reset counter state on navigation', async ({
page,
dev,
build,
}) => {
await setup(dev, build, page);

// Increment counter
const counterButton = page.locator('button:has-text("Count:")');
await counterButton.click();
await counterButton.click();
await expect(counterButton).toHaveText('Count: 2');

// Navigate to Other page
const otherLink = page.locator('nav a:has-text("Other")');
await otherLink.click();
await expect(page.locator('h1')).toHaveText('This is another RSC!');

// Navigate back to Index page
const indexLink = page.locator('nav a:has-text("Index")');
await indexLink.click();
await expect(page.locator('h1')).toHaveText('This is an RSC!');

// Counter should be reset
const resetCounter = page.locator('button:has-text("Count:")');
await expect(resetCounter).toHaveText('Count: 0');
});
1 change: 1 addition & 0 deletions e2e/integration/todos-app/index.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import path from 'node:path';
import { type Build, type Dev, expect, test } from '@e2e/helper';
import type { Page } from 'playwright';

const PROJECT_DIR = path.resolve(
import.meta.dirname,
Expand Down
185 changes: 172 additions & 13 deletions examples/static/README.md
Original file line number Diff line number Diff line change
@@ -1,25 +1,184 @@
# Parcel RSC Static Site Generator Example
# Rsbuild RSC Static Rendering Example

This example is a simple static site generator built with Parcel and React Server Components.
This example demonstrates a multi-page static site built with Rsbuild and React Server Components (RSC). It showcases file-based page routing, static site generation (SSG), client-side hydration, and client-side navigation between pages — all powered by `rsbuild-plugin-rsc`.

Note: the plugins used in this example will move into Parcel eventually.
## Getting Started

## Setup
```bash
# Start development server
pnpm dev

### pages/*.tsx
# Build for production (includes static HTML generation)
pnpm build

These are the entry points of the build. They are React Server Components that render the root `<html>` element of the page, and any other client or server components. A Parcel packager plugin executes the server components during the build to render them to static HTML. A separate `.rsc` file for each page is also generated for use during client side navigations.
# Preview the production build
pnpm preview
```

### components/client.tsx
## Project Structure

This is the main client entrypoint, imported from each page. It uses the Parcel-specific `"use client-entry"` directive to mark that it should only run on the client, and not on the server (even during SSR). The client is responsible for hydrating the initial page, and intercepting link clicks and navigations to perform client side routing.
```
src/
├── pages/ # Page components (file-based routing)
│ ├── Index.tsx
│ └── Other.tsx
├── components/ # Shared components
│ ├── Counter.tsx # Client component
│ ├── Nav.tsx # Server component
│ └── style.css
└── framework/ # Framework layer
├── entry.rsc.tsx # RSC entrypoint (server)
├── entry.ssr.tsx # SSR entrypoint (server)
├── entry.client.tsx # Client entrypoint (browser)
├── request.tsx # RSC/SSR request routing
├── shared.tsx # Shared types
└── ssg.tsx # Page types
generate.mjs # Post-build static generation script
```

See the [client side routing](../server/README.md#client-side-routing) section of the server readme for a description of how this works. One difference is that we fetch statically pre-generated `.rsc` files instead of dynamically generated content from the server.
### Build Output

### components/Counter.tsx
After `pnpm build`, the `dist/` directory contains a fully static site:

This is a client component.
```
dist/
├── index.html # Pre-rendered HTML for /index
├── other.html # Pre-rendered HTML for /other
├── index_.rsc # RSC payload for client-side navigation
├── other_.rsc # RSC payload for client-side navigation
├── static/
│ ├── js/ # Client JavaScript bundles
│ └── css/ # Client CSS bundles
└── server/
└── index.js # Server bundle (used during generation only)
```

### components/Nav.tsx
### Page Components (`src/pages/*.tsx`)

This is a server component that renders a list of links to all of the pages on the site. This is passed as a prop to each entry page component by the SSG packager plugin, and then through to this component.
Each file under `src/pages/` represents a page. Pages are React Server Components that render the full `<html>` document tree using the `"use server-entry"` directive. The file name determines the route (e.g., `Index.tsx` → `/index`, `Other.tsx` → `/other`).

```tsx
'use server-entry';

import { Counter } from '../components/Counter';
import { Nav } from '../components/Nav';

export default function Index({ pages, currentPage }: PageProps) {
return (
<html lang="en">
<head><title>Static RSC</title></head>
<body>
<h1>This is an RSC!</h1>
<Nav pages={pages} currentPage={currentPage} />
<Counter />
</body>
</html>
);
}
```

Pages are automatically discovered at build time via `import.meta.webpackContext` in `entry.rsc.tsx`, so adding a new `.tsx` file to `src/pages/` is all that's needed to create a new route.

### Server Components (`src/components/Nav.tsx`)

Server components run only on the server. `Nav` renders a list of links for all pages, highlighting the current page via `aria-current`.

### Client Components (`src/components/Counter.tsx`)

Client components use the `"use client"` directive and run in the browser. `Counter` demonstrates interactive state with `useState`.

## Framework Layer

### RSC Entrypoint (`src/framework/entry.rsc.tsx`)

The RSC entrypoint runs in the `react-server-components` layer. It:

1. Discovers all pages under `src/pages/` using `import.meta.webpackContext`
2. Exports `getStaticPaths()` to list all routes for static generation
3. Exports `renderStaticPage(route)` and `renderStaticRsc(route)` for SSG
4. Provides a dev server handler for development mode

CSS and JS bootstrap files are automatically resolved via `entryCssFiles` and `entryJsFiles` injected by the RSC plugin on `"use server-entry"` components.

### SSR Entrypoint (`src/framework/entry.ssr.tsx`)

The SSR entrypoint consumes the RSC stream and renders it to an HTML stream using `react-dom/server`. It supports both SSG (via `prerender()`) and runtime SSR (via `renderToReadableStream()`). The RSC payload is injected into the HTML via `rsc-html-stream` for seamless client hydration.

### Client Entrypoint (`src/framework/entry.client.tsx`)

The client entrypoint hydrates the server-rendered HTML using the embedded RSC payload. It also implements a simple client-side router that:

- Intercepts link clicks to perform client-side navigation
- Fetches pre-generated RSC payloads for new pages (via `_.rsc` URL convention)
- Updates the page without a full browser reload

## Rsbuild Configuration

The `rsbuild.config.ts` configures two environments and a static generation plugin:

- **server**: Builds `entry.rsc.tsx` with the RSC layer, outputs to `dist/server/`
- **client**: Builds `entry.client.tsx` for browser hydration and navigation
- **pluginStaticGenerate**: Runs `generate.mjs` after build to produce static HTML and RSC payloads

```ts
import { Layers, pluginRSC } from 'rsbuild-plugin-rsc';

export default defineConfig({
plugins: [
pluginReact(),
pluginRSC({
layers: {
ssr: path.join(import.meta.dirname, './src/framework/entry.ssr.tsx'),
},
}),
pluginStaticGenerate(),
],
environments: {
server: {
source: {
entry: {
index: {
import: './src/framework/entry.rsc.tsx',
layer: Layers.rsc,
},
},
},
output: {
distPath: { root: 'dist/server' },
},
},
client: {
source: {
entry: {
index: './src/framework/entry.client.tsx',
},
},
},
},
});
```

## How It Works

### Build-Time Static Generation (SSG)

1. Rsbuild builds both the server bundle and client assets
2. The `pluginStaticGenerate` plugin runs `generate.mjs` after the build
3. `generate.mjs` loads the server bundle and calls `getStaticPaths()` to discover all routes
4. For each route, it generates:
- An HTML file with pre-rendered content, embedded RSC payload, and client bootstrap scripts/CSS
- An RSC payload file (`_.rsc`) for client-side navigation
5. The result is a fully static site that can be served by any static file server

### Initial Page Load

1. Browser requests a URL (e.g., `/index.html`)
2. The browser receives pre-rendered HTML with embedded RSC payload
3. Client JavaScript hydrates the page using the embedded RSC payload

### Client-Side Navigation

1. User clicks a link (e.g., from `Index` to `Other`)
2. The client router intercepts the click and calls `history.pushState`
3. The client fetches the pre-generated RSC payload for the new page (e.g., `/other_.rsc`)
4. React updates the page in-place without a full reload
Loading
Loading