diff --git a/e2e/react-start/rsc/package.json b/e2e/react-start/rsc/package.json index 7a499078d3..50540ce3de 100644 --- a/e2e/react-start/rsc/package.json +++ b/e2e/react-start/rsc/package.json @@ -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": { diff --git a/e2e/react-start/rsc/playwright.hmr.config.ts b/e2e/react-start/rsc/playwright.hmr.config.ts new file mode 100644 index 0000000000..e916eec1ab --- /dev/null +++ b/e2e/react-start/rsc/playwright.hmr.config.ts @@ -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'], + }, + }, + ], +}) diff --git a/e2e/react-start/rsc/src/routeTree.gen.ts b/e2e/react-start/rsc/src/routeTree.gen.ts index 015d3edf82..dd0910e9f2 100644 --- a/e2e/react-start/rsc/src/routeTree.gen.ts +++ b/e2e/react-start/rsc/src/routeTree.gen.ts @@ -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' @@ -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', @@ -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 @@ -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 @@ -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 @@ -463,6 +472,7 @@ export interface FileRouteTypes { | '/rsc-flight-api' | '/rsc-forms' | '/rsc-global-css' + | '/rsc-hmr-serverfn' | '/rsc-hydration' | '/rsc-invalidation' | '/rsc-large' @@ -512,6 +522,7 @@ export interface FileRouteTypes { | '/rsc-flight-api' | '/rsc-forms' | '/rsc-global-css' + | '/rsc-hmr-serverfn' | '/rsc-hydration' | '/rsc-invalidation' | '/rsc-large' @@ -561,6 +572,7 @@ export interface FileRouteTypes { | '/rsc-flight-api' | '/rsc-forms' | '/rsc-global-css' + | '/rsc-hmr-serverfn' | '/rsc-hydration' | '/rsc-invalidation' | '/rsc-large' @@ -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 @@ -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' @@ -995,6 +1015,7 @@ const rootRouteChildren: RootRouteChildren = { RscFlightApiRoute: RscFlightApiRoute, RscFormsRoute: RscFormsRoute, RscGlobalCssRoute: RscGlobalCssRoute, + RscHmrServerfnRoute: RscHmrServerfnRoute, RscHydrationRoute: RscHydrationRoute, RscInvalidationRoute: RscInvalidationRoute, RscLargeRoute: RscLargeRoute, diff --git a/e2e/react-start/rsc/src/routes/rsc-hmr-serverfn.tsx b/e2e/react-start/rsc/src/routes/rsc-hmr-serverfn.tsx new file mode 100644 index 0000000000..dd5dd57fa8 --- /dev/null +++ b/e2e/react-start/rsc/src/routes/rsc-hmr-serverfn.tsx @@ -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( +
server-rendered content
, + ) + }, +) + +export const Route = createFileRoute('/rsc-hmr-serverfn')({ + loader: () => getHmrServerComponent(), + component: RscHmrServerFnComponent, +}) + +function RscHmrServerFnComponent() { + const ServerComponent = Route.useLoaderData() + const [count, setCount] = useState(0) + + return ( +
+

rsc-hmr-baseline

+

Count: {count}

+ + {ServerComponent} +
+ ) +} diff --git a/e2e/react-start/rsc/tests-hmr/rsc-hmr.spec.ts b/e2e/react-start/rsc/tests-hmr/rsc-hmr.spec.ts new file mode 100644 index 0000000000..bb034b04f2 --- /dev/null +++ b/e2e/react-start/rsc/tests-hmr/rsc-hmr.spec.ts @@ -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') +})