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
75 changes: 75 additions & 0 deletions packages/plugin-rsc/e2e/cache-replay.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
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'

test.describe('dev', () => {
const f = useFixture({
root: 'examples/cache-replay',
mode: 'dev',
})
defineTests(f)
})

test.describe('build', () => {
const f = useFixture({
root: 'examples/cache-replay',
mode: 'build',
})
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('/'))
await expect(page.getByTestId('cache-exists')).toHaveText('false')

// Serialize content after importing its action.
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')

// Avoid a dev client reload racing navigation after restart.
await page.goto('about:blank')
await f.restart()

// Default replay imports the referenced action.
await page.goto(f.url('/read-cache'))
await waitForHydration(page)
await expect(page.getByTestId('cached-content')).toBeVisible()
await page.goto(f.url('/'))
await expect(page.getByTestId('cache-exists')).toHaveText('true')
await expect(page.getByTestId('action-imported')).toHaveText('true')
await expect(page.getByTestId('action-invoked')).toHaveText('false')

// Avoid a dev client reload racing navigation after restart.
await page.goto('about:blank')
await f.restart()

// Preserved replay leaves the referenced action unloaded.
await page.goto(f.url('/read-cache-preserve'))
await waitForHydration(page)
await expect(page.getByTestId('cached-content')).toBeVisible()
await page.goto(f.url('/'))
await expect(page.getByTestId('cache-exists')).toHaveText('true')
await expect(page.getByTestId('action-imported')).toHaveText('false')
await expect(page.getByTestId('action-invoked')).toHaveText('false')

// Invoking the preserved reference imports and runs the action.
await page.goto(f.url('/read-cache-preserve'))
await waitForHydration(page)
await page.getByTestId('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
32 changes: 32 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,32 @@
# Persisted Flight server reference replay

This example persists a Flight payload containing a server reference and compares the default replay, which imports the server action in the RSC environment, with a replay that preserves the reference. With preservation enabled, 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.

## Manual test

Start the development server:

```bash
pnpm dev
```

Alternatively, build once and start the production server:

```bash
pnpm build
pnpm preview
```

1. Visit `/cache` and confirm the action is imported.
2. Stop and restart the server with `pnpm dev` or `pnpm preview`.
3. Visit `/read-cache`, then visit `/` and confirm the action is imported.
4. Restart the server again.
5. Visit `/read-cache-preserve`, then visit `/` and confirm the action is not imported.
6. Return to `/read-cache-preserve`.
7. Select **Invoke action** and confirm the action is imported and invoked.

Do not change the source graph between development server restarts or rebuild between production server restarts. The persisted Flight payload contains server-reference IDs that must remain stable.

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
}
14 changes: 14 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,14 @@
import { testAction } from './action'

export function CachedContent() {
return (
<section data-testid="cached-content">
<h2>Cached content</h2>
<form action={testAction}>
<button type="submit" data-testid="invoke-action">
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