Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 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 @@ function defineTest(f: Fixture) {
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 @@ function defineTest(f: Fixture) {
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
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>
)
}
4 changes: 3 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,7 @@ 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'

export function Root(props: { url: URL }) {
return (
Expand All @@ -63,7 +64,8 @@ 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 }} />
<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