Skip to content
Merged
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
5 changes: 5 additions & 0 deletions .changeset/icy-donkeys-worry.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@tanstack/start-plugin-core': patch
---

Fix Rsbuild RSC builds so client directives in packages under `node_modules` are compiled and detected correctly.
24 changes: 24 additions & 0 deletions e2e/react-start/rsc-rsbuild/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
node_modules
!src/node_modules/
!src/node_modules/rsc-client-pkg/
!src/node_modules/rsc-client-pkg/**
package-lock.json
yarn.lock

.DS_Store
.cache
.env
.vercel
.output

/build/
/api/
/server/build
/public/build
/dist/
/dist-rsbuild-*/
/test-results/
/playwright-report/
/blob-report/
/playwright/.cache/
port-*.txt
1 change: 1 addition & 0 deletions e2e/react-start/rsc-rsbuild/.prettierignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
.prettierrc
40 changes: 40 additions & 0 deletions e2e/react-start/rsc-rsbuild/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
{
"name": "tanstack-react-start-e2e-rsc-rsbuild",
"private": true,
"sideEffects": false,
"type": "module",
"scripts": {
"dev": "rsbuild dev",
"dev:e2e": "rsbuild dev --port $PORT",
"build": "rsbuild build && tsc --noEmit",
"start": "node server.js",
"test:e2e-full": "pnpm build && sh -c 'rm -f \"port-${E2E_PORT_KEY:-$npm_package_name}.txt\" \"port-${E2E_PORT_KEY:-$npm_package_name}-external.txt\"' && playwright test --project=chromium"
},
"nx": {
"metadata": {
"playwrightModes": [
{
"toolchain": "rsbuild",
"mode": "ssr"
}
]
}
},
"dependencies": {
"@tanstack/react-router": "workspace:^",
"@tanstack/react-start": "workspace:^",
"react": "^19.0.0",
"react-dom": "^19.0.0"
},
"devDependencies": {
"@playwright/test": "^1.50.1",
"@rsbuild/core": "^2.0.8",
"@rsbuild/plugin-react": "^2.0.0",
"@tanstack/router-e2e-utils": "workspace:^",
"@types/node": "^22.10.2",
"@types/react": "^19.0.8",
"@types/react-dom": "^19.0.3",
"srvx": "^0.10.0",
"typescript": "^5.7.2"
}
}
58 changes: 58 additions & 0 deletions e2e/react-start/rsc-rsbuild/playwright.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import fs from 'node:fs'
import { defineConfig, devices } from '@playwright/test'
import {
getDummyServerPort,
getTestServerPort,
} from '@tanstack/router-e2e-utils'
import packageJson from './package.json' with { type: 'json' }

const e2ePortKey = process.env.E2E_PORT_KEY ?? packageJson.name
const distDir = process.env.E2E_DIST_DIR ?? 'dist'

if (process.env.TEST_WORKER_INDEX === undefined) {
for (const portFile of [
`port-${e2ePortKey}.txt`,
`port-${e2ePortKey}-external.txt`,
]) {
fs.rmSync(portFile, { force: true })
}
}

const PORT = await getTestServerPort(e2ePortKey)
const EXTERNAL_PORT = await getDummyServerPort(e2ePortKey)
const baseURL = `http://localhost:${PORT}`

export default defineConfig({
testDir: './tests',
workers: 1,
reporter: [['line']],

globalSetup: './tests/setup/global.setup.ts',
globalTeardown: './tests/setup/global.teardown.ts',

use: {
baseURL,
},

webServer: {
command: 'pnpm start',
url: baseURL,
reuseExistingServer: !process.env.CI,
stdout: 'pipe',
env: {
PORT: String(PORT),
E2E_DIST_DIR: distDir,
E2E_PORT_KEY: e2ePortKey,
EXTERNAL_SERVER_URL: `http://localhost:${EXTERNAL_PORT}`,
},
},

projects: [
{
name: 'chromium',
use: {
...devices['Desktop Chrome'],
},
},
],
})
21 changes: 21 additions & 0 deletions e2e/react-start/rsc-rsbuild/rsbuild.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { defineConfig } from '@rsbuild/core'
import { pluginReact } from '@rsbuild/plugin-react'
import { tanstackStart } from '@tanstack/react-start/plugin/rsbuild'

const outDir = process.env.E2E_DIST_DIR ?? 'dist'

export default defineConfig({
plugins: [
pluginReact({ splitChunks: false }),
tanstackStart({
rsc: {
enabled: true,
},
}),
],
output: {
distPath: {
root: outDir,
},
},
})
57 changes: 57 additions & 0 deletions e2e/react-start/rsc-rsbuild/server.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import fs from 'node:fs'
import path from 'node:path'
import { spawn } from 'node:child_process'
import { pathToFileURL } from 'node:url'

const distDir = process.env.E2E_DIST_DIR || 'dist'

function resolveDistClientDir() {
return path.resolve(distDir, 'client')
}

function resolveDistServerEntryPath() {
const serverJsPath = path.resolve(distDir, 'server', 'server.js')
if (fs.existsSync(serverJsPath)) {
return serverJsPath
}

const indexJsPath = path.resolve(distDir, 'server', 'index.js')
if (fs.existsSync(indexJsPath)) {
return indexJsPath
}

return serverJsPath
}
Comment on lines +23 to +24
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Fail fast when no server build artifact exists.

If neither dist/server/server.js nor dist/server/index.js exists, returning serverJsPath hides the root cause and fails later inside srvx. Throw an explicit error here.

