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
30 changes: 30 additions & 0 deletions packages/vite-plugin-tanstack-start/test/e2e/build.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,4 +71,34 @@ describe.runIf(isSupportedNode && !isWindows)('build output when deployed to Net
await page.click('text=sunt aut facere repe')
await page.waitForSelector('text=quia et suscipit suscipit')
})

test('Renders basic RSC page with server components', async () => {
const response = await page.goto(`${baseUrl}/rsc-basic`)
expect(response?.status()).toBe(200)
// The greeting is rendered by a server component via renderServerComponent
await page.waitForSelector('[data-testid="rsc-greeting"]')
const greetingText = await page.textContent('[data-testid="rsc-greeting"]')
expect(greetingText).toContain('Hello from a Server Component!')
// The user list is also rendered as a server component with data fetching
await page.waitForSelector('[data-testid="rsc-user-list"]')
const userListText = await page.textContent('[data-testid="rsc-user-list"]')
expect(userListText).toContain('Ervin Howell')
})

test('Renders composite RSC page with client component children', async () => {
const response = await page.goto(`${baseUrl}/rsc-composite`)
expect(response?.status()).toBe(200)
// The card shell is rendered on the server via createCompositeComponent
await page.waitForSelector('[data-testid="rsc-card"]')
const cardText = await page.textContent('[data-testid="rsc-card"]')
expect(cardText).toContain('Server-Rendered Card')
// The counter is a client component passed as a children slot
await page.waitForSelector('[data-testid="client-counter"]')
const initialValue = await page.textContent('[data-testid="counter-value"]')
expect(initialValue).toBe('0')
// Verify client interactivity works within the server-rendered shell
await page.click('[data-testid="counter-button"]')
const updatedValue = await page.textContent('[data-testid="counter-value"]')
expect(updatedValue).toBe('1')
Comment on lines +100 to +102

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

Stabilize post-click counter assertion to avoid async flake.

At Line 100-Line 102, the value is read immediately after click. This can intermittently fail before state commit in slower environments.

Suggested fix
-    await page.click('[data-testid="counter-button"]')
-    const updatedValue = await page.textContent('[data-testid="counter-value"]')
-    expect(updatedValue).toBe('1')
+    await page.click('[data-testid="counter-button"]')
+    await page.waitForFunction(() => {
+      const el = document.querySelector('[data-testid="counter-value"]')
+      return el?.textContent === '1'
+    })
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
await page.click('[data-testid="counter-button"]')
const updatedValue = await page.textContent('[data-testid="counter-value"]')
expect(updatedValue).toBe('1')
await page.click('[data-testid="counter-button"]')
await page.waitForFunction(() => {
const el = document.querySelector('[data-testid="counter-value"]')
return el?.textContent === '1'
})
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/vite-plugin-tanstack-start/test/e2e/build.test.ts` around lines 100
- 102, The test reads the counter value immediately after page.click which can
race with async state updates; replace the immediate page.textContent call with
a wait-based assertion (use page.waitForSelector or page.waitForFunction, or
create a locator via page.locator('[data-testid="counter-value"]') and use
expect(locator).toHaveText('1')) so the test waits for the DOM to reflect the
increment before asserting; update the lines using page.click, page.textContent
and the '[data-testid="counter-value"]' selector accordingly.

})
})
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,9 @@
"start": "node .output/server/index.mjs"
},
"dependencies": {
"@tanstack/react-router": "^1.154.5",
"@tanstack/react-router-devtools": "^1.154.5",
"@tanstack/react-start": "^1.154.5",
"@tanstack/react-router": "^1.168.19",
"@tanstack/react-router-devtools": "^1.166.13",
"@tanstack/react-start": "^1.167.37",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"tailwind-merge": "^2.6.0",
Expand All @@ -21,6 +21,7 @@
"devDependencies": {
"@netlify/vite-plugin-tanstack-start": "file:../../..",
"@tailwindcss/vite": "^4.1.18",
"@vitejs/plugin-rsc": "^0.5.24",
"@types/node": "^22.5.4",
"@types/react": "^19.0.8",
"@types/react-dom": "^19.0.3",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import * as React from 'react'

/**
* A client-only interactive counter component.
* Used as a child slot inside a server-rendered composite component
* to demonstrate RSC composition with client interactivity.
*/
export function Counter() {
const [count, setCount] = React.useState(0)
return (
<div data-testid="client-counter">
<p>Client-side counter: <span data-testid="counter-value">{count}</span></p>
<button data-testid="counter-button" onClick={() => setCount((c) => c + 1)}>
Increment
</button>
</div>
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ import { Route as rootRouteImport } from './routes/__root'
import { Route as UsersRouteImport } from './routes/users'
import { Route as RedirectRouteImport } from './routes/redirect'
import { Route as PostsRouteImport } from './routes/posts'
import { Route as RscBasicRouteImport } from './routes/rsc-basic'
import { Route as RscCompositeRouteImport } from './routes/rsc-composite'
import { Route as DeferredRouteImport } from './routes/deferred'
import { Route as CustomScriptDotjsRouteImport } from './routes/customScript[.]js'
import { Route as PathlessLayoutRouteImport } from './routes/_pathlessLayout'
Expand Down Expand Up @@ -42,6 +44,16 @@ const PostsRoute = PostsRouteImport.update({
path: '/posts',
getParentRoute: () => rootRouteImport,
} as any)
const RscBasicRoute = RscBasicRouteImport.update({
id: '/rsc-basic',
path: '/rsc-basic',
getParentRoute: () => rootRouteImport,
} as any)
const RscCompositeRoute = RscCompositeRouteImport.update({
id: '/rsc-composite',
path: '/rsc-composite',
getParentRoute: () => rootRouteImport,
} as any)
const DeferredRoute = DeferredRouteImport.update({
id: '/deferred',
path: '/deferred',
Expand Down Expand Up @@ -120,6 +132,8 @@ export interface FileRoutesByFullPath {
'/deferred': typeof DeferredRoute
'/posts': typeof PostsRouteWithChildren
'/redirect': typeof RedirectRoute
'/rsc-basic': typeof RscBasicRoute
'/rsc-composite': typeof RscCompositeRoute
'/users': typeof UsersRouteWithChildren
'/api/users': typeof ApiUsersRouteWithChildren
'/posts/$postId': typeof PostsPostIdRoute
Expand All @@ -136,6 +150,8 @@ export interface FileRoutesByTo {
'/customScript.js': typeof CustomScriptDotjsRoute
'/deferred': typeof DeferredRoute
'/redirect': typeof RedirectRoute
'/rsc-basic': typeof RscBasicRoute
'/rsc-composite': typeof RscCompositeRoute
'/api/users': typeof ApiUsersRouteWithChildren
'/posts/$postId': typeof PostsPostIdRoute
'/users/$userId': typeof UsersUserIdRoute
Expand All @@ -154,6 +170,8 @@ export interface FileRoutesById {
'/deferred': typeof DeferredRoute
'/posts': typeof PostsRouteWithChildren
'/redirect': typeof RedirectRoute
'/rsc-basic': typeof RscBasicRoute
'/rsc-composite': typeof RscCompositeRoute
'/users': typeof UsersRouteWithChildren
'/_pathlessLayout/_nested-layout': typeof PathlessLayoutNestedLayoutRouteWithChildren
'/api/users': typeof ApiUsersRouteWithChildren
Expand All @@ -174,6 +192,8 @@ export interface FileRouteTypes {
| '/deferred'
| '/posts'
| '/redirect'
| '/rsc-basic'
| '/rsc-composite'
| '/users'
| '/api/users'
| '/posts/$postId'
Expand All @@ -190,6 +210,8 @@ export interface FileRouteTypes {
| '/customScript.js'
| '/deferred'
| '/redirect'
| '/rsc-basic'
| '/rsc-composite'
| '/api/users'
| '/posts/$postId'
| '/users/$userId'
Expand All @@ -207,6 +229,8 @@ export interface FileRouteTypes {
| '/deferred'
| '/posts'
| '/redirect'
| '/rsc-basic'
| '/rsc-composite'
| '/users'
| '/_pathlessLayout/_nested-layout'
| '/api/users'
Expand All @@ -227,6 +251,8 @@ export interface RootRouteChildren {
DeferredRoute: typeof DeferredRoute
PostsRoute: typeof PostsRouteWithChildren
RedirectRoute: typeof RedirectRoute
RscBasicRoute: typeof RscBasicRoute
RscCompositeRoute: typeof RscCompositeRoute
UsersRoute: typeof UsersRouteWithChildren
ApiUsersRoute: typeof ApiUsersRouteWithChildren
PostsPostIdDeepRoute: typeof PostsPostIdDeepRoute
Expand Down Expand Up @@ -262,6 +288,20 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof DeferredRouteImport
parentRoute: typeof rootRouteImport
}
'/rsc-basic': {
id: '/rsc-basic'
path: '/rsc-basic'
fullPath: '/rsc-basic'
preLoaderRoute: typeof RscBasicRouteImport
parentRoute: typeof rootRouteImport
}
'/rsc-composite': {
id: '/rsc-composite'
path: '/rsc-composite'
fullPath: '/rsc-composite'
preLoaderRoute: typeof RscCompositeRouteImport
parentRoute: typeof rootRouteImport
}
'/customScript.js': {
id: '/customScript.js'
path: '/customScript.js'
Expand Down Expand Up @@ -429,6 +469,8 @@ const rootRouteChildren: RootRouteChildren = {
DeferredRoute: DeferredRoute,
PostsRoute: PostsRouteWithChildren,
RedirectRoute: RedirectRoute,
RscBasicRoute: RscBasicRoute,
RscCompositeRoute: RscCompositeRoute,
UsersRoute: UsersRouteWithChildren,
ApiUsersRoute: ApiUsersRouteWithChildren,
PostsPostIdDeepRoute: PostsPostIdDeepRoute,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,22 @@ function RootDocument({ children }: { children: React.ReactNode }) {
>
Deferred
</Link>{' '}
<Link
to="/rsc-basic"
activeProps={{
className: 'font-bold',
}}
>
RSC Basic
</Link>{' '}
<Link
to="/rsc-composite"
activeProps={{
className: 'font-bold',
}}
>
RSC Composite
</Link>{' '}
<Link
// @ts-expect-error
to="/this-route-does-not-exist"
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { createFileRoute } from '@tanstack/react-router'
import { fetchRscGreeting, fetchRscUserList } from '~/utils/rsc'

export const Route = createFileRoute('/rsc-basic')({
loader: async () => {
const [greeting, users] = await Promise.all([
fetchRscGreeting(),
fetchRscUserList(),
])
return { ...greeting, ...users }
},
component: RscBasicPage,
})

function RscBasicPage() {
const { Greeting, UserList } = Route.useLoaderData()
return (
<div className="p-2">
<h2>RSC Basic Demo</h2>
<p>This page demonstrates basic React Server Components rendered via <code>renderServerComponent</code>.</p>
<hr />
{Greeting}
<hr />
{UserList}
</div>
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { createFileRoute } from '@tanstack/react-router'
import { CompositeComponent } from '@tanstack/react-start/rsc'
import { fetchRscCard } from '~/utils/rsc'
import { Counter } from '~/components/Counter'

export const Route = createFileRoute('/rsc-composite')({
loader: () => fetchRscCard(),
component: RscCompositePage,
})

function RscCompositePage() {
const { Card } = Route.useLoaderData()
return (
<div className="p-2">
<h2>RSC Composite Demo</h2>
<p>
This page demonstrates <code>createCompositeComponent</code> with children slots.
The card shell is rendered on the server, while the counter inside is a client component.
</p>
<hr />
<CompositeComponent src={Card}>
<Counter />
</CompositeComponent>
</div>
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import * as React from 'react'
import { createServerFn } from '@tanstack/react-start'
import {
renderServerComponent,
createCompositeComponent,
} from '@tanstack/react-start/rsc'

/**
* A basic server function that renders a server component using renderServerComponent.
* The component fetches data on the server and renders it — the fetch logic never
* reaches the client bundle.
*/
export const fetchRscGreeting = createServerFn().handler(async () => {
// Simulate server-only work (e.g., DB query, secret API call)
const timestamp = new Date().toISOString()

const Greeting = await renderServerComponent(
<div data-testid="rsc-greeting">
<h2>Hello from a Server Component!</h2>
<p>This was rendered on the server at {timestamp}.</p>
<p>
This content comes from a React Server Component. The rendering logic
and any server-only dependencies never reach the client bundle.
</p>
</div>,
)

return { Greeting }
})

/**
* A server function that fetches data and renders it as a server component.
* Demonstrates data fetching colocated within an RSC.
*/
export const fetchRscUserList = createServerFn().handler(async () => {
const res = await fetch('https://jsonplaceholder.typicode.com/users')
if (!res.ok) {
throw new Error('Failed to fetch users for RSC')
}
const users = (await res.json()) as Array<{ id: number; name: string; email: string }>
Comment on lines +36 to +40

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 | 🟠 Major

Avoid hard dependency on live third-party API in e2e fixture path.

At Line 36-Line 40, the fixture relies on a public API at request time. This makes RSC tests non-deterministic and vulnerable to external outages/rate limits.

Suggested deterministic fallback pattern
 export const fetchRscUserList = createServerFn().handler(async () => {
-  const res = await fetch('https://jsonplaceholder.typicode.com/users')
-  if (!res.ok) {
-    throw new Error('Failed to fetch users for RSC')
-  }
-  const users = (await res.json()) as Array<{ id: number; name: string; email: string }>
+  const fallbackUsers: Array<{ id: number; name: string; email: string }> = [
+    { id: 1, name: 'Leanne Graham', email: 'leanne@example.com' },
+    { id: 2, name: 'Ervin Howell', email: 'ervin@example.com' },
+    { id: 3, name: 'Clementine Bauch', email: 'clementine@example.com' },
+    { id: 4, name: 'Patricia Lebsack', email: 'patricia@example.com' },
+    { id: 5, name: 'Chelsey Dietrich', email: 'chelsey@example.com' },
+  ]
+
+  let users = fallbackUsers
+  try {
+    const res = await fetch('https://jsonplaceholder.typicode.com/users')
+    if (res.ok) {
+      users = (await res.json()) as Array<{ id: number; name: string; email: string }>
+    }
+  } catch {
+    // keep deterministic fallback for fixture stability
+  }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@packages/vite-plugin-tanstack-start/test/fixtures/start-basic-rc/src/utils/rsc.tsx`
around lines 36 - 40, The RSC fixture currently fetches live data via fetch(...)
and throws on non-OK results, creating a brittle dependency; modify the code
around the fetch call so that you attempt the network request but gracefully
fallback to a deterministic local dataset when the fetch fails or returns a
non-OK response. Specifically, wrap the fetch in try/catch (or check res.ok) and
on any error or non-OK response set users to a hard-coded array of user objects
(same shape as Array<{id:number;name:string;email:string}>) instead of throwing;
update the logic in the module containing the const res / const users to use
this fallback so tests remain deterministic.


const UserList = await renderServerComponent(
<div data-testid="rsc-user-list">
<h3>Users (Server Component)</h3>
<ul>
{users.slice(0, 5).map((user) => (
<li key={user.id} data-testid={`rsc-user-${user.id}`}>
{user.name} — {user.email}
</li>
))}
</ul>
</div>,
)

return { UserList }
})

/**
* A composite component that accepts children slots.
* The server renders the outer shell, and client components can be
* passed as children.
*/
export const fetchRscCard = createServerFn().handler(async () => {
const src = await createCompositeComponent(
(props: { children?: React.ReactNode }) => (
<div data-testid="rsc-card" style={{ border: '1px solid #ccc', padding: '16px', borderRadius: '8px' }}>
<h3>Server-Rendered Card</h3>
<p>This card shell is rendered on the server.</p>
<div data-testid="rsc-card-children">{props.children}</div>
</div>
),
)

return { Card: src }
})
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { tanstackStart } from "@tanstack/react-start/plugin/vite";
import { defineConfig } from "vite";
import tsConfigPaths from "vite-tsconfig-paths";
import viteReact from "@vitejs/plugin-react";
import rsc from "@vitejs/plugin-rsc";
import tailwindcss from "@tailwindcss/vite";
import netlify from "@netlify/vite-plugin-tanstack-start";

Expand All @@ -16,7 +17,9 @@ export default defineConfig({
}),
tanstackStart({
srcDirectory: "src",
rsc: { enabled: true },
}),
rsc(),
viteReact(),
netlify(),
],
Expand Down
Loading