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
48 changes: 45 additions & 3 deletions packages/plugin-rsc/e2e/basic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -415,7 +415,7 @@
await page.getByRole('button', { name: 'client-counter: 0' }).click()
})

test('non-boundary client hmr', async ({ page }) => {
test('non-client-reference client hmr', async ({ page }) => {
await page.goto(f.url())
await waitForHydration(page)

Expand All @@ -428,13 +428,55 @@
editor.edit((s) => s.replace('[ok]', '[ok-edit]'))
await expect(locator).toHaveText('test-hmr-client-dep: 1[ok-edit]')

// check next rsc payload includes current client reference and preserves state
await page.locator("a[href='?test-hmr-client-dep-re-render']").click()
await expect(
page.locator("a[href='?test-hmr-client-dep-re-render']"),
).toHaveText('re-render [ok]')
await expect(locator).toHaveText('test-hmr-client-dep: 1[ok-edit]')

// check next ssr is also updated
const res = await page.reload()
const res = await page.request.get(f.url(), {
headers: {
accept: 'text/html',
},
})
expect(await res?.text()).toContain('[ok-edit]')

editor.reset()
await expect(locator).toHaveText('test-hmr-client-dep: 1[ok]')
})

test('non-self-accepting client hmr', async ({ page }) => {
await page.goto(f.url())
await waitForHydration(page)

const locator = page.getByTestId('test-hmr-client-dep2')
await expect(locator).toHaveText('test-hmr-client-dep2: 0[ok]')
await locator.locator('button').click()
await expect(locator).toHaveText('test-hmr-client-dep2: 1[ok]')

const editor = f.createEditor('src/routes/hmr-client-dep2/client-dep.ts')
editor.edit((s) => s.replace('[ok]', '[ok-edit]'))
await expect(locator).toHaveText('test-hmr-client-dep2: 1[ok-edit]')

// check next rsc payload includes an updated client reference and preserves state
await page.locator("a[href='?test-hmr-client-dep2-re-render']").click()
await expect(
page.locator("a[href='?test-hmr-client-dep2-re-render']"),
).toHaveText('re-render [ok]')
await expect(locator).toHaveText('test-hmr-client-dep2: 1[ok-edit]')

// check next ssr is also updated
const res = await page.request.get(f.url(), {
headers: {
accept: 'text/html',
},
})
expect(await res?.text()).toContain('[ok-edit]')

editor.reset()
await expect(locator).toHaveText('test-hmr-client-dep: 0[ok]')
await expect(locator).toHaveText('test-hmr-client-dep2: 1[ok]')
})

test('server hmr', async ({ page }) => {
Expand Down Expand Up @@ -766,7 +808,7 @@
`/* color: rgb(0, 165, 255); */`,
),
)
await expect(page.locator('.test-style-server')).toHaveCSS(

Check failure on line 811 in packages/plugin-rsc/e2e/basic.test.ts

View workflow job for this annotation

GitHub Actions / test-rsc (macos-latest / chromium)

[chromium] › e2e/basic.test.ts:794:5 › dev-default › css hmr server

1) [chromium] › e2e/basic.test.ts:794:5 › dev-default › css hmr server ─────────────────────────── Error: expect(locator).toHaveCSS(expected) failed Locator: locator('.test-style-server') Expected string: "rgb(0, 0, 0)" Received string: "rgb(0, 165, 255)" Timeout: 5000ms Call log: - Expect "toHaveCSS" with timeout 5000ms - waiting for locator('.test-style-server') 9 × locator resolved to <div class="test-style-server">test-style-server</div> - unexpected value "rgb(0, 165, 255)" 809 | ), 810 | ) > 811 | await expect(page.locator('.test-style-server')).toHaveCSS( | ^ 812 | 'color', 813 | 'rgb(0, 0, 0)', 814 | ) at /Users/runner/work/vite-plugin-react/vite-plugin-react/packages/plugin-rsc/e2e/basic.test.ts:811:56
'color',
'rgb(0, 0, 0)',
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,22 @@
import React from 'react'
import { ClientDep } from './client-dep'

