diff --git a/packages/plugin-rsc/e2e/co-located-client-hmr.test.ts b/packages/plugin-rsc/e2e/co-located-client-hmr.test.ts new file mode 100644 index 000000000..92155f135 --- /dev/null +++ b/packages/plugin-rsc/e2e/co-located-client-hmr.test.ts @@ -0,0 +1,122 @@ +import { expect, test } from '@playwright/test' +import { type Fixture, setupInlineFixture, useFixture } from './fixture' + +// Regression test for the client `hotUpdate` guard: a genuine client-rendered +// component that is also present in the `rsc` module graph (because its file +// co-locates server-graph code) must keep Fast Refresh. Before the fix the +// guard returned `[]` for such files and the edit was silently dropped on the +// client. +// +// The fixture sets up the trigger without a framework: the browser entry mounts +// `App` as a CSR island, and the import chain `entry.browser -> app -> page` +// has no `"use client"` boundary, so `Page` enters the client graph as a +// non-client-reference. `page.tsx` is also in the `rsc` graph because the +// server `root.tsx` imports `ServerNote` from it. +test.describe('co-located-client-hmr', () => { + const root = 'examples/e2e/temp/co-located-client-hmr' + + test.beforeAll(async () => { + await setupInlineFixture({ + src: 'examples/starter', + dest: root, + files: { + 'src/routes/page.tsx': /* tsx */ ` + import React from 'react' + + // Imported by the server 'root.tsx' below, so this file is in the + // 'rsc' module graph -- like a route file co-locating server-graph + // code with its route component. + export function ServerNote() { + return

server-note

+ } + + // Client-rendered route component, reached from the browser entry via + // 'app.tsx' with no "use client" boundary in the chain. + export function Page() { + const [count, setCount] = React.useState(0) + return ( +
+

marker-baseline

+ +
+ ) + } + `, + 'src/app.tsx': /* tsx */ ` + // No "use client": this module is imported directly by the browser + // entry and statically imports the route component, so the chain + // 'entry.browser -> app -> page' contains no client reference. + import { Page } from './routes/page' + + export function App() { + return + } + `, + 'src/root.tsx': /* tsx */ ` + import { ServerNote } from './routes/page' + + // Server shell. Importing 'ServerNote' puts 'routes/page' into the + // 'rsc' module graph. 'Page' itself is mounted client-side into + // '#client-root' by the browser entry, not rendered here. + export function Root(_props: { url: URL }) { + return ( + + + + + +
+ + + + ) + } + `, + 'src/framework/entry.browser.tsx': /* tsx */ ` + import { createRoot } from 'react-dom/client' + import { App } from '../app' + + // Render the client app as a CSR island instead of hydrating the RSC + // payload, so 'Page' is a client-rendered, non-"use client" component. + const el = document.getElementById('client-root') + if (el) { + createRoot(el).render() + } + `, + }, + }) + }) + + function defineTest(f: Fixture) { + test('route component co-located with rsc-graph code hot-updates', async ({ + page, + }) => { + await page.goto(f.url()) + + const marker = page.getByTestId('marker') + const count = page.getByTestId('count') + await expect(marker).toHaveText('marker-baseline') + + // seed client state to prove the edit is a Fast Refresh, not a reload + await count.click() + await count.click() + await expect(count).toHaveText('count: 2') + + const editor = f.createEditor('src/routes/page.tsx') + editor.edit((s) => s.replace('marker-baseline', 'marker-edited')) + await expect(marker).toHaveText('marker-edited') + await expect(count).toHaveText('count: 2') + + editor.reset() + await expect(marker).toHaveText('marker-baseline') + await expect(count).toHaveText('count: 2') + }) + } + + test.describe('dev', () => { + const f = useFixture({ root, mode: 'dev' }) + defineTest(f) + }) +}) diff --git a/packages/plugin-rsc/src/plugin.ts b/packages/plugin-rsc/src/plugin.ts index 01ec7b083..ab4f77b9f 100644 --- a/packages/plugin-rsc/src/plugin.ts +++ b/packages/plugin-rsc/src/plugin.ts @@ -826,14 +826,25 @@ export default function vitePluginRsc( const env = ctx.server.environments.rsc! const mod = env.moduleGraph.getModuleById(ctx.file) if (mod) { - for (const clientMod of ctx.modules) { - for (const importer of clientMod.importers) { - if (importer.id && isCSSRequest(importer.id)) { - await this.environment.reloadModule(importer) + // A non-CSS importer means a client module here is real client + // code (e.g. a route component co-located with a server fn), not a + // server-only file pulled into the client graph as a style dep, so + // keep its HMR instead of returning []. + const hasNonCssImporter = ctx.modules.some((clientMod) => + [...clientMod.importers].some( + (importer) => importer.id && !isCSSRequest(importer.id), + ), + ) + if (!hasNonCssImporter) { + for (const clientMod of ctx.modules) { + for (const importer of clientMod.importers) { + if (importer.id && isCSSRequest(importer.id)) { + await this.environment.reloadModule(importer) + } } } + return [] } - return [] } } }