Skip to content
Merged
Show file tree
Hide file tree
Changes from 9 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: 48 additions & 0 deletions packages/plugin-rsc/e2e/cache-replay.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import fs from 'node:fs'
import path from 'node:path'
import { expect, test } from '@playwright/test'
import { type Fixture, useFixture } from './fixture'
import { waitForHydration } from './helper'

for (const mode of ['dev', 'build'] as const) {
test.describe(mode, () => {
const f = useFixture({
root: 'examples/cache-replay',
mode,
})

defineTests(f)
})
}

function defineTests(f: Fixture) {
const cacheFile = path.join(f.root, '.flight-cache')

test.beforeEach(() => fs.rmSync(cacheFile, { force: true }))
test.afterEach(() => fs.rmSync(cacheFile, { force: true }))

test('replays a server reference without loading its module', async ({
page,
}) => {
await page.goto(f.url('/cache'))
await waitForHydration(page)
await expect(page.getByTestId('cache-exists')).toHaveText('true')
await expect(page.getByTestId('action-imported')).toHaveText('true')
await expect(page.getByTestId('action-invoked')).toHaveText('false')

await f.restart()

await page.goto(f.url('/read-cache'))

Check failure on line 35 in packages/plugin-rsc/e2e/cache-replay.test.ts

View workflow job for this annotation

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

[chromium] › e2e/cache-replay.test.ts:24:3 › dev › replays a server reference without loading its module

1) [chromium] › e2e/cache-replay.test.ts:24:3 › dev › replays a server reference without loading its module Retry #2 ─────────────────────────────────────────────────────────────────────────────────────── Error: page.goto: net::ERR_ABORTED at http://localhost:5173/read-cache Call log: - navigating to "http://localhost:5173/read-cache", waiting until "load" 33 | await f.restart() 34 | > 35 | await page.goto(f.url('/read-cache')) | ^ 36 | await waitForHydration(page) 37 | await expect( 38 | page.getByRole('heading', { name: 'Cached content' }), at /home/runner/work/vite-plugin-react/vite-plugin-react/packages/plugin-rsc/e2e/cache-replay.test.ts:35:16

Check failure on line 35 in packages/plugin-rsc/e2e/cache-replay.test.ts

View workflow job for this annotation

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

[chromium] › e2e/cache-replay.test.ts:24:3 › dev › replays a server reference without loading its module

1) [chromium] › e2e/cache-replay.test.ts:24:3 › dev › replays a server reference without loading its module Retry #1 ─────────────────────────────────────────────────────────────────────────────────────── Error: page.goto: net::ERR_ABORTED at http://localhost:5173/read-cache Call log: - navigating to "http://localhost:5173/read-cache", waiting until "load" 33 | await f.restart() 34 | > 35 | await page.goto(f.url('/read-cache')) | ^ 36 | await waitForHydration(page) 37 | await expect( 38 | page.getByRole('heading', { name: 'Cached content' }), at /home/runner/work/vite-plugin-react/vite-plugin-react/packages/plugin-rsc/e2e/cache-replay.test.ts:35:16

Check failure on line 35 in packages/plugin-rsc/e2e/cache-replay.test.ts

View workflow job for this annotation

GitHub Actions / test-rsc (ubuntu-latest / chromium) (vite-7)

[chromium] › e2e/cache-replay.test.ts:24:3 › dev › replays a server reference without loading its module

1) [chromium] › e2e/cache-replay.test.ts:24:3 › dev › replays a server reference without loading its module Retry #2 ─────────────────────────────────────────────────────────────────────────────────────── Error: page.goto: net::ERR_ABORTED at http://localhost:5173/read-cache Call log: - navigating to "http://localhost:5173/read-cache", waiting until "load" 33 | await f.restart() 34 | > 35 | await page.goto(f.url('/read-cache')) | ^ 36 | await waitForHydration(page) 37 | await expect( 38 | page.getByRole('heading', { name: 'Cached content' }), at /home/runner/work/vite-plugin-react/vite-plugin-react/packages/plugin-rsc/e2e/cache-replay.test.ts:35:16

Check failure on line 35 in packages/plugin-rsc/e2e/cache-replay.test.ts

View workflow job for this annotation

GitHub Actions / test-rsc (ubuntu-latest / chromium) (vite-7)

[chromium] › e2e/cache-replay.test.ts:24:3 › dev › replays a server reference without loading its module