export function TestHmrClientDep() {
export function TestHmrClientDep(props: { url: Pick<URL, 'search'> }) {
const [count, setCount] = React.useState(0)
return (
<div data-testid="test-hmr-client-dep">
<button onClick={() => setCount((c) => c + 1)}>
test-hmr-client-dep: {count}
</button>
<ClientDep />
<div>
<span data-testid="test-hmr-client-dep">
<button onClick={() => setCount((c) => c + 1)}>
test-hmr-client-dep: {count}
</button>
<ClientDep />
</span>{' '}
<a href="?test-hmr-client-dep-re-render">
re-render
{props.url.search.includes('test-hmr-client-dep-re-render')
? ' [ok]'
: ''}
</a>
</div>
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export function clientDep() {
return '[ok]'
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
'use client'

import React from 'react'
import { clientDep } from './client-dep'

export function TestHmrClientDep2(props: { url: Pick<URL, 'search'> }) {
const [count, setCount] = React.useState(0)
return (
<div>
<span data-testid="test-hmr-client-dep2">
<button onClick={() => setCount((c) => c + 1)}>
test-hmr-client-dep2: {count}
</button>
{clientDep()}
</span>{' '}
<a href="?test-hmr-client-dep2-re-render">
re-render
{props.url.search.includes('test-hmr-client-dep2-re-render')
? ' [ok]'
: ''}
</a>
</div>
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
'use client'

import React from 'react'
import { clientDep } from './client-dep'
import { ClientDepComp } from './client-dep-comp'

export function TestHmrClientDepA() {
const [count, setCount] = React.useState(0)
return (
<>
<span data-testid="test-hmr-client-dep3">
<button onClick={() => setCount((c) => c + 1)}>
test-hmr-client-dep3: {count}
</button>
{clientDep()}
<ClientDepComp />
</span>
</>
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
'use client'

import { TestHmrClientDepA } from './client-a'

export function TestHmrClientDepB() {
return <TestHmrClientDepA />
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export function ClientDepComp() {
return '[ok]'
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export function clientDep() {
return '[ok]'
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { TestHmrClientDepA } from './client-a'
import { TestHmrClientDepB } from './client-b'

// example to demonstrate a folowing behavior
// https://github.com/vitejs/vite-plugin-react/pull/788#issuecomment-3227656612
/*
server server
| |
v v
client-a client-a?t=xx <-- client-b
| |
v v
client-dep-comp?t=xx
*/

export function TestHmrClientDep3() {
return (
<div>
<TestHmrClientDepA />
<TestHmrClientDepB />
</div>
)
}
6 changes: 5 additions & 1 deletion packages/plugin-rsc/examples/basic/src/routes/root.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@ import { TestHmrSwitchClient } from './hmr-switch/client'
import { TestTreeShakeServer } from './tree-shake/server'
import { TestClientChunkServer } from './chunk/server'
import { TestTailwind } from './tailwind'
import { TestHmrClientDep2 } from './hmr-client-dep2/client'
import { TestHmrClientDep3 } from './hmr-client-dep3/server'

export function Root(props: { url: URL }) {
return (
Expand All @@ -63,7 +65,9 @@ export function Root(props: { url: URL }) {
<TestTailwind />
<TestDepCssInServer />
<TestHydrationMismatch url={props.url} />
<TestHmrClientDep />
<TestHmrClientDep url={{ search: props.url.search }} />
<TestHmrClientDep2 url={{ search: props.url.search }} />
<TestHmrClientDep3 />
<TestHmrSharedServer />
<TestHmrSharedClient />
<TestHmrSharedAtomic />
Expand Down
29 changes: 29 additions & 0 deletions packages/plugin-rsc/src/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -415,6 +415,35 @@ export default function vitePluginRsc(
configureServer(server) {
;(globalThis as any).__viteRscDevServer = server

// intercept client hmr to propagate client boundary invalidation to server environment
const oldSend = server.environments.client.hot.send
server.environments.client.hot.send = async function (
this,
...args: any[]
) {
const e = args[0] as vite.UpdatePayload
if (e && typeof e === 'object' && e.type === 'update') {
for (const update of e.updates) {
if (update.type === 'js-update') {
const mod =
server.environments.client.moduleGraph.urlToModuleMap.get(
update.path,
)
if (mod && mod.id && manager.clientReferenceMetaMap[mod.id]) {
const serverMod =
server.environments.rsc!.moduleGraph.getModuleById(mod.id)
if (serverMod) {
server.environments.rsc!.moduleGraph.invalidateModule(
serverMod,
)
}
}
}
}
}
return oldSend.apply(this, args as any)
}
Comment on lines +418 to +445
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Since this only covers the case where client boundary gets hmr-ed, technically there are edge cases like client reference itself doesn't self-accept or invalidateModule is directly used, so they causes full-reload instead.

Copy link
Contributor Author

@hi-ogawa hi-ogawa Aug 27, 2025

Choose a reason for hiding this comment

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

However, patching invalidateModule directly will break the first test case "non-client-reference client hmr" as this is more aggressive invalidation.

const oldInvalidateModule = server.environments.client.moduleGraph.invalidateModule
server.environments.client.moduleGraph.invalidateModule = function (this, ...args) {
  const mod = args[0];
  if (mod && mod.id && manager.clientReferenceMetaMap[mod.id]) {
    const serverMod =
      server.environments.rsc!.moduleGraph.getModuleById(mod.id)
    if (serverMod) {
      server.environments.rsc!.moduleGraph.invalidateModule(
        serverMod,
      )
    }
  }
  return oldInvalidateModule.apply(this, args);
}


if (rscPluginOptions.disableServerHandler) return
if (rscPluginOptions.serverHandler === false) return
const options = rscPluginOptions.serverHandler ?? {
Expand Down
Loading