diff --git a/.changeset/fair-assets-align.md b/.changeset/fair-assets-align.md new file mode 100644 index 0000000000..a837187b98 --- /dev/null +++ b/.changeset/fair-assets-align.md @@ -0,0 +1,5 @@ +--- +'@tanstack/start-plugin-core': patch +--- + +Fix Rsbuild SSR asset URLs for `?url` imports by aligning server public asset paths with the client build. diff --git a/e2e/react-start/rsc-rsbuild/src/node_modules/rsc-client-pkg/index.js b/e2e/react-start/rsc-rsbuild/src/node_modules/rsc-client-pkg/index.js index 75080ca08a..066938631b 100644 --- a/e2e/react-start/rsc-rsbuild/src/node_modules/rsc-client-pkg/index.js +++ b/e2e/react-start/rsc-rsbuild/src/node_modules/rsc-client-pkg/index.js @@ -2,6 +2,24 @@ import * as React from 'react' +const buttonStyle = { + display: 'inline-flex', + alignItems: 'center', + justifyContent: 'center', + minHeight: '40px', + padding: '0 16px', + backgroundColor: '#16a34a', + border: '0', + borderRadius: '6px', + boxShadow: '0 1px 2px rgba(15, 23, 42, 0.16)', + color: 'white', + cursor: 'pointer', + fontFamily: + '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif', + fontSize: '13px', + fontWeight: 700, +} + export function NodeModuleClientWidget(props) { const [count, setCount] = React.useState(0) @@ -10,6 +28,7 @@ export function NodeModuleClientWidget(props) { { type: 'button', 'data-testid': 'node-module-client-widget', + style: buttonStyle, onClick: () => setCount((value) => value + 1), }, `${props.label}: ${count}`, diff --git a/e2e/react-start/rsc-rsbuild/src/routeTree.gen.ts b/e2e/react-start/rsc-rsbuild/src/routeTree.gen.ts index 84c964d11b..460e0af805 100644 --- a/e2e/react-start/rsc-rsbuild/src/routeTree.gen.ts +++ b/e2e/react-start/rsc-rsbuild/src/routeTree.gen.ts @@ -10,6 +10,7 @@ import { Route as rootRouteImport } from './routes/__root' import { Route as RscNodeModuleClientRouteImport } from './routes/rsc-node-module-client' +import { Route as RscCssUrlRouteImport } from './routes/rsc-css-url' import { Route as IndexRouteImport } from './routes/index' const RscNodeModuleClientRoute = RscNodeModuleClientRouteImport.update({ @@ -17,6 +18,11 @@ const RscNodeModuleClientRoute = RscNodeModuleClientRouteImport.update({ path: '/rsc-node-module-client', getParentRoute: () => rootRouteImport, } as any) +const RscCssUrlRoute = RscCssUrlRouteImport.update({ + id: '/rsc-css-url', + path: '/rsc-css-url', + getParentRoute: () => rootRouteImport, +} as any) const IndexRoute = IndexRouteImport.update({ id: '/', path: '/', @@ -25,27 +31,31 @@ const IndexRoute = IndexRouteImport.update({ export interface FileRoutesByFullPath { '/': typeof IndexRoute + '/rsc-css-url': typeof RscCssUrlRoute '/rsc-node-module-client': typeof RscNodeModuleClientRoute } export interface FileRoutesByTo { '/': typeof IndexRoute + '/rsc-css-url': typeof RscCssUrlRoute '/rsc-node-module-client': typeof RscNodeModuleClientRoute } export interface FileRoutesById { __root__: typeof rootRouteImport '/': typeof IndexRoute + '/rsc-css-url': typeof RscCssUrlRoute '/rsc-node-module-client': typeof RscNodeModuleClientRoute } export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath - fullPaths: '/' | '/rsc-node-module-client' + fullPaths: '/' | '/rsc-css-url' | '/rsc-node-module-client' fileRoutesByTo: FileRoutesByTo - to: '/' | '/rsc-node-module-client' - id: '__root__' | '/' | '/rsc-node-module-client' + to: '/' | '/rsc-css-url' | '/rsc-node-module-client' + id: '__root__' | '/' | '/rsc-css-url' | '/rsc-node-module-client' fileRoutesById: FileRoutesById } export interface RootRouteChildren { IndexRoute: typeof IndexRoute + RscCssUrlRoute: typeof RscCssUrlRoute RscNodeModuleClientRoute: typeof RscNodeModuleClientRoute } @@ -58,6 +68,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof RscNodeModuleClientRouteImport parentRoute: typeof rootRouteImport } + '/rsc-css-url': { + id: '/rsc-css-url' + path: '/rsc-css-url' + fullPath: '/rsc-css-url' + preLoaderRoute: typeof RscCssUrlRouteImport + parentRoute: typeof rootRouteImport + } '/': { id: '/' path: '/' @@ -70,6 +87,7 @@ declare module '@tanstack/react-router' { const rootRouteChildren: RootRouteChildren = { IndexRoute: IndexRoute, + RscCssUrlRoute: RscCssUrlRoute, RscNodeModuleClientRoute: RscNodeModuleClientRoute, } export const routeTree = rootRouteImport diff --git a/e2e/react-start/rsc-rsbuild/src/routes/index.tsx b/e2e/react-start/rsc-rsbuild/src/routes/index.tsx index d66168b65d..3b186c8f59 100644 --- a/e2e/react-start/rsc-rsbuild/src/routes/index.tsx +++ b/e2e/react-start/rsc-rsbuild/src/routes/index.tsx @@ -1,14 +1,160 @@ -import { Link, createFileRoute } from '@tanstack/react-router' +import { createFileRoute, Link, linkOptions } from '@tanstack/react-router' +import type { CSSProperties } from 'react' export const Route = createFileRoute('/')({ component: Home, }) +const examples = linkOptions([ + { + to: '/rsc-node-module-client', + title: 'Node module client component', + description: + 'Hydrates a client component imported from node_modules inside an RSC route.', + marker: 'RSC', + markerColor: '#0284c7', + }, + { + to: '/rsc-css-url', + title: 'css?url stylesheet', + description: + 'Applies a stylesheet imported with the ?url query from route head metadata.', + marker: 'CSS', + markerColor: '#16a34a', + }, +]) + +const colors = { + server: '#0284c7', + client: '#16a34a', + async: '#f59e0b', +} + +const styles = { + page: { + maxWidth: '800px', + padding: '20px', + fontFamily: + '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif', + }, + title: { + margin: '0 0 8px 0', + color: '#1e293b', + fontSize: '24px', + }, + description: { + color: '#64748b', + lineHeight: '1.5', + marginBottom: '20px', + }, + legend: { + display: 'flex', + gap: '16px', + marginBottom: '20px', + padding: '12px', + backgroundColor: '#f8fafc', + borderRadius: '8px', + flexWrap: 'wrap', + }, + legendItem: { + display: 'flex', + alignItems: 'center', + gap: '6px', + }, + legendText: { + color: '#475569', + fontSize: '13px', + }, + legendColor: { + width: '16px', + height: '16px', + borderRadius: '4px', + }, + grid: { + display: 'grid', + gap: '16px', + gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))', + }, + card: { + display: 'block', + padding: '16px', + backgroundColor: '#f8fafc', + borderRadius: '8px', + border: '1px solid #e2e8f0', + textDecoration: 'none', + }, + marker: { + display: 'grid', + placeItems: 'center', + width: '40px', + height: '32px', + marginBottom: '8px', + borderRadius: '6px', + color: 'white', + fontSize: '13px', + fontWeight: 'bold', + }, + cardTitle: { + marginBottom: '4px', + color: '#0f172a', + fontWeight: 'bold', + }, + cardDescription: { + color: '#64748b', + fontSize: '13px', + lineHeight: '1.4', + }, +} satisfies Record + +function legendColor(color: string): CSSProperties { + return { + ...styles.legendColor, + backgroundColor: color, + } +} + +function markerStyle(color: string): CSSProperties { + return { + ...styles.marker, + backgroundColor: color, + } +} + function Home() { return ( -
-

Rsbuild RSC fixture

- Node module client component +
+

+ React Server Components Rsbuild E2E Tests +

+

+ These examples cover Rsbuild-specific RSC behavior with clear visual + distinction between server-rendered content and client-side assets. +

+ +
+
+ + Server Rendered (RSC) +
+
+ + Client Hydration and Assets +
+
+ + Rsbuild SSR Coverage +
+
+ +
) } diff --git a/e2e/react-start/rsc-rsbuild/src/routes/rsc-css-url.css b/e2e/react-start/rsc-rsbuild/src/routes/rsc-css-url.css new file mode 100644 index 0000000000..c6b47196e6 --- /dev/null +++ b/e2e/react-start/rsc-rsbuild/src/routes/rsc-css-url.css @@ -0,0 +1,30 @@ +.rsc-css-url-card { + display: grid; + gap: 12px; + max-width: 480px; + padding: 18px; + background-color: #ecfdf5; + border: 2px solid #10b981; + border-radius: 8px; + color: #064e3b; +} + +.rsc-css-url-badge { + width: max-content; + padding: 3px 8px; + background-color: #047857; + border-radius: 4px; + color: #fff; + font-size: 11px; + font-weight: 700; +} + +.rsc-css-url-title { + margin: 0; + font-size: 18px; +} + +.rsc-css-url-text { + margin: 0; + line-height: 1.5; +} diff --git a/e2e/react-start/rsc-rsbuild/src/routes/rsc-css-url.tsx b/e2e/react-start/rsc-rsbuild/src/routes/rsc-css-url.tsx new file mode 100644 index 0000000000..563e2fb46c --- /dev/null +++ b/e2e/react-start/rsc-rsbuild/src/routes/rsc-css-url.tsx @@ -0,0 +1,24 @@ +import { createFileRoute } from '@tanstack/react-router' +import cssUrl from './rsc-css-url.css?url' + +export const Route = createFileRoute('/rsc-css-url')({ + head: () => ({ + links: [{ rel: 'stylesheet', href: cssUrl }], + }), + component: RscCssUrlComponent, +}) + +function RscCssUrlComponent() { + return ( +
+

RSC css?url stylesheet

+
+ CSS URL +

Stylesheet imported with ?url

+

+ The SSR head should point at a client-served asset URL. +

+
+
+ ) +} diff --git a/e2e/react-start/rsc-rsbuild/src/routes/rsc-node-module-client.tsx b/e2e/react-start/rsc-rsbuild/src/routes/rsc-node-module-client.tsx index 00994ffb50..d05ab99c0b 100644 --- a/e2e/react-start/rsc-rsbuild/src/routes/rsc-node-module-client.tsx +++ b/e2e/react-start/rsc-rsbuild/src/routes/rsc-node-module-client.tsx @@ -1,4 +1,5 @@ -import { createFileRoute } from '@tanstack/react-router' +import { createFileRoute, Link } from '@tanstack/react-router' +import type { CSSProperties } from 'react' import { getNodeModuleClientServerComponent } from '~/utils/nodeModuleClientServerComponent' export const Route = createFileRoute('/rsc-node-module-client')({ @@ -9,14 +10,82 @@ export const Route = createFileRoute('/rsc-node-module-client')({ component: RscNodeModuleClientComponent, }) +const styles = { + page: { + maxWidth: '800px', + padding: '20px', + fontFamily: + '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif', + }, + backLink: { + display: 'inline-block', + marginBottom: '16px', + color: '#0284c7', + fontSize: '13px', + fontWeight: 'bold', + textDecoration: 'none', + }, + title: { + margin: '0 0 8px 0', + color: '#1e293b', + fontSize: '24px', + }, + description: { + maxWidth: '680px', + margin: '0 0 20px 0', + color: '#64748b', + lineHeight: '1.5', + }, + summary: { + display: 'grid', + gap: '12px', + gridTemplateColumns: 'repeat(auto-fit, minmax(220px, 1fr))', + marginBottom: '20px', + }, + summaryItem: { + padding: '12px', + backgroundColor: '#f8fafc', + border: '1px solid #e2e8f0', + borderRadius: '8px', + }, + summaryLabel: { + marginBottom: '4px', + color: '#475569', + fontSize: '12px', + fontWeight: 'bold', + }, + summaryText: { + color: '#0f172a', + fontSize: '13px', + lineHeight: '1.4', + }, +} satisfies Record + function RscNodeModuleClientComponent() { const { Server } = Route.useLoaderData() return ( -
-

+
+ + Back to examples + +

RSC node_modules client component

+

+ This route renders a server component that imports a client component + from a package-style module and hydrates it on the client. +

+
+
+
Server source
+
Route loader + RSC server function
+
+
+
Client source
+
Component exported from node_modules
+
+
{Server}
) diff --git a/e2e/react-start/rsc-rsbuild/src/utils/RscClientPkgContent.tsx b/e2e/react-start/rsc-rsbuild/src/utils/RscClientPkgContent.tsx index 742f6cbc1a..68f83814f1 100644 --- a/e2e/react-start/rsc-rsbuild/src/utils/RscClientPkgContent.tsx +++ b/e2e/react-start/rsc-rsbuild/src/utils/RscClientPkgContent.tsx @@ -1,12 +1,79 @@ import { NodeModuleClientWidget } from 'rsc-client-pkg' +import type { CSSProperties } from 'react' + +const styles = { + container: { + padding: '16px', + backgroundColor: '#e0f2fe', + border: '2px solid #0284c7', + borderRadius: '8px', + }, + header: { + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + gap: '12px', + marginBottom: '12px', + }, + badge: { + display: 'inline-block', + padding: '2px 8px', + backgroundColor: '#0284c7', + borderRadius: '4px', + color: 'white', + fontSize: '11px', + fontWeight: 'bold', + }, + meta: { + color: '#64748b', + fontSize: '12px', + }, + title: { + margin: '0 0 8px 0', + color: '#0c4a6e', + fontSize: '18px', + }, + description: { + margin: '0 0 16px 0', + color: '#0369a1', + fontSize: '13px', + lineHeight: '1.5', + }, + clientSlot: { + padding: '14px', + backgroundColor: '#dcfce7', + border: '2px solid #16a34a', + borderRadius: '8px', + }, + clientLabel: { + marginBottom: '10px', + color: '#166534', + fontSize: '11px', + fontWeight: 'bold', + }, +} satisfies Record export function RscClientPkgContent() { return ( -
-

+
+
+ SERVER + Rendered before hydration +
+

Server rendered package boundary

- +

+ The server component owns this blue boundary, then hands the interactive + island below to a client component exported from a module package. +

+
+
CLIENT COMPONENT FROM NODE_MODULES
+ +
) } diff --git a/e2e/react-start/rsc-rsbuild/src/vite-env.d.ts b/e2e/react-start/rsc-rsbuild/src/vite-env.d.ts new file mode 100644 index 0000000000..0b2af560d6 --- /dev/null +++ b/e2e/react-start/rsc-rsbuild/src/vite-env.d.ts @@ -0,0 +1,4 @@ +declare module '*?url' { + const url: string + export default url +} diff --git a/e2e/react-start/rsc-rsbuild/tests/rsc-css-url.spec.ts b/e2e/react-start/rsc-rsbuild/tests/rsc-css-url.spec.ts new file mode 100644 index 0000000000..377c4e6011 --- /dev/null +++ b/e2e/react-start/rsc-rsbuild/tests/rsc-css-url.spec.ts @@ -0,0 +1,15 @@ +import { expect } from '@playwright/test' +import { test } from '@tanstack/router-e2e-utils' + +test('applies styles imported with css?url during SSR', async ({ page }) => { + const response = await page.goto('/rsc-css-url') + expect(response?.status()).toBe(200) + + const card = page.getByTestId('rsc-css-url-card') + await expect(card).toBeVisible() + + const backgroundColor = await card.evaluate( + (el) => getComputedStyle(el).backgroundColor, + ) + expect(backgroundColor).toBe('rgb(236, 253, 245)') +}) diff --git a/packages/start-plugin-core/src/rsbuild/planning.ts b/packages/start-plugin-core/src/rsbuild/planning.ts index e6518ad2e6..65fbd94024 100644 --- a/packages/start-plugin-core/src/rsbuild/planning.ts +++ b/packages/start-plugin-core/src/rsbuild/planning.ts @@ -31,6 +31,29 @@ export type RsbuildEnvironmentName = (typeof RSBUILD_ENVIRONMENT_NAMES)[keyof typeof RSBUILD_ENVIRONMENT_NAMES] type RsbuildDistPath = NonNullable['distPath'] +type RsbuildDistPathObject = Exclude + +function createPublicAssetDistPath(root: string): RsbuildDistPathObject { + return { + root, + css: `${RSBUILD_CLIENT_ASSETS_DIR}/css`, + cssAsync: `${RSBUILD_CLIENT_ASSETS_DIR}/css/async`, + svg: `${RSBUILD_CLIENT_ASSETS_DIR}/svg`, + font: `${RSBUILD_CLIENT_ASSETS_DIR}/font`, + wasm: `${RSBUILD_CLIENT_ASSETS_DIR}/wasm`, + image: `${RSBUILD_CLIENT_ASSETS_DIR}/image`, + media: `${RSBUILD_CLIENT_ASSETS_DIR}/media`, + assets: `${RSBUILD_CLIENT_ASSETS_DIR}/assets`, + } +} + +function createClientAssetDistPath(root: string): RsbuildDistPathObject { + return { + ...createPublicAssetDistPath(root), + js: `${RSBUILD_CLIENT_ASSETS_DIR}/js`, + jsAsync: `${RSBUILD_CLIENT_ASSETS_DIR}/js/async`, + } +} export interface RsbuildResolvedEntryAliases { client: string @@ -121,19 +144,7 @@ export function createRsbuildEnvironmentPlan(opts: { output: { target: 'web', module: clientOutputModule, - distPath: { - root: opts.clientOutputDirectory, - js: `${RSBUILD_CLIENT_ASSETS_DIR}/js`, - jsAsync: `${RSBUILD_CLIENT_ASSETS_DIR}/js/async`, - css: `${RSBUILD_CLIENT_ASSETS_DIR}/css`, - cssAsync: `${RSBUILD_CLIENT_ASSETS_DIR}/css/async`, - svg: `${RSBUILD_CLIENT_ASSETS_DIR}/svg`, - font: `${RSBUILD_CLIENT_ASSETS_DIR}/font`, - wasm: `${RSBUILD_CLIENT_ASSETS_DIR}/wasm`, - image: `${RSBUILD_CLIENT_ASSETS_DIR}/image`, - media: `${RSBUILD_CLIENT_ASSETS_DIR}/media`, - assets: `${RSBUILD_CLIENT_ASSETS_DIR}/assets`, - }, + distPath: createClientAssetDistPath(opts.clientOutputDirectory), assetPrefix: opts.publicBase, }, resolve: { @@ -171,9 +182,8 @@ export function createRsbuildEnvironmentPlan(opts: { // which requires `--experimental-vm-modules`. Emit CJS for the dev // server bundle so SSR works without extra Node flags. ...(opts.dev ? { module: false } : {}), - distPath: { - root: opts.serverOutputDirectory, - }, + distPath: createPublicAssetDistPath(opts.serverOutputDirectory), + assetPrefix: opts.publicBase, }, resolve: { alias, @@ -208,9 +218,10 @@ export function createRsbuildEnvironmentPlan(opts: { output: { target: 'node', ...(opts.dev ? { module: false } : {}), - distPath: { - root: `${opts.serverOutputDirectory}/${opts.serverFnProviderEnv}`, - }, + distPath: createPublicAssetDistPath( + `${opts.serverOutputDirectory}/${opts.serverFnProviderEnv}`, + ), + assetPrefix: opts.publicBase, }, resolve: { alias,