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
84 changes: 84 additions & 0 deletions packages/plugin-rsc/e2e/basic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }) => {
Expand Down
Original file line number Diff line number Diff line change
@@ -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})</>
}
Original file line number Diff line number Diff line change
@@ -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<Props, State> {
constructor(props: Props) {
super(props)
this.state = { error: null }
}

static getDerivedStateFromError(error: Error) {
return { error }
}

render() {
if (this.state.error) {
return (
<span>
ErrorBoundary: {this.state.error.message}
<button
onClick={() => {
this.setState({ error: null })
}}
>
Reset
</button>
</span>
)
}
return this.props.children
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { TestClient } from './client'
import ErrorBoundary from './error-boundary'
import { testShared } from './shared'

export function TestHmrSharedAtomic() {
return (
<div data-testid="test-hmr-shared-atomic">
test-hmr-shared-atomic:{' '}
<ErrorBoundary>
<TestClient testSharedFromServer={testShared} />
</ErrorBoundary>
</div>
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export const testShared = 'test-shared'
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
'use client'

import { TestHmrSharedComponent } from './shared1'
import { testHmrSharedObject } from './shared2'

export function TestHmrSharedClient() {
return (
<div data-testid="test-hmr-shared-client">
test-hmr-shared-client: (<TestHmrSharedComponent />,{' '}
{testHmrSharedObject.value})
</div>
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { TestHmrSharedComponent } from './shared1'
import { testHmrSharedObject } from './shared2'

export function TestHmrSharedServer() {
return (
<div data-testid="test-hmr-shared-server">
test-hmr-shared-server: (<TestHmrSharedComponent />,{' '}
{testHmrSharedObject.value})
</div>
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export function TestHmrSharedComponent() {
return <>shared1</>
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export const testHmrSharedObject = {
value: 'shared2',
}
6 changes: 6 additions & 0 deletions packages/plugin-rsc/examples/basic/src/routes/root.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand All @@ -56,6 +59,9 @@ export function Root(props: { url: URL }) {
<TestDepCssInServer />
<TestHydrationMismatch url={props.url} />
<TestHmrClientDep />
<TestHmrSharedServer />
<TestHmrSharedClient />
<TestHmrSharedAtomic />
<TestTemporaryReference />
<TestServerActionError />
<TestReplayConsoleLogs url={props.url} />
Expand Down
4 changes: 3 additions & 1 deletion packages/plugin-rsc/src/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>()
function recurse(mod: EnvironmentModuleNode): boolean {
Expand Down
Loading