Proposed fix
 function resolveDistServerEntryPath() {
   const serverJsPath = path.resolve(distDir, 'server', 'server.js')
   if (fs.existsSync(serverJsPath)) {
     return serverJsPath
   }

   const indexJsPath = path.resolve(distDir, 'server', 'index.js')
   if (fs.existsSync(indexJsPath)) {
     return indexJsPath
   }

-  return serverJsPath
+  throw new Error(
+    `Server entry not found. Looked for "${serverJsPath}" and "${indexJsPath}". Did you run the build first?`,
+  )
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@e2e/react-start/rsc-rsbuild/server.js` around lines 23 - 24, The function
that computes serverJsPath currently returns serverJsPath even when no artifact
exists, causing later failures; instead, in the code that determines/returns
serverJsPath (the variable named serverJsPath and its containing function),
check for existence of both candidate files (dist/server/server.js and
dist/server/index.js) and if neither exists throw an explicit Error with a clear
message like "Server build artifact not found: expected dist/server/server.js or
dist/server/index.js"; replace the plain return of serverJsPath with this
existence check and throw to fail fast.


export function resolveStartCommand() {
const distClientDir = resolveDistClientDir()
const distServerEntryPath = resolveDistServerEntryPath()
return `srvx --prod -s ${JSON.stringify(distClientDir)} ${JSON.stringify(distServerEntryPath)}`
}

export function start() {
const child = spawn(
'srvx',
['--prod', '-s', resolveDistClientDir(), resolveDistServerEntryPath()],
{
stdio: 'inherit',
shell: process.platform === 'win32',
},
)

child.on('exit', (code, signal) => {
if (signal) {
process.kill(process.pid, signal)
return
}

process.exit(code ?? 0)
})
}

if (
process.argv[1] &&
import.meta.url === pathToFileURL(process.argv[1]).href
) {
start()
}

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

86 changes: 86 additions & 0 deletions e2e/react-start/rsc-rsbuild/src/routeTree.gen.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
/* eslint-disable */

// @ts-nocheck

// noinspection JSUnusedGlobalSymbols

// This file was automatically generated by TanStack Router.
// You should NOT make any changes in this file as it will be overwritten.
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.

import { Route as rootRouteImport } from './routes/__root'
import { Route as RscNodeModuleClientRouteImport } from './routes/rsc-node-module-client'
import { Route as IndexRouteImport } from './routes/index'

const RscNodeModuleClientRoute = RscNodeModuleClientRouteImport.update({
id: '/rsc-node-module-client',
path: '/rsc-node-module-client',
getParentRoute: () => rootRouteImport,
} as any)
const IndexRoute = IndexRouteImport.update({
id: '/',
path: '/',
getParentRoute: () => rootRouteImport,
} as any)

export interface FileRoutesByFullPath {
'/': typeof IndexRoute
'/rsc-node-module-client': typeof RscNodeModuleClientRoute
}
export interface FileRoutesByTo {
'/': typeof IndexRoute
'/rsc-node-module-client': typeof RscNodeModuleClientRoute
}
export interface FileRoutesById {
__root__: typeof rootRouteImport
'/': typeof IndexRoute
'/rsc-node-module-client': typeof RscNodeModuleClientRoute
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
fullPaths: '/' | '/rsc-node-module-client'
fileRoutesByTo: FileRoutesByTo
to: '/' | '/rsc-node-module-client'
id: '__root__' | '/' | '/rsc-node-module-client'
fileRoutesById: FileRoutesById
}
export interface RootRouteChildren {
IndexRoute: typeof IndexRoute
RscNodeModuleClientRoute: typeof RscNodeModuleClientRoute
}

declare module '@tanstack/react-router' {
interface FileRoutesByPath {
'/rsc-node-module-client': {
id: '/rsc-node-module-client'
path: '/rsc-node-module-client'
fullPath: '/rsc-node-module-client'
preLoaderRoute: typeof RscNodeModuleClientRouteImport
parentRoute: typeof rootRouteImport
}
'/': {
id: '/'
path: '/'
fullPath: '/'
preLoaderRoute: typeof IndexRouteImport
parentRoute: typeof rootRouteImport
}
}
}

const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute,
RscNodeModuleClientRoute: RscNodeModuleClientRoute,
}
export const routeTree = rootRouteImport
._addFileChildren(rootRouteChildren)
._addFileTypes<FileRouteTypes>()

import type { getRouter } from './router.tsx'
import type { createStart } from '@tanstack/react-start'
declare module '@tanstack/react-start' {
interface Register {
ssr: true
router: Awaited<ReturnType<typeof getRouter>>
}
}
10 changes: 10 additions & 0 deletions e2e/react-start/rsc-rsbuild/src/router.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { createRouter } from '@tanstack/react-router'
import { routeTree } from './routeTree.gen'

export function getRouter() {
return createRouter({
routeTree,
scrollRestoration: true,
defaultPreload: 'intent',
})
}
37 changes: 37 additions & 0 deletions e2e/react-start/rsc-rsbuild/src/routes/__root.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import {
HeadContent,
Outlet,
Scripts,
createRootRoute,
useHydrated,
} from '@tanstack/react-router'

export const Route = createRootRoute({
head: () => ({
meta: [
{ charSet: 'utf-8' },
{ name: 'viewport', content: 'width=device-width, initial-scale=1' },
{ title: 'Rsbuild RSC test' },
],
}),
component: RootComponent,
})

function RootComponent() {
const hydrated = useHydrated()

return (
<html>
<head>
<HeadContent />
</head>
<body>
<span data-testid="app-hydrated" style={{ display: 'none' }}>
{hydrated ? 'hydrated' : 'hydrating'}
</span>
<Outlet />
<Scripts />
</body>
</html>
)
}
Loading