Skip to content

Commit a3690f3

Browse files
hi-ogawaOpenCode
andauthored
feat(rsc): ability to not load server reference module during createFromReadableStream on rsc environment (#1289)
Co-authored-by: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Co-authored-by: OpenCode <noreply@opencode.ai>
1 parent f098083 commit a3690f3

21 files changed

Lines changed: 799 additions & 37 deletions
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import fs from 'node:fs'
2+
import path from 'node:path'
3+
import { expect, test } from '@playwright/test'
4+
import { type Fixture, useFixture } from './fixture'
5+
import { waitForHydration } from './helper'
6+
7+
test.describe('dev', () => {
8+
const f = useFixture({
9+
root: 'examples/cache-replay',
10+
mode: 'dev',
11+
})
12+
defineTests(f)
13+
})
14+
15+
test.describe('build', () => {
16+
const f = useFixture({
17+
root: 'examples/cache-replay',
18+
mode: 'build',
19+
})
20+
defineTests(f)
21+
})
22+
23+
function defineTests(f: Fixture) {
24+
const cacheFile = path.join(f.root, '.flight-cache')
25+
26+
test.beforeEach(() => fs.rmSync(cacheFile, { force: true }))
27+
test.afterEach(() => fs.rmSync(cacheFile, { force: true }))
28+
29+
test('replays a server reference without loading its module', async ({
30+
page,
31+
}) => {
32+
await page.goto(f.url('/'))
33+
await expect(page.getByTestId('cache-exists')).toHaveText('false')
34+
35+
// Serialize content after importing its action.
36+
await page.goto(f.url('/cache'))
37+
await waitForHydration(page)
38+
await expect(page.getByTestId('cache-exists')).toHaveText('true')
39+
await expect(page.getByTestId('action-imported')).toHaveText('true')
40+
await expect(page.getByTestId('action-invoked')).toHaveText('false')
41+
42+
// Avoid a dev client reload racing navigation after restart.
43+
await page.goto('about:blank')
44+
await f.restart()
45+
46+
// Default replay imports the referenced action.
47+
await page.goto(f.url('/read-cache'))
48+
await waitForHydration(page)
49+
await expect(page.getByTestId('cached-content')).toBeVisible()
50+
await page.goto(f.url('/'))
51+
await expect(page.getByTestId('cache-exists')).toHaveText('true')
52+
await expect(page.getByTestId('action-imported')).toHaveText('true')
53+
await expect(page.getByTestId('action-invoked')).toHaveText('false')
54+
55+
// Avoid a dev client reload racing navigation after restart.
56+
await page.goto('about:blank')
57+
await f.restart()
58+
59+
// Preserved replay leaves the referenced action unloaded.
60+
await page.goto(f.url('/read-cache-preserve'))
61+
await waitForHydration(page)
62+
await expect(page.getByTestId('cached-content')).toBeVisible()
63+
await page.goto(f.url('/'))
64+
await expect(page.getByTestId('cache-exists')).toHaveText('true')
65+
await expect(page.getByTestId('action-imported')).toHaveText('false')
66+
await expect(page.getByTestId('action-invoked')).toHaveText('false')
67+
68+
// Invoking the preserved reference imports and runs the action.
69+
await page.goto(f.url('/read-cache-preserve'))
70+
await waitForHydration(page)
71+
await page.getByTestId('invoke-action').click()
72+
await expect(page.getByTestId('action-imported')).toHaveText('true')
73+
await expect(page.getByTestId('action-invoked')).toHaveText('true')
74+
})
75+
}

packages/plugin-rsc/e2e/fixture.ts

Lines changed: 28 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -71,58 +71,50 @@ export function useFixture(options: {
7171
buildCommand?: string
7272
cliOptions?: SpawnOptions
7373
}) {
74-
let cleanup: (() => Promise<void>) | undefined
7574
let baseURL!: string
7675

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

79+
async function startServer() {
80+
const command =
81+
options.command ??
82+
(options.mode === 'build' ? `pnpm preview` : `pnpm dev`)
83+
proc = runCli({
84+
command,
85+
label: `${options.root}:${options.mode}`,
86+
cwd,
87+
...options.cliOptions,
88+
})
89+
const port = await proc.findPort()
90+
baseURL = `http://localhost:${port}`
91+
}
92+
93+
async function stopServer() {
94+
proc.kill()
95+
await proc.done
96+
}
97+
8098
// TODO: `beforeAll` is called again on any test failure.
8199
// https://playwright.dev/docs/test-retries
82100
test.beforeAll(async () => {
83-
if (options.mode === 'dev') {
84-
proc = runCli({
85-
command: options.command ?? `pnpm dev`,
86-
label: `${options.root}:dev`,
87-
cwd,
88-
...options.cliOptions,
89-
})
90-
const port = await proc.findPort()
91-
// TODO: use `test.extend` to set `baseURL`?
92-
baseURL = `http://localhost:${port}`
93-
cleanup = async () => {
94-
proc.kill()
95-
await proc.done
96-
}
97-
}
98101
if (options.mode === 'build') {
99102
if (!process.env.TEST_SKIP_BUILD) {
100-
const proc = runCli({
103+
const buildProc = runCli({
101104
command: options.buildCommand ?? `pnpm build`,
102105
label: `${options.root}:build`,
103106
cwd,
104107
...options.cliOptions,
105108
})
106-
await proc.done
107-
assert(proc.proc.exitCode === 0)
108-
}
109-
proc = runCli({
110-
command: options.command ?? `pnpm preview`,
111-
label: `${options.root}:preview`,
112-
cwd,
113-
...options.cliOptions,
114-
})
115-
const port = await proc.findPort()
116-
baseURL = `http://localhost:${port}`
117-
cleanup = async () => {
118-
proc.kill()
119-
await proc.done
109+
await buildProc.done
110+
assert(buildProc.proc.exitCode === 0)
120111
}
121112
}
113+
if (options.mode) await startServer()
122114
})
123115

124116
test.afterAll(async () => {
125-
await cleanup?.()
117+
if (options.mode) await stopServer()
126118
})
127119

128120
const createEditor = useCreateEditor(cwd)
@@ -133,6 +125,10 @@ export function useFixture(options: {
133125
url: (url: string = './') => new URL(url, baseURL).href,
134126
createEditor,
135127
proc: () => proc,
128+
restart: async () => {
129+
await stopServer()
130+
await startServer()
131+
},
136132
}
137133
}
138134

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
.flight-cache
2+
dist
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
# Persisted Flight server reference replay
2+
3+
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.
4+
5+
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.
6+
7+
## Manual test
8+
9+
Start the development server:
10+
11+
```bash
12+
pnpm dev
13+
```
14+
15+
Alternatively, build once and start the production server:
16+
17+
```bash
18+
pnpm build
19+
pnpm preview
20+
```
21+
22+
1. Visit `/cache` and confirm the action is imported.
23+
2. Stop and restart the server with `pnpm dev` or `pnpm preview`.
24+
3. Visit `/read-cache`, then visit `/` and confirm the action is imported.
25+
4. Restart the server again.
26+
5. Visit `/read-cache-preserve`, then visit `/` and confirm the action is not imported.
27+
6. Return to `/read-cache-preserve`.
28+
7. Select **Invoke action** and confirm the action is imported and invoked.
29+
30+
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.
31+
32+
Delete `.flight-cache` to reset the example.
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
{
2+
"name": "@vitejs/plugin-rsc-examples-cache-replay",
3+
"version": "0.0.0",
4+
"private": true,
5+
"license": "MIT",
6+
"type": "module",
7+
"scripts": {
8+
"dev": "vite",
9+
"build": "vite build",
10+
"preview": "vite preview"
11+
},
12+
"dependencies": {
13+
"react": "^19.2.7",
14+
"react-dom": "^19.2.7"
15+
},
16+
"devDependencies": {
17+
"@types/react": "^19.2.17",
18+
"@types/react-dom": "^19.2.3",
19+
"@vitejs/plugin-react": "latest",
20+
"@vitejs/plugin-rsc": "latest",
21+
"rsc-html-stream": "^0.0.7",
22+
"vite": "^8.1.4"
23+
}
24+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export const actionState = { imported: false, invoked: false }
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
'use server'
2+
3+
import { actionState } from './action-state'
4+
5+
actionState.imported = true
6+
7+
export async function testAction() {
8+
actionState.invoked = true
9+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import { testAction } from './action'
2+
3+
export function CachedContent() {
4+
return (
5+
<section data-testid="cached-content">
6+
<h2>Cached content</h2>
7+
<form action={testAction}>
8+
<button type="submit" data-testid="invoke-action">
9+
Invoke action
10+
</button>
11+
</form>
12+
</section>
13+
)
14+
}
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
import {
2+
createFromReadableStream,
3+
createFromFetch,
4+
setServerCallback,
5+
createTemporaryReferenceSet,
6+
encodeReply,
7+
} from '@vitejs/plugin-rsc/browser'
8+
import React from 'react'
9+
import { createRoot, hydrateRoot } from 'react-dom/client'
10+
import { rscStream } from 'rsc-html-stream/client'
11+
import type { RscPayload } from './entry.rsc'
12+
import { GlobalErrorBoundary } from './error-boundary'
13+
import { createRscRenderRequest } from './request'
14+
15+
async function main() {
16+
let setPayload: (value: RscPayload) => void
17+
18+
const initialPayload = await createFromReadableStream<RscPayload>(rscStream)
19+
20+
function BrowserRoot() {
21+
const [payload, setPayload_] = React.useState(initialPayload)
22+
23+
React.useEffect(() => {
24+
setPayload = (value) => React.startTransition(() => setPayload_(value))
25+
}, [setPayload_])
26+
27+
React.useEffect(() => {
28+
return listenNavigation(() => fetchRscPayload())
29+
}, [])
30+
31+
return payload.root
32+
}
33+
34+
async function fetchRscPayload() {
35+
const renderRequest = createRscRenderRequest(window.location.href)
36+
const payload = await createFromFetch<RscPayload>(fetch(renderRequest))
37+
setPayload(payload)
38+
}
39+
40+
setServerCallback(async (id, args) => {
41+
const temporaryReferences = createTemporaryReferenceSet()
42+
const renderRequest = createRscRenderRequest(window.location.href, {
43+
id,
44+
body: await encodeReply(args, { temporaryReferences }),
45+
})
46+
const payload = await createFromFetch<RscPayload>(fetch(renderRequest), {
47+
temporaryReferences,
48+
})
49+
setPayload(payload)
50+
const { ok, data } = payload.returnValue!
51+
if (!ok) throw data
52+
return data
53+
})
54+
55+
const browserRoot = (
56+
<React.StrictMode>
57+
<GlobalErrorBoundary>
58+
<BrowserRoot />
59+
</GlobalErrorBoundary>
60+
</React.StrictMode>
61+
)
62+
if ('__NO_HYDRATE' in globalThis) {
63+
createRoot(document).render(browserRoot)
64+
} else {
65+
hydrateRoot(document, browserRoot, {
66+
formState: initialPayload.formState,
67+
})
68+
}
69+
70+
if (import.meta.hot) {
71+
import.meta.hot.on('rsc:update', () => {
72+
fetchRscPayload()
73+
})
74+
}
75+
}
76+
77+
function listenNavigation(onNavigation: () => void) {
78+
window.addEventListener('popstate', onNavigation)
79+
80+
const oldPushState = window.history.pushState
81+
window.history.pushState = function (...args) {
82+
const result = oldPushState.apply(this, args)
83+
onNavigation()
84+
return result
85+
}
86+
87+
const oldReplaceState = window.history.replaceState
88+
window.history.replaceState = function (...args) {
89+
const result = oldReplaceState.apply(this, args)
90+
onNavigation()
91+
return result
92+
}
93+
94+
function onClick(event: MouseEvent) {
95+
const link = (event.target as Element).closest('a')
96+
if (
97+
link &&
98+
link instanceof HTMLAnchorElement &&
99+
link.href &&
100+
(!link.target || link.target === '_self') &&
101+
link.origin === location.origin &&
102+
!link.hasAttribute('download') &&
103+
event.button === 0 &&
104+
!event.metaKey &&
105+
!event.ctrlKey &&
106+
!event.altKey &&
107+
!event.shiftKey &&
108+
!event.defaultPrevented
109+
) {
110+
event.preventDefault()
111+
history.pushState(null, '', link.href)
112+
}
113+
}
114+
document.addEventListener('click', onClick)
115+
116+
return () => {
117+
document.removeEventListener('click', onClick)
118+
window.removeEventListener('popstate', onNavigation)
119+
window.history.pushState = oldPushState
120+
window.history.replaceState = oldReplaceState
121+
}
122+
}
123+
124+
main()

0 commit comments

Comments
 (0)