Skip to content
Draft
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
3 changes: 2 additions & 1 deletion e2e/react-start/rsc/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@
"build:vite": "vite build && tsc --noEmit",
"preview": "vite preview",
"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"
"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",
"test:e2e:hmr": "playwright test --config playwright.hmr.config.ts --project=chromium"
},
"nx": {
"metadata": {
Expand Down
40 changes: 40 additions & 0 deletions e2e/react-start/rsc/playwright.hmr.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { defineConfig, devices } from '@playwright/test'
import { getTestServerPort } from '@tanstack/router-e2e-utils'
import packageJson from './package.json' with { type: 'json' }

// Dedicated HMR test lane for the RSC app. Unlike playwright.config.ts (which
// runs the built server via `pnpm start`), HMR must run against the dev server.
const e2ePortKey = process.env.E2E_PORT_KEY ?? `${packageJson.name}-hmr`
const PORT = await getTestServerPort(e2ePortKey)
const baseURL = `http://localhost:${PORT}`

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

use: {
baseURL,
},

webServer: {
command: `pnpm dev:vite --port ${PORT}`,
url: baseURL,
reuseExistingServer: !process.env.CI,
stdout: 'pipe',
timeout: 120_000,
env: {
PORT: String(PORT),
E2E_PORT_KEY: e2ePortKey,
},
},

projects: [
{
name: 'chromium',
use: {
...devices['Desktop Chrome'],
},
},
],
})
21 changes: 21 additions & 0 deletions e2e/react-start/rsc/src/routeTree.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import { Route as RscLinkRouteImport } from './routes/rsc-link'
import { Route as RscLargeRouteImport } from './routes/rsc-large'
import { Route as RscInvalidationRouteImport } from './routes/rsc-invalidation'
import { Route as RscHydrationRouteImport } from './routes/rsc-hydration'
import { Route as RscHmrServerfnRouteImport } from './routes/rsc-hmr-serverfn'
import { Route as RscGlobalCssRouteImport } from './routes/rsc-global-css'
import { Route as RscFormsRouteImport } from './routes/rsc-forms'
import { Route as RscFlightApiRouteImport } from './routes/rsc-flight-api'
Expand Down Expand Up @@ -172,6 +173,11 @@ const RscHydrationRoute = RscHydrationRouteImport.update({
path: '/rsc-hydration',
getParentRoute: () => rootRouteImport,
} as any)
const RscHmrServerfnRoute = RscHmrServerfnRouteImport.update({
id: '/rsc-hmr-serverfn',
path: '/rsc-hmr-serverfn',
getParentRoute: () => rootRouteImport,
} as any)
const RscGlobalCssRoute = RscGlobalCssRouteImport.update({
id: '/rsc-global-css',
path: '/rsc-global-css',
Expand Down Expand Up @@ -313,6 +319,7 @@ export interface FileRoutesByFullPath {
'/rsc-flight-api': typeof RscFlightApiRoute
'/rsc-forms': typeof RscFormsRoute
'/rsc-global-css': typeof RscGlobalCssRoute
'/rsc-hmr-serverfn': typeof RscHmrServerfnRoute
'/rsc-hydration': typeof RscHydrationRoute
'/rsc-invalidation': typeof RscInvalidationRoute
'/rsc-large': typeof RscLargeRoute
Expand Down Expand Up @@ -362,6 +369,7 @@ export interface FileRoutesByTo {
'/rsc-flight-api': typeof RscFlightApiRoute
'/rsc-forms': typeof RscFormsRoute
'/rsc-global-css': typeof RscGlobalCssRoute
'/rsc-hmr-serverfn': typeof RscHmrServerfnRoute
'/rsc-hydration': typeof RscHydrationRoute
'/rsc-invalidation': typeof RscInvalidationRoute
'/rsc-large': typeof RscLargeRoute
Expand Down Expand Up @@ -412,6 +420,7 @@ export interface FileRoutesById {
'/rsc-flight-api': typeof RscFlightApiRoute
'/rsc-forms': typeof RscFormsRoute
'/rsc-global-css': typeof RscGlobalCssRoute
'/rsc-hmr-serverfn': typeof RscHmrServerfnRoute
'/rsc-hydration': typeof RscHydrationRoute
'/rsc-invalidation': typeof RscInvalidationRoute
'/rsc-large': typeof RscLargeRoute
Expand Down Expand Up @@ -463,6 +472,7 @@ export interface FileRouteTypes {
| '/rsc-flight-api'
| '/rsc-forms'
| '/rsc-global-css'
| '/rsc-hmr-serverfn'
| '/rsc-hydration'
| '/rsc-invalidation'
| '/rsc-large'
Expand Down Expand Up @@ -512,6 +522,7 @@ export interface FileRouteTypes {
| '/rsc-flight-api'
| '/rsc-forms'
| '/rsc-global-css'
| '/rsc-hmr-serverfn'
| '/rsc-hydration'
| '/rsc-invalidation'
| '/rsc-large'
Expand Down Expand Up @@ -561,6 +572,7 @@ export interface FileRouteTypes {
| '/rsc-flight-api'
| '/rsc-forms'
| '/rsc-global-css'
| '/rsc-hmr-serverfn'
| '/rsc-hydration'
| '/rsc-invalidation'
| '/rsc-large'
Expand Down Expand Up @@ -611,6 +623,7 @@ export interface RootRouteChildren {
RscFlightApiRoute: typeof RscFlightApiRoute
RscFormsRoute: typeof RscFormsRoute
RscGlobalCssRoute: typeof RscGlobalCssRoute
RscHmrServerfnRoute: typeof RscHmrServerfnRoute
RscHydrationRoute: typeof RscHydrationRoute
RscInvalidationRoute: typeof RscInvalidationRoute
RscLargeRoute: typeof RscLargeRoute
Expand Down Expand Up @@ -804,6 +817,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof RscHydrationRouteImport
parentRoute: typeof rootRouteImport
}
'/rsc-hmr-serverfn': {
id: '/rsc-hmr-serverfn'
path: '/rsc-hmr-serverfn'
fullPath: '/rsc-hmr-serverfn'
preLoaderRoute: typeof RscHmrServerfnRouteImport
parentRoute: typeof rootRouteImport
}
'/rsc-global-css': {
id: '/rsc-global-css'
path: '/rsc-global-css'
Expand Down Expand Up @@ -995,6 +1015,7 @@ const rootRouteChildren: RootRouteChildren = {
RscFlightApiRoute: RscFlightApiRoute,
RscFormsRoute: RscFormsRoute,
RscGlobalCssRoute: RscGlobalCssRoute,
RscHmrServerfnRoute: RscHmrServerfnRoute,
RscHydrationRoute: RscHydrationRoute,
RscInvalidationRoute: RscInvalidationRoute,
RscLargeRoute: RscLargeRoute,
Expand Down
41 changes: 41 additions & 0 deletions e2e/react-start/rsc/src/routes/rsc-hmr-serverfn.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { createFileRoute } from '@tanstack/react-router'
import { createServerFn } from '@tanstack/react-start'
import { renderServerComponent } from '@tanstack/react-start/rsc'
import { useState } from 'react'

// This route deliberately keeps BOTH a `createServerFn` loader and the route
// component in the same file. That combination is the regression under test:
// the server-fn extraction (`?tss-serverfn-split`) must not strip the route
// component's client Fast Refresh boundary (`?tsr-split=component`).
const getHmrServerComponent = createServerFn({ method: 'GET' }).handler(
async () => {
return renderServerComponent(
<div data-testid="rsc-hmr-server-content">server-rendered content</div>,
)
},
)

export const Route = createFileRoute('/rsc-hmr-serverfn')({
loader: () => getHmrServerComponent(),
component: RscHmrServerFnComponent,
})

function RscHmrServerFnComponent() {
const ServerComponent = Route.useLoaderData()
const [count, setCount] = useState(0)

return (
<div>
<h1 data-testid="rsc-hmr-marker">rsc-hmr-baseline</h1>
<p data-testid="rsc-hmr-count">Count: {count}</p>
<button
type="button"
data-testid="rsc-hmr-increment"
onClick={() => setCount((value) => value + 1)}
>
Increment
</button>
{ServerComponent}
</div>
)
}
54 changes: 54 additions & 0 deletions e2e/react-start/rsc/tests-hmr/rsc-hmr.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { expect } from '@playwright/test'
import { createHmrFileEditor, test } from '@tanstack/router-e2e-utils'
import path from 'node:path'

const hmrExpect = expect.configure({ timeout: 20_000 })

const routeFiles = {
rscHmrServerFn: path.join(process.cwd(), 'src/routes/rsc-hmr-serverfn.tsx'),
} as const

const editor = createHmrFileEditor({ files: routeFiles })

test.afterEach(async () => {
await editor.capturePromise
await editor.restoreFiles()
})

test.afterAll(async () => {
await editor.capturePromise
await editor.restoreFiles()
})

// Regression: a route file that also defines a `createServerFn` must still hot
// update its route component. On the buggy path the edit only reaches the rsc
// graph (`?tss-serverfn-split`) and the client component split
// (`?tsr-split=component`) is never produced, so the DOM never updates.
test('route component with a co-located server fn Fast Refreshes', async ({
page,
}) => {
await page.goto('/rsc-hmr-serverfn')
await expect(page.getByTestId('app-hydrated')).toHaveText('hydrated')

// Seed client-only state so we can prove the update was HMR, not a reload.
await page.getByTestId('rsc-hmr-increment').click()
await expect(page.getByTestId('rsc-hmr-count')).toHaveText('Count: 1')
await expect(page.getByTestId('rsc-hmr-marker')).toHaveText('rsc-hmr-baseline')
await expect(page.getByTestId('rsc-hmr-server-content')).toHaveText(
'server-rendered content',
)

await editor.replaceText(
'rscHmrServerFn',
'rsc-hmr-baseline',
'rsc-hmr-updated',
)

// FAILS on the buggy path: the marker never updates.
await hmrExpect(page.getByTestId('rsc-hmr-marker')).toHaveText(
'rsc-hmr-updated',
)

// The component hot-updated rather than fully reloading: local state survives.
await expect(page.getByTestId('rsc-hmr-count')).toHaveText('Count: 1')
})