1) [chromium] › e2e/cache-replay.test.ts:24:3 › dev › replays a server reference without loading its module Retry #1 ─────────────────────────────────────────────────────────────────────────────────────── Error: page.goto: net::ERR_ABORTED at http://localhost:5173/read-cache Call log: - navigating to "http://localhost:5173/read-cache", waiting until "load" 33 | await f.restart() 34 | > 35 | await page.goto(f.url('/read-cache')) | ^ 36 | await waitForHydration(page) 37 | await expect( 38 | page.getByRole('heading', { name: 'Cached content' }), at /home/runner/work/vite-plugin-react/vite-plugin-react/packages/plugin-rsc/e2e/cache-replay.test.ts:35:16

Check failure on line 35 in packages/plugin-rsc/e2e/cache-replay.test.ts

View workflow job for this annotation

GitHub Actions / test-rsc (ubuntu-latest / chromium) (react-experimental)

[chromium] › e2e/cache-replay.test.ts:24:3 › dev › replays a server reference without loading its module

1) [chromium] › e2e/cache-replay.test.ts:24:3 › dev › replays a server reference without loading its module Retry #2 ─────────────────────────────────────────────────────────────────────────────────────── Error: page.goto: net::ERR_ABORTED at http://localhost:5174/read-cache Call log: - navigating to "http://localhost:5174/read-cache", waiting until "load" 33 | await f.restart() 34 | > 35 | await page.goto(f.url('/read-cache')) | ^ 36 | await waitForHydration(page) 37 | await expect( 38 | page.getByRole('heading', { name: 'Cached content' }), at /home/runner/work/vite-plugin-react/vite-plugin-react/packages/plugin-rsc/e2e/cache-replay.test.ts:35:16

Check failure on line 35 in packages/plugin-rsc/e2e/cache-replay.test.ts

View workflow job for this annotation

GitHub Actions / test-rsc (ubuntu-latest / chromium) (react-experimental)

[chromium] › e2e/cache-replay.test.ts:24:3 › dev › replays a server reference without loading its module

1) [chromium] › e2e/cache-replay.test.ts:24:3 › dev › replays a server reference without loading its module Retry #1 ─────────────────────────────────────────────────────────────────────────────────────── Error: page.goto: net::ERR_ABORTED at http://localhost:5173/read-cache Call log: - navigating to "http://localhost:5173/read-cache", waiting until "load" 33 | await f.restart() 34 | > 35 | await page.goto(f.url('/read-cache')) | ^ 36 | await waitForHydration(page) 37 | await expect( 38 | page.getByRole('heading', { name: 'Cached content' }), at /home/runner/work/vite-plugin-react/vite-plugin-react/packages/plugin-rsc/e2e/cache-replay.test.ts:35:16

Check failure on line 35 in packages/plugin-rsc/e2e/cache-replay.test.ts

View workflow job for this annotation

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

[chromium] › e2e/cache-replay.test.ts:24:3 › dev › replays a server reference without loading its module

1) [chromium] › e2e/cache-replay.test.ts:24:3 › dev › replays a server reference without loading its module Retry #2 ─────────────────────────────────────────────────────────────────────────────────────── Error: page.goto: net::ERR_ABORTED at http://localhost:5173/read-cache Call log: - navigating to "http://localhost:5173/read-cache", waiting until "load" 33 | await f.restart() 34 | > 35 | await page.goto(f.url('/read-cache')) | ^ 36 | await waitForHydration(page) 37 | await expect( 38 | page.getByRole('heading', { name: 'Cached content' }), at /Users/runner/work/vite-plugin-react/vite-plugin-react/packages/plugin-rsc/e2e/cache-replay.test.ts:35:16

Check failure on line 35 in packages/plugin-rsc/e2e/cache-replay.test.ts

View workflow job for this annotation

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

[chromium] › e2e/cache-replay.test.ts:24:3 › dev › replays a server reference without loading its module

1) [chromium] › e2e/cache-replay.test.ts:24:3 › dev › replays a server reference without loading its module Retry #1 ─────────────────────────────────────────────────────────────────────────────────────── Error: page.goto: net::ERR_ABORTED at http://localhost:5173/read-cache Call log: - navigating to "http://localhost:5173/read-cache", waiting until "load" 33 | await f.restart() 34 | > 35 | await page.goto(f.url('/read-cache')) | ^ 36 | await waitForHydration(page) 37 | await expect( 38 | page.getByRole('heading', { name: 'Cached content' }), at /Users/runner/work/vite-plugin-react/vite-plugin-react/packages/plugin-rsc/e2e/cache-replay.test.ts:35:16

