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

; +}