diff --git a/e2e/integration/server-css/client/App.tsx b/e2e/integration/server-css/client/App.tsx
new file mode 100644
index 0000000..40fd565
--- /dev/null
+++ b/e2e/integration/server-css/client/App.tsx
@@ -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 {
+ return createFromFetch(
+ fetch(url, {
+ headers: {
+ Accept: 'text/x-component',
+ },
+ }),
+ );
+}
+
+let request:
+ | {
+ url: string;
+ response: Promise;
+ }
+ | 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 (
+
+ Client shell
+ Loading RSC
}>
+
+
+
+ );
+}
diff --git a/e2e/integration/server-css/client/index.tsx b/e2e/integration/server-css/client/index.tsx
new file mode 100644
index 0000000..6815f51
--- /dev/null
+++ b/e2e/integration/server-css/client/index.tsx
@@ -0,0 +1,5 @@
+import { createRoot } from 'react-dom/client';
+import { App } from './App';
+
+const root = createRoot(document.getElementById('root')!);
+root.render();
diff --git a/e2e/integration/server-css/index.test.ts b/e2e/integration/server-css/index.test.ts
new file mode 100644
index 0000000..b45ffa6
--- /dev/null
+++ b/e2e/integration/server-css/index.test.ts
@@ -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)',
+ );
+});
diff --git a/e2e/integration/server-css/rsbuild.config.ts b/e2e/integration/server-css/rsbuild.config.ts
new file mode 100644
index 0000000..505145b
--- /dev/null
+++ b/e2e/integration/server-css/rsbuild.config.ts
@@ -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);
+ });
+ },
+ },
+});
diff --git a/e2e/integration/server-css/server/Root.css b/e2e/integration/server-css/server/Root.css
new file mode 100644
index 0000000..7408b35
--- /dev/null
+++ b/e2e/integration/server-css/server/Root.css
@@ -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;
+}
diff --git a/e2e/integration/server-css/server/Root.tsx b/e2e/integration/server-css/server/Root.tsx
new file mode 100644
index 0000000..a4aef7a
--- /dev/null
+++ b/e2e/integration/server-css/server/Root.tsx
@@ -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 (
+
+ Server CSS root
+ This root stylesheet is not owned by a server-entry component.
+
+
+ Shared stylesheet from root
+
+ {children}
+
+ );
+}
diff --git a/e2e/integration/server-css/server/Shared.css b/e2e/integration/server-css/server/Shared.css
new file mode 100644
index 0000000..efa36f4
--- /dev/null
+++ b/e2e/integration/server-css/server/Shared.css
@@ -0,0 +1,3 @@
+.shared-server-css {
+ color: rgb(46, 139, 87);
+}
diff --git a/e2e/integration/server-css/server/index.tsx b/e2e/integration/server-css/server/index.tsx
new file mode 100644
index 0000000..cdbc9cd
--- /dev/null
+++ b/e2e/integration/server-css/server/index.tsx
@@ -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 {
+ const page1CssFiles =
+ (Page1 as ServerEntry).entryCssFiles ?? [];
+ const page2CssFiles =
+ (Page2 as ServerEntry).entryCssFiles ?? [];
+ const activePageCssFiles =
+ activePage === 'page1' ? page1CssFiles : page2CssFiles;
+ const page = activePage === 'page1' ? : ;
+ const cssLinks = activePageCssFiles.map((href) => (
+
+ ));
+ const rscStream = renderToReadableStream(
+ <>
+ {cssLinks}
+ {page}
+ >,
+ );
+
+ 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,
+ next: () => void,
+ ) {
+ if (req.headers.accept?.includes('text/x-component')) {
+ await toNodeHandler(() =>
+ handler(getActivePage(req.url, req.headers.host)),
+ )(req, res);
+ return;
+ }
+
+ next();
+ },
+};
diff --git a/e2e/integration/server-css/server/pages/Page1.css b/e2e/integration/server-css/server/pages/Page1.css
new file mode 100644
index 0000000..bb77106
--- /dev/null
+++ b/e2e/integration/server-css/server/pages/Page1.css
@@ -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;
+}
diff --git a/e2e/integration/server-css/server/pages/Page1.tsx b/e2e/integration/server-css/server/pages/Page1.tsx
new file mode 100644
index 0000000..94f0f09
--- /dev/null
+++ b/e2e/integration/server-css/server/pages/Page1.tsx
@@ -0,0 +1,17 @@
+'use server-entry';
+
+import { Page1Child } from './Page1Child';
+import './Page1.css';
+import '../Shared.css';
+
+export async function Page1() {
+ return (
+
+ Page 1
+
+ Shared stylesheet from Page 1
+
+
+
+ );
+}
diff --git a/e2e/integration/server-css/server/pages/Page1Child.css b/e2e/integration/server-css/server/pages/Page1Child.css
new file mode 100644
index 0000000..979bdc1
--- /dev/null
+++ b/e2e/integration/server-css/server/pages/Page1Child.css
@@ -0,0 +1,5 @@
+.page-one-child-css {
+ margin: 0;
+ color: rgb(0, 104, 155);
+ font-weight: 700;
+}
diff --git a/e2e/integration/server-css/server/pages/Page1Child.tsx b/e2e/integration/server-css/server/pages/Page1Child.tsx
new file mode 100644
index 0000000..7a2755a
--- /dev/null
+++ b/e2e/integration/server-css/server/pages/Page1Child.tsx
@@ -0,0 +1,5 @@
+import './Page1Child.css';
+
+export function Page1Child() {
+ return Page 1 child CSS
;
+}
diff --git a/e2e/integration/server-css/server/pages/Page2.css b/e2e/integration/server-css/server/pages/Page2.css
new file mode 100644
index 0000000..2a48509
--- /dev/null
+++ b/e2e/integration/server-css/server/pages/Page2.css
@@ -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;
+}
diff --git a/e2e/integration/server-css/server/pages/Page2.tsx b/e2e/integration/server-css/server/pages/Page2.tsx
new file mode 100644
index 0000000..35efb6c
--- /dev/null
+++ b/e2e/integration/server-css/server/pages/Page2.tsx
@@ -0,0 +1,16 @@
+'use server-entry';
+
+import { Page2Child } from './Page2Child';
+import './Page2.css';
+
+export async function Page2() {
+ return (
+
+ Page 2
+
+ Shared stylesheet from Page 2
+
+
+
+ );
+}
diff --git a/e2e/integration/server-css/server/pages/Page2Child.css b/e2e/integration/server-css/server/pages/Page2Child.css
new file mode 100644
index 0000000..4b2fa79
--- /dev/null
+++ b/e2e/integration/server-css/server/pages/Page2Child.css
@@ -0,0 +1,5 @@
+.page-two-child-css {
+ margin: 0;
+ color: rgb(180, 90, 0);
+ font-weight: 700;
+}
diff --git a/e2e/integration/server-css/server/pages/Page2Child.tsx b/e2e/integration/server-css/server/pages/Page2Child.tsx
new file mode 100644
index 0000000..fc0a07e
--- /dev/null
+++ b/e2e/integration/server-css/server/pages/Page2Child.tsx
@@ -0,0 +1,5 @@
+import './Page2Child.css';
+
+export function Page2Child() {
+ return Page 2 child CSS
;
+}