Check failure on line 35 in packages/plugin-rsc/e2e/cache-replay.test.ts

View workflow job for this annotation

GitHub Actions / test-rsc (ubuntu-latest / chromium) (react-canary)

[chromium] › e2e/cache-replay.test.ts:24:3 › dev › replays a server reference without loading its module

1) [chromium] › e2e/cache-replay.test.ts:24:3 › dev › replays a server reference without loading its module Retry #2 ─────────────────────────────────────────────────────────────────────────────────────── Error: page.goto: net::ERR_ABORTED at http://localhost:5173/read-cache Call log: - navigating to "http://localhost:5173/read-cache", waiting until "load" 33 | await f.restart() 34 | > 35 | await page.goto(f.url('/read-cache')) | ^ 36 | await waitForHydration(page) 37 | await expect( 38 | page.getByRole('heading', { name: 'Cached content' }), at /home/runner/work/vite-plugin-react/vite-plugin-react/packages/plugin-rsc/e2e/cache-replay.test.ts:35:16

Check failure on line 35 in packages/plugin-rsc/e2e/cache-replay.test.ts

View workflow job for this annotation

GitHub Actions / test-rsc (ubuntu-latest / chromium) (react-canary)

[chromium] › e2e/cache-replay.test.ts:24:3 › dev › replays a server reference without loading its module

1) [chromium] › e2e/cache-replay.test.ts:24:3 › dev › replays a server reference without loading its module Retry #1 ─────────────────────────────────────────────────────────────────────────────────────── Error: page.goto: net::ERR_ABORTED at http://localhost:5173/read-cache Call log: - navigating to "http://localhost:5173/read-cache", waiting until "load" 33 | await f.restart() 34 | > 35 | await page.goto(f.url('/read-cache')) | ^ 36 | await waitForHydration(page) 37 | await expect( 38 | page.getByRole('heading', { name: 'Cached content' }), at /home/runner/work/vite-plugin-react/vite-plugin-react/packages/plugin-rsc/e2e/cache-replay.test.ts:35:16

Check failure on line 35 in packages/plugin-rsc/e2e/cache-replay.test.ts

View workflow job for this annotation

GitHub Actions / test-rsc (ubuntu-latest / firefox)

[firefox] › e2e/cache-replay.test.ts:24:3 › dev › replays a server reference without loading its module

1) [firefox] › e2e/cache-replay.test.ts:24:3 › dev › replays a server reference without loading its module Retry #2 ─────────────────────────────────────────────────────────────────────────────────────── Error: page.goto: NS_BINDING_ABORTED Call log: - navigating to "http://localhost:5173/read-cache", waiting until "load" 33 | await f.restart() 34 | > 35 | await page.goto(f.url('/read-cache')) | ^ 36 | await waitForHydration(page) 37 | await expect( 38 | page.getByRole('heading', { name: 'Cached content' }), at /home/runner/work/vite-plugin-react/vite-plugin-react/packages/plugin-rsc/e2e/cache-replay.test.ts:35:16

Check failure on line 35 in packages/plugin-rsc/e2e/cache-replay.test.ts

View workflow job for this annotation

GitHub Actions / test-rsc (ubuntu-latest / firefox)

[firefox] › e2e/cache-replay.test.ts:24:3 › dev › replays a server reference without loading its module

1) [firefox] › e2e/cache-replay.test.ts:24:3 › dev › replays a server reference without loading its module Retry #1 ─────────────────────────────────────────────────────────────────────────────────────── Error: page.goto: NS_BINDING_ABORTED Call log: - navigating to "http://localhost:5173/read-cache", waiting until "load" 33 | await f.restart() 34 | > 35 | await page.goto(f.url('/read-cache')) | ^ 36 | await waitForHydration(page) 37 | await expect( 38 | page.getByRole('heading', { name: 'Cached content' }), at /home/runner/work/vite-plugin-react/vite-plugin-react/packages/plugin-rsc/e2e/cache-replay.test.ts:35:16
await waitForHydration(page)
await expect(
page.getByRole('heading', { name: 'Cached content' }),
).toBeVisible()
await expect(page.getByTestId('cache-exists')).toHaveText('true')
await expect(page.getByTestId('action-imported')).toHaveText('false')
await expect(page.getByTestId('action-invoked')).toHaveText('false')

