diff --git a/packages/plugin-rsc/e2e/basic.test.ts b/packages/plugin-rsc/e2e/basic.test.ts index 98a4f966b..a43786380 100644 --- a/packages/plugin-rsc/e2e/basic.test.ts +++ b/packages/plugin-rsc/e2e/basic.test.ts @@ -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) @@ -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 }) => { diff --git a/packages/plugin-rsc/examples/basic/src/routes/hmr-client-dep/client.tsx b/packages/plugin-rsc/examples/basic/src/routes/hmr-client-dep/client.tsx index 1812aadb5..3c2500fbe 100644 --- a/packages/plugin-rsc/examples/basic/src/routes/hmr-client-dep/client.tsx +++ b/packages/plugin-rsc/examples/basic/src/routes/hmr-client-dep/client.tsx @@ -3,14 +3,22 @@ import React from 'react' import { ClientDep } from './client-dep' -export function TestHmrClientDep() { +export function TestHmrClientDep(props: { url: Pick }) { const [count, setCount] = React.useState(0) return ( -
- - +
+ + + + {' '} + + re-render + {props.url.search.includes('test-hmr-client-dep-re-render') + ? ' [ok]' + : ''} +
) } diff --git a/packages/plugin-rsc/examples/basic/src/routes/hmr-client-dep2/client-dep.ts b/packages/plugin-rsc/examples/basic/src/routes/hmr-client-dep2/client-dep.ts new file mode 100644 index 000000000..fd47fd7c7 --- /dev/null +++ b/packages/plugin-rsc/examples/basic/src/routes/hmr-client-dep2/client-dep.ts @@ -0,0 +1,3 @@ +export function clientDep() { + return '[ok]' +} diff --git a/packages/plugin-rsc/examples/basic/src/routes/hmr-client-dep2/client.tsx b/packages/plugin-rsc/examples/basic/src/routes/hmr-client-dep2/client.tsx new file mode 100644 index 000000000..8bee6cdbc --- /dev/null +++ b/packages/plugin-rsc/examples/basic/src/routes/hmr-client-dep2/client.tsx @@ -0,0 +1,24 @@ +'use client' + +import React from 'react' +import { clientDep } from './client-dep' + +export function TestHmrClientDep2(props: { url: Pick }) { + const [count, setCount] = React.useState(0) + return ( +
+ + + {clientDep()} + {' '} + + re-render + {props.url.search.includes('test-hmr-client-dep2-re-render') + ? ' [ok]' + : ''} + +
+ ) +} diff --git a/packages/plugin-rsc/examples/basic/src/routes/hmr-client-dep3/client-a.tsx b/packages/plugin-rsc/examples/basic/src/routes/hmr-client-dep3/client-a.tsx new file mode 100644 index 000000000..c4154575c --- /dev/null +++ b/packages/plugin-rsc/examples/basic/src/routes/hmr-client-dep3/client-a.tsx @@ -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 ( + <> + + + {clientDep()} + + + + ) +} diff --git a/packages/plugin-rsc/examples/basic/src/routes/hmr-client-dep3/client-b.tsx b/packages/plugin-rsc/examples/basic/src/routes/hmr-client-dep3/client-b.tsx new file mode 100644 index 000000000..fbd243711 --- /dev/null +++ b/packages/plugin-rsc/examples/basic/src/routes/hmr-client-dep3/client-b.tsx @@ -0,0 +1,7 @@ +'use client' + +import { TestHmrClientDepA } from './client-a' + +export function TestHmrClientDepB() { + return +} diff --git a/packages/plugin-rsc/examples/basic/src/routes/hmr-client-dep3/client-dep-comp.tsx b/packages/plugin-rsc/examples/basic/src/routes/hmr-client-dep3/client-dep-comp.tsx new file mode 100644 index 000000000..e028d7239 --- /dev/null +++ b/packages/plugin-rsc/examples/basic/src/routes/hmr-client-dep3/client-dep-comp.tsx @@ -0,0 +1,3 @@ +export function ClientDepComp() { + return '[ok]' +} diff --git a/packages/plugin-rsc/examples/basic/src/routes/hmr-client-dep3/client-dep.ts b/packages/plugin-rsc/examples/basic/src/routes/hmr-client-dep3/client-dep.ts new file mode 100644 index 000000000..fd47fd7c7 --- /dev/null +++ b/packages/plugin-rsc/examples/basic/src/routes/hmr-client-dep3/client-dep.ts @@ -0,0 +1,3 @@ +export function clientDep() { + return '[ok]' +} diff --git a/packages/plugin-rsc/examples/basic/src/routes/hmr-client-dep3/server.tsx b/packages/plugin-rsc/examples/basic/src/routes/hmr-client-dep3/server.tsx new file mode 100644 index 000000000..a364d29c0 --- /dev/null +++ b/packages/plugin-rsc/examples/basic/src/routes/hmr-client-dep3/server.tsx @@ -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 ( +
+ + +
+ ) +} diff --git a/packages/plugin-rsc/examples/basic/src/routes/root.tsx b/packages/plugin-rsc/examples/basic/src/routes/root.tsx index eadc31eb0..cc266a546 100644 --- a/packages/plugin-rsc/examples/basic/src/routes/root.tsx +++ b/packages/plugin-rsc/examples/basic/src/routes/root.tsx @@ -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 ( @@ -63,7 +65,9 @@ export function Root(props: { url: URL }) { - + + + diff --git a/packages/plugin-rsc/src/plugin.ts b/packages/plugin-rsc/src/plugin.ts index 4df6fb845..5951ef801 100644 --- a/packages/plugin-rsc/src/plugin.ts +++ b/packages/plugin-rsc/src/plugin.ts @@ -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) + } + if (rscPluginOptions.disableServerHandler) return if (rscPluginOptions.serverHandler === false) return const options = rscPluginOptions.serverHandler ?? {