diff --git a/packages/plugin-rsc/e2e/basic.test.ts b/packages/plugin-rsc/e2e/basic.test.ts index 7b14539b..71c2679a 100644 --- a/packages/plugin-rsc/e2e/basic.test.ts +++ b/packages/plugin-rsc/e2e/basic.test.ts @@ -316,6 +316,90 @@ function defineTest(f: Fixture) { editor.reset() await expect(locator).toContainText('[dep: 1]') }) + + test('shared hmr basic', async ({ page }) => { + await page.goto(f.url()) + await waitForHydration(page) + await using _ = await expectNoReload(page) + + // Test initial state + await expect(page.getByTestId('test-hmr-shared-server')).toContainText( + '(shared1, shared2)', + ) + await expect(page.getByTestId('test-hmr-shared-client')).toContainText( + '(shared1, shared2)', + ) + + // Test 1: Component HMR (shared1.tsx) + const editor1 = f.createEditor('src/routes/hmr-shared/shared1.tsx') + editor1.edit((s) => s.replace('shared1', 'shared1-edit')) + + // Verify both server and client components updated + await expect(page.getByTestId('test-hmr-shared-server')).toContainText( + '(shared1-edit, shared2)', + ) + await expect(page.getByTestId('test-hmr-shared-client')).toContainText( + '(shared1-edit, shared2)', + ) + + editor1.reset() + await expect(page.getByTestId('test-hmr-shared-server')).toContainText( + '(shared1, shared2)', + ) + await expect(page.getByTestId('test-hmr-shared-client')).toContainText( + '(shared1, shared2)', + ) + + // Test 2: Non-component HMR (shared2.tsx) + const editor2 = f.createEditor('src/routes/hmr-shared/shared2.tsx') + editor2.edit((s) => s.replace('shared2', 'shared2-edit')) + + // Verify both server and client components updated + await expect(page.getByTestId('test-hmr-shared-server')).toContainText( + '(shared1, shared2-edit)', + ) + await expect(page.getByTestId('test-hmr-shared-client')).toContainText( + '(shared1, shared2-edit)', + ) + + editor2.reset() + await expect(page.getByTestId('test-hmr-shared-server')).toContainText( + '(shared1, shared2)', + ) + await expect(page.getByTestId('test-hmr-shared-client')).toContainText( + '(shared1, shared2)', + ) + }) + + // for this use case to work, server refetch/render and client hmr needs to applied atomically + // at the same time. Next.js doesn't seem to support this either. + // https://github.com/hi-ogawa/reproductions/tree/main/next-rsc-hmr-shared-module + test('shared hmr not atomic', async ({ page }) => { + await page.goto(f.url()) + await waitForHydration(page) + await expect(page.getByTestId('test-hmr-shared-atomic')).toContainText( + 'ok (test-shared)', + ) + + // non-atomic update causes an error + const editor = f.createEditor('src/routes/hmr-shared/atomic/shared.tsx') + editor.edit((s) => s.replace('test-shared', 'test-shared-edit')) + await expect(page.getByTestId('test-hmr-shared-atomic')).toContainText( + 'ErrorBoundary', + ) + + await page.reload() + await expect(page.getByText('ok (test-shared-edit)')).toBeVisible() + + // non-atomic update causes an error + editor.reset() + await expect(page.getByTestId('test-hmr-shared-atomic')).toContainText( + 'ErrorBoundary', + ) + + await page.reload() + await expect(page.getByText('ok (test-shared)')).toBeVisible() + }) }) test('css @js', async ({ page }) => { diff --git a/packages/plugin-rsc/examples/basic/src/routes/hmr-shared/atomic/client.tsx b/packages/plugin-rsc/examples/basic/src/routes/hmr-shared/atomic/client.tsx new file mode 100644 index 00000000..bc28b88a --- /dev/null +++ b/packages/plugin-rsc/examples/basic/src/routes/hmr-shared/atomic/client.tsx @@ -0,0 +1,21 @@ +'use client' + +import React from 'react' +import { testShared } from './shared' + +export function TestClient({ + testSharedFromServer, +}: { + testSharedFromServer: string +}) { + React.useEffect(() => { + console.log({ testShared, testSharedFromServer }) + if (testShared !== testSharedFromServer) { + throw new Error( + `Mismatch: ${JSON.stringify({ testShared, testSharedFromServer })}`, + ) + } + }, [testShared, testSharedFromServer]) + + return <>ok ({testShared}) +} diff --git a/packages/plugin-rsc/examples/basic/src/routes/hmr-shared/atomic/error-boundary.tsx b/packages/plugin-rsc/examples/basic/src/routes/hmr-shared/atomic/error-boundary.tsx new file mode 100644 index 00000000..af59d2c4 --- /dev/null +++ b/packages/plugin-rsc/examples/basic/src/routes/hmr-shared/atomic/error-boundary.tsx @@ -0,0 +1,40 @@ +'use client' + +import * as React from 'react' + +interface Props { + children?: React.ReactNode +} + +interface State { + error: Error | null +} + +export default class ErrorBoundary extends React.Component { + constructor(props: Props) { + super(props) + this.state = { error: null } + } + + static getDerivedStateFromError(error: Error) { + return { error } + } + + render() { + if (this.state.error) { + return ( + + ErrorBoundary: {this.state.error.message} + + + ) + } + return this.props.children + } +} diff --git a/packages/plugin-rsc/examples/basic/src/routes/hmr-shared/atomic/server.tsx b/packages/plugin-rsc/examples/basic/src/routes/hmr-shared/atomic/server.tsx new file mode 100644 index 00000000..478d9ac0 --- /dev/null +++ b/packages/plugin-rsc/examples/basic/src/routes/hmr-shared/atomic/server.tsx @@ -0,0 +1,14 @@ +import { TestClient } from './client' +import ErrorBoundary from './error-boundary' +import { testShared } from './shared' + +export function TestHmrSharedAtomic() { + return ( +
+ test-hmr-shared-atomic:{' '} + + + +
+ ) +} diff --git a/packages/plugin-rsc/examples/basic/src/routes/hmr-shared/atomic/shared.tsx b/packages/plugin-rsc/examples/basic/src/routes/hmr-shared/atomic/shared.tsx new file mode 100644 index 00000000..64f17a98 --- /dev/null +++ b/packages/plugin-rsc/examples/basic/src/routes/hmr-shared/atomic/shared.tsx @@ -0,0 +1 @@ +export const testShared = 'test-shared' diff --git a/packages/plugin-rsc/examples/basic/src/routes/hmr-shared/client.tsx b/packages/plugin-rsc/examples/basic/src/routes/hmr-shared/client.tsx new file mode 100644 index 00000000..ef749722 --- /dev/null +++ b/packages/plugin-rsc/examples/basic/src/routes/hmr-shared/client.tsx @@ -0,0 +1,13 @@ +'use client' + +import { TestHmrSharedComponent } from './shared1' +import { testHmrSharedObject } from './shared2' + +export function TestHmrSharedClient() { + return ( +
+ test-hmr-shared-client: (,{' '} + {testHmrSharedObject.value}) +
+ ) +} diff --git a/packages/plugin-rsc/examples/basic/src/routes/hmr-shared/server.tsx b/packages/plugin-rsc/examples/basic/src/routes/hmr-shared/server.tsx new file mode 100644 index 00000000..f53a1037 --- /dev/null +++ b/packages/plugin-rsc/examples/basic/src/routes/hmr-shared/server.tsx @@ -0,0 +1,11 @@ +import { TestHmrSharedComponent } from './shared1' +import { testHmrSharedObject } from './shared2' + +export function TestHmrSharedServer() { + return ( +
+ test-hmr-shared-server: (,{' '} + {testHmrSharedObject.value}) +
+ ) +} diff --git a/packages/plugin-rsc/examples/basic/src/routes/hmr-shared/shared1.tsx b/packages/plugin-rsc/examples/basic/src/routes/hmr-shared/shared1.tsx new file mode 100644 index 00000000..b2fc6ea7 --- /dev/null +++ b/packages/plugin-rsc/examples/basic/src/routes/hmr-shared/shared1.tsx @@ -0,0 +1,3 @@ +export function TestHmrSharedComponent() { + return <>shared1 +} diff --git a/packages/plugin-rsc/examples/basic/src/routes/hmr-shared/shared2.tsx b/packages/plugin-rsc/examples/basic/src/routes/hmr-shared/shared2.tsx new file mode 100644 index 00000000..fd39b39f --- /dev/null +++ b/packages/plugin-rsc/examples/basic/src/routes/hmr-shared/shared2.tsx @@ -0,0 +1,3 @@ +export const testHmrSharedObject = { + value: 'shared2', +} diff --git a/packages/plugin-rsc/examples/basic/src/routes/root.tsx b/packages/plugin-rsc/examples/basic/src/routes/root.tsx index b3a61be9..41d73ace 100644 --- a/packages/plugin-rsc/examples/basic/src/routes/root.tsx +++ b/packages/plugin-rsc/examples/basic/src/routes/root.tsx @@ -32,6 +32,9 @@ import { TestHydrationMismatch } from './hydration-mismatch/server' import { TestBrowserOnly } from './browser-only/client' import { TestTransitiveCjsClient } from './deps/transitive-cjs/client' import TestDepCssInServer from '@vitejs/test-dep-css-in-server/server' +import { TestHmrSharedServer } from './hmr-shared/server' +import { TestHmrSharedClient } from './hmr-shared/client' +import { TestHmrSharedAtomic } from './hmr-shared/atomic/server' export function Root(props: { url: URL }) { return ( @@ -56,6 +59,9 @@ export function Root(props: { url: URL }) { + + + diff --git a/packages/plugin-rsc/src/plugin.ts b/packages/plugin-rsc/src/plugin.ts index 01a7e163..37dd4201 100644 --- a/packages/plugin-rsc/src/plugin.ts +++ b/packages/plugin-rsc/src/plugin.ts @@ -429,7 +429,9 @@ export default function vitePluginRsc( const ids = ctx.modules.map((mod) => mod.id).filter((v) => v !== null) if (ids.length === 0) return - // TODO: what if shared component? + // a shared component/module will have `isInsideClientBoundary = false` on `rsc` environment + // and `isInsideClientBoundary = true` on `client` environment, + // which means both server hmr and client hmr will be triggered. function isInsideClientBoundary(mods: EnvironmentModuleNode[]) { const visited = new Set() function recurse(mod: EnvironmentModuleNode): boolean {