Skip to content
Draft
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
41 changes: 41 additions & 0 deletions e2e/integration/server-css/client/App.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { type ReactElement, Suspense, use } from 'react';
import { createFromFetch } from 'react-server-dom-rspack/client.browser';

function fetchRSC(url: string): Promise<ReactElement> {
return createFromFetch(
fetch(url, {
headers: {
Accept: 'text/x-component',
},
}),
);
}

let request:
| {
url: string;
response: Promise<ReactElement>;
}
| undefined;

function ServerContent() {
const url = `${window.location.pathname}${window.location.search}`;
if (!request || request.url !== url) {
request = {
url,
response: fetchRSC(url),
};
}
return use(request.response);
}

export function App() {
return (
<main>
<h1>Client shell</h1>
<Suspense fallback={<p>Loading RSC</p>}>
<ServerContent />
</Suspense>
</main>
);
}
5 changes: 5 additions & 0 deletions e2e/integration/server-css/client/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { createRoot } from 'react-dom/client';
import { App } from './App';

const root = createRoot(document.getElementById('root')!);
root.render(<App />);
99 changes: 99 additions & 0 deletions e2e/integration/server-css/index.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
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);

type ServerCssPage = 'page1' | 'page2';

const startApp = async (dev: Dev, build: Build) =>
process.env.TEST_MODE === 'dev'
? await dev({ cwd: PROJECT_DIR })
: await build({ cwd: PROJECT_DIR, runServer: true });

const gotoPage = (
page: Page,
port: number,
serverCssPage: ServerCssPage,
) => page.goto(`http://localhost:${port}/?page=${serverCssPage}`);

const expectPageShell = async (page: Page, activePage: ServerCssPage) => {
await expect(
page.getByRole('heading', { name: 'Client shell' }),
).toBeVisible();
await expect(
page.getByRole('heading', { name: 'Server CSS root' }),
).toBeVisible();
const pageOneLink = page.getByRole('link', { name: 'Page 1' });
const pageTwoLink = page.getByRole('link', { name: 'Page 2' });
if (activePage === 'page1') {
await expect(pageOneLink).toHaveAttribute('aria-current', 'page');
await expect(pageTwoLink).not.toHaveAttribute('aria-current', 'page');
} else {
await expect(pageTwoLink).toHaveAttribute('aria-current', 'page');
await expect(pageOneLink).not.toHaveAttribute('aria-current', 'page');
}
};

test('should apply root CSS and server-entry page CSS by route', async ({
page,
dev,
build,
}) => {
const rsbuild = await startApp(dev, build);

await gotoPage(page, rsbuild.port, 'page1');
await expectPageShell(page, 'page1');
await expect(page.getByRole('heading', { name: 'Page 1' })).toBeVisible();
await expect(page.getByRole('heading', { name: 'Page 2' })).toHaveCount(0);
await expect(page.getByTestId('server-css-shared-root')).toHaveText(
'Shared stylesheet from root',
);
await expect(page.getByTestId('server-css-shared-page-one')).toHaveText(
'Shared stylesheet from Page 1',
);
await expect(page.getByTestId('server-css-shared-root')).toHaveCSS(
'color',
'rgb(46, 139, 87)',
);
await expect(page.getByTestId('server-css-page-one')).toHaveCSS(
'background-color',
'rgb(230, 244, 255)',
);
await expect(page.locator('.page-one-child-css')).toHaveCSS(
'color',
'rgb(0, 104, 155)',
);
await expect(page.getByTestId('server-css-shared-page-one')).toHaveCSS(
'color',
'rgb(46, 139, 87)',
);

await gotoPage(page, rsbuild.port, 'page2');
await expectPageShell(page, 'page2');
await expect(page.getByRole('heading', { name: 'Page 2' })).toBeVisible();
await expect(page.getByRole('heading', { name: 'Page 1' })).toHaveCount(0);
await expect(page.getByTestId('server-css-shared-root')).toHaveText(
'Shared stylesheet from root',
);
await expect(page.getByTestId('server-css-shared-page-one')).toHaveCount(0);
await expect(page.getByTestId('server-css-shared-page-two')).toHaveText(
'Shared stylesheet from Page 2',
);
await expect(page.getByTestId('server-css-shared-root')).toHaveCSS(
'color',
'rgb(46, 139, 87)',
);
await expect(page.getByTestId('server-css-shared-page-two')).toHaveCSS(
'color',
'rgb(46, 139, 87)',
);
await expect(page.getByTestId('server-css-page-two')).toHaveCSS(
'background-color',
'rgb(255, 240, 230)',
);
await expect(page.locator('.page-two-child-css')).toHaveCSS(
'color',
'rgb(180, 90, 0)',
);
});
42 changes: 42 additions & 0 deletions e2e/integration/server-css/rsbuild.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { defineConfig } from '@rsbuild/core';
import { pluginReact } from '@rsbuild/plugin-react';
import { Layers, pluginRSC } from 'rsbuild-plugin-rsc';
import type ServerModule from './server';

export default defineConfig({
plugins: [pluginReact(), pluginRSC()],
environments: {
server: {
source: {
entry: {
index: {
import: './server/index.tsx',
layer: Layers.rsc,
},
},
},
},
client: {
source: {
entry: {
index: './client/index.tsx',
},
},
},
},
server: {
setup: ({ server }) => {
server.middlewares.use(async (req, res, next) => {
if (!req.headers.accept?.includes('text/x-component')) {
next();
return;
}

const indexModule = await server.environments.server.loadBundle<{
default: typeof ServerModule;
}>('index');
await indexModule.default.nodeHandler(req, res, next);
});
},
},
});
26 changes: 26 additions & 0 deletions e2e/integration/server-css/server/Root.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
.server-css-root {
box-sizing: border-box;
width: min(720px, 100%);
margin: 32px;
padding: 24px;
color: rgb(29, 48, 43);
background-color: rgb(224, 250, 238);
border: 6px solid rgb(45, 103, 90);
border-radius: 16px;
box-shadow: 0 18px 36px rgba(45, 103, 90, 0.3);
}