await page.getByRole('button', { name: 'Invoke action' }).click()
await expect(page.getByTestId('action-imported')).toHaveText('true')
await expect(page.getByTestId('action-invoked')).toHaveText('true')
})
}
60 changes: 28 additions & 32 deletions packages/plugin-rsc/e2e/fixture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,58 +71,50 @@ export function useFixture(options: {
buildCommand?: string
cliOptions?: SpawnOptions
}) {
let cleanup: (() => Promise<void>) | undefined
let baseURL!: string

const cwd = path.resolve(options.root)
let proc!: ReturnType<typeof runCli>

async function startServer() {
const command =
options.command ??
(options.mode === 'build' ? `pnpm preview` : `pnpm dev`)
proc = runCli({
command,
label: `${options.root}:${options.mode}`,
cwd,
...options.cliOptions,
})
const port = await proc.findPort()
baseURL = `http://localhost:${port}`
}

async function stopServer() {
proc.kill()
await proc.done
}

// TODO: `beforeAll` is called again on any test failure.
// https://playwright.dev/docs/test-retries
test.beforeAll(async () => {
if (options.mode === 'dev') {
proc = runCli({
command: options.command ?? `pnpm dev`,
label: `${options.root}:dev`,
cwd,
...options.cliOptions,
})
const port = await proc.findPort()
// TODO: use `test.extend` to set `baseURL`?
baseURL = `http://localhost:${port}`
cleanup = async () => {
proc.kill()
await proc.done
}
}
if (options.mode === 'build') {
if (!process.env.TEST_SKIP_BUILD) {
const proc = runCli({
const buildProc = runCli({
command: options.buildCommand ?? `pnpm build`,
label: `${options.root}:build`,
cwd,
...options.cliOptions,
})
await proc.done
assert(proc.proc.exitCode === 0)
}
proc = runCli({
command: options.command ?? `pnpm preview`,
label: `${options.root}:preview`,
cwd,
...options.cliOptions,
})
const port = await proc.findPort()
baseURL = `http://localhost:${port}`
cleanup = async () => {
proc.kill()
await proc.done
await buildProc.done
assert(buildProc.proc.exitCode === 0)
}
}
if (options.mode) await startServer()
})

test.afterAll(async () => {
await cleanup?.()
if (options.mode) await stopServer()
})

const createEditor = useCreateEditor(cwd)
Expand All @@ -133,6 +125,10 @@ export function useFixture(options: {
url: (url: string = './') => new URL(url, baseURL).href,
createEditor,
proc: () => proc,
restart: async () => {
await stopServer()
await startServer()
},
}
}

Expand Down
2 changes: 2 additions & 0 deletions packages/plugin-rsc/examples/cache-replay/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
.flight-cache
dist
46 changes: 46 additions & 0 deletions packages/plugin-rsc/examples/cache-replay/README.md
Comment thread
hi-ogawa marked this conversation as resolved.
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
# Persisted Flight server reference replay

This example persists a Flight payload containing a server reference, restarts the server, and replays the payload without importing the server action in the RSC environment. The action is imported only when the replayed form invokes it.

The framework files follow the starter example. The application routes own persistence and replay, while the framework only performs its normal request parsing, action handling, and RSC serialization.

## Development manual test

Start the first process:

```bash
pnpm dev
```

1. Visit `http://localhost:5173/cache` and confirm the page displays `true`.
2. Stop the development server without changing the source graph.
3. Run `pnpm dev` again.
4. Visit `http://localhost:5173/` and confirm the page displays `false`.
5. Select **Invoke replayed action** and confirm the response displays `true`.

The source graph must remain unchanged across the restart so its development server-reference IDs remain stable.

## Production manual test

Build the example once:

```bash
pnpm build
```

Start the first process:

```bash
pnpm preview
```

1. Visit `http://localhost:4173/cache`.
2. Confirm the page displays `Action imported in the RSC environment: true`.
3. Stop the preview server without rebuilding.
4. Run `pnpm preview` again.
5. Visit `http://localhost:4173/` and confirm the page displays `false`.
6. Select **Invoke replayed action** and confirm the response displays `true`.

The same production build must be used for both processes because the persisted Flight payload contains build-specific server-reference IDs.

Delete `.flight-cache` to reset the example.
24 changes: 24 additions & 0 deletions packages/plugin-rsc/examples/cache-replay/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
{
"name": "@vitejs/plugin-rsc-examples-cache-replay",
"version": "0.0.0",
"private": true,
"license": "MIT",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"react": "^19.2.7",
"react-dom": "^19.2.7"
},
"devDependencies": {
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "latest",
"@vitejs/plugin-rsc": "latest",
"rsc-html-stream": "^0.0.7",
"vite": "^8.1.4"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export const actionState = { imported: false, invoked: false }
9 changes: 9 additions & 0 deletions packages/plugin-rsc/examples/cache-replay/src/action.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
'use server'

import { actionState } from './action-state'

actionState.imported = true

export async function testAction() {
actionState.invoked = true
}
12 changes: 12 additions & 0 deletions packages/plugin-rsc/examples/cache-replay/src/cached-content.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { testAction } from './action'

export function CachedContent() {
return (
<section>
<h2>Cached content</h2>
<form action={testAction}>
<button type="submit">Invoke action</button>
</form>
</section>
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
import {
createFromReadableStream,
createFromFetch,
setServerCallback,
createTemporaryReferenceSet,
encodeReply,
} from '@vitejs/plugin-rsc/browser'
import React from 'react'
import { createRoot, hydrateRoot } from 'react-dom/client'
import { rscStream } from 'rsc-html-stream/client'
import type { RscPayload } from './entry.rsc'
import { GlobalErrorBoundary } from './error-boundary'
import { createRscRenderRequest } from './request'

async function main() {
let setPayload: (value: RscPayload) => void

const initialPayload = await createFromReadableStream<RscPayload>(rscStream)

function BrowserRoot() {
const [payload, setPayload_] = React.useState(initialPayload)

React.useEffect(() => {
setPayload = (value) => React.startTransition(() => setPayload_(value))
}, [setPayload_])

React.useEffect(() => {
return listenNavigation(() => fetchRscPayload())
}, [])

return payload.root
}

async function fetchRscPayload() {
const renderRequest = createRscRenderRequest(window.location.href)
const payload = await createFromFetch<RscPayload>(fetch(renderRequest))
setPayload(payload)
}

setServerCallback(async (id, args) => {
const temporaryReferences = createTemporaryReferenceSet()
const renderRequest = createRscRenderRequest(window.location.href, {
id,
body: await encodeReply(args, { temporaryReferences }),
})
const payload = await createFromFetch<RscPayload>(fetch(renderRequest), {
temporaryReferences,
})
setPayload(payload)
const { ok, data } = payload.returnValue!
if (!ok) throw data
return data
})

const browserRoot = (
<React.StrictMode>
<GlobalErrorBoundary>
<BrowserRoot />
</GlobalErrorBoundary>
</React.StrictMode>
)
if ('__NO_HYDRATE' in globalThis) {
createRoot(document).render(browserRoot)
} else {
hydrateRoot(document, browserRoot, {
formState: initialPayload.formState,
})
}

if (import.meta.hot) {
import.meta.hot.on('rsc:update', () => {
fetchRscPayload()
})
}
}

function listenNavigation(onNavigation: () => void) {
window.addEventListener('popstate', onNavigation)

const oldPushState = window.history.pushState
window.history.pushState = function (...args) {
const result = oldPushState.apply(this, args)
onNavigation()
return result
}

const oldReplaceState = window.history.replaceState
window.history.replaceState = function (...args) {
const result = oldReplaceState.apply(this, args)
onNavigation()
return result
}

function onClick(event: MouseEvent) {
const link = (event.target as Element).closest('a')
if (
link &&
link instanceof HTMLAnchorElement &&
link.href &&
(!link.target || link.target === '_self') &&
link.origin === location.origin &&
!link.hasAttribute('download') &&
event.button === 0 &&
!event.metaKey &&
!event.ctrlKey &&
!event.altKey &&
!event.shiftKey &&
!event.defaultPrevented
) {
event.preventDefault()
history.pushState(null, '', link.href)
}
}
document.addEventListener('click', onClick)

return () => {
document.removeEventListener('click', onClick)
window.removeEventListener('popstate', onNavigation)
window.history.pushState = oldPushState
window.history.replaceState = oldReplaceState
}
}

main()
Loading
Loading