.server-css-root h2 {
margin: 0 0 8px;
font-size: 24px;
}

.server-css-root p {
margin: 0 0 18px;
}

.server-css-pages {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 16px;
}
37 changes: 37 additions & 0 deletions e2e/integration/server-css/server/Root.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import type { ReactNode } from 'react';
import './Root.css';
import './Shared.css';

type ServerCssPage = 'page1' | 'page2';

type RootProps = {
activePage: ServerCssPage;
children: ReactNode;
};

export async function Root({ activePage, children }: RootProps) {
return (
<section className="server-css-root" data-testid="server-css-root">
<h2>Server CSS root</h2>
<p>This root stylesheet is not owned by a server-entry component.</p>
<nav aria-label="Server CSS pages">
<a
href="/?page=page1"
aria-current={activePage === 'page1' ? 'page' : undefined}
>
Page 1
</a>
<a
href="/?page=page2"
aria-current={activePage === 'page2' ? 'page' : undefined}
>
Page 2
</a>
</nav>
<p className="shared-server-css" data-testid="server-css-shared-root">
Shared stylesheet from root
</p>
<div className="server-css-pages">{children}</div>
</section>
);
}
3 changes: 3 additions & 0 deletions e2e/integration/server-css/server/Shared.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
.shared-server-css {
color: rgb(46, 139, 87);
}
75 changes: 75 additions & 0 deletions e2e/integration/server-css/server/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import type { IncomingMessage, ServerResponse } from 'node:http';
import {
renderToReadableStream,
type ServerEntry,
} from 'react-server-dom-rspack/server.node';
import { toNodeHandler } from 'srvx/node';
// Keep Page1 and Page2 before Root to cover shared CSS ownership. Page1
// reaches Shared.css through a server-entry parent chain first, then Root must
// still make its `import './Shared.css'` root-owned so it is loaded by the
// client entry instead of duplicated in server-entry CSS metadata.
import { Page1 } from './pages/Page1';
import { Page2 } from './pages/Page2';
import { Root } from './Root';

type ServerCssPage = 'page1' | 'page2';

function getActivePage(
url: string | undefined,
host: string | undefined,
): ServerCssPage {
const requestUrl = new URL(url ?? '/', `http://${host ?? 'localhost'}`);
return requestUrl.searchParams.get('page') === 'page2' ? 'page2' : 'page1';
}

async function handler(activePage: ServerCssPage): Promise<Response> {
const page1CssFiles =
(Page1 as ServerEntry<typeof Page1>).entryCssFiles ?? [];
const page2CssFiles =
(Page2 as ServerEntry<typeof Page2>).entryCssFiles ?? [];
const activePageCssFiles =
activePage === 'page1' ? page1CssFiles : page2CssFiles;
const page = activePage === 'page1' ? <Page1 /> : <Page2 />;
const cssLinks = activePageCssFiles.map((href) => (
<link
key={href}
rel="stylesheet"
href={href}
precedence="default"
data-testid="server-entry-css"
/>
));
const rscStream = renderToReadableStream(
<>
{cssLinks}
<Root activePage={activePage}>{page}</Root>
</>,
);

return new Response(rscStream, {
headers: {
'content-type': 'text/x-component;charset=utf-8',
},
});
}

export default {
fetch(request: Request) {
const url = new URL(request.url);
return handler(getActivePage(`${url.pathname}${url.search}`, url.host));
},
async nodeHandler(
req: IncomingMessage,
res: ServerResponse<IncomingMessage>,
next: () => void,
) {
if (req.headers.accept?.includes('text/x-component')) {
await toNodeHandler(() =>
handler(getActivePage(req.url, req.headers.host)),
)(req, res);
return;
}

next();
},
};
13 changes: 13 additions & 0 deletions e2e/integration/server-css/server/pages/Page1.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
.page-one-css {
box-sizing: border-box;
padding: 18px;
color: rgb(22, 50, 68);
background-color: rgb(230, 244, 255);
border: 5px solid rgb(70, 130, 180);
border-radius: 12px;
}

.page-one-css h3 {
margin: 0 0 8px;
font-size: 20px;
}
17 changes: 17 additions & 0 deletions e2e/integration/server-css/server/pages/Page1.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
'use server-entry';

import { Page1Child } from './Page1Child';
import './Page1.css';
import '../Shared.css';

export async function Page1() {
return (
<section className="page-one-css" data-testid="server-css-page-one">
<h3>Page 1</h3>
<p className="shared-server-css" data-testid="server-css-shared-page-one">
Shared stylesheet from Page 1
</p>
<Page1Child />
</section>
);
}
5 changes: 5 additions & 0 deletions e2e/integration/server-css/server/pages/Page1Child.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
.page-one-child-css {
margin: 0;
color: rgb(0, 104, 155);
font-weight: 700;
}
5 changes: 5 additions & 0 deletions e2e/integration/server-css/server/pages/Page1Child.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import './Page1Child.css';

export function Page1Child() {
return <p className="page-one-child-css">Page 1 child CSS</p>;
}
13 changes: 13 additions & 0 deletions e2e/integration/server-css/server/pages/Page2.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
.page-two-css {
box-sizing: border-box;
padding: 18px;
color: rgb(70, 24, 34);
background-color: rgb(255, 240, 230);
border: 5px solid rgb(220, 20, 60);
border-radius: 12px;
}

.page-two-css h3 {
margin: 0 0 8px;
font-size: 20px;
}
Loading
Loading