Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 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
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/rsc-server-reference-replay/README.md
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.
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
{
"name": "@vitejs/plugin-rsc-examples-rsc-server-reference-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,9 @@
let actionImported = false
Comment thread
hi-ogawa marked this conversation as resolved.
Outdated

export function markActionImported() {
actionImported = true
}

export function wasActionImported() {
return actionImported
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
'use server'

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

markActionImported()

export async function replayedAction() {}
Comment thread
hi-ogawa marked this conversation as resolved.
Outdated
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { replayedAction } from './action'

export function CachedContent() {
return (
<form action={replayedAction}>
<button type="submit">Invoke replayed action</button>
</form>
)
}
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()
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import {
renderToReadableStream,
createTemporaryReferenceSet,
decodeReply,
loadServerAction,
decodeAction,
decodeFormState,
} from '@vitejs/plugin-rsc/rsc'
import type { ReactFormState } from 'react-dom/client'
import { Root } from '../root'
import { parseRenderRequest } from './request'

export type RscPayload = {
root: React.ReactNode
returnValue?: { ok: boolean; data: unknown }
formState?: ReactFormState
}

export default { fetch: handler }

async function handler(request: Request): Promise<Response> {
const renderRequest = parseRenderRequest(request)
request = renderRequest.request

let returnValue: RscPayload['returnValue'] | undefined
let formState: ReactFormState | undefined
let temporaryReferences: unknown | undefined
let actionStatus: number | undefined
if (renderRequest.isAction === true) {
if (renderRequest.actionId) {
const contentType = request.headers.get('content-type')
const body = contentType?.startsWith('multipart/form-data')
? await request.formData()
: await request.text()
temporaryReferences = createTemporaryReferenceSet()
const args = await decodeReply(body, { temporaryReferences })
const action = await loadServerAction(renderRequest.actionId)
try {
const data = await action.apply(null, args)
returnValue = { ok: true, data }
} catch (error) {
returnValue = { ok: false, data: error }
actionStatus = 500
}
} else {
const formData = await request.formData()
const decodedAction = await decodeAction(formData)
try {
const result = await decodedAction()
formState = await decodeFormState(result, formData)
} catch {
return new Response('Internal Server Error: server action failed', {
status: 500,
})
}
}
}

const rscPayload: RscPayload = {
root: <Root url={renderRequest.url} />,
formState,
returnValue,
}
const rscOptions = { temporaryReferences }
const rscStream = renderToReadableStream<RscPayload>(rscPayload, rscOptions)

if (renderRequest.isRsc) {
return new Response(rscStream, {
status: actionStatus,
headers: {
'content-type': 'text/x-component;charset=utf-8',
},
})
}

const ssrEntryModule = await import.meta.viteRsc.loadModule<
typeof import('./entry.ssr')
>('ssr', 'index')
const ssrResult = await ssrEntryModule.renderHTML(rscStream, {
formState,
debugNojs: renderRequest.url.searchParams.has('__nojs'),
})

return new Response(ssrResult.stream, {
status: ssrResult.status,
headers: {
'Content-type': 'text/html',
},
})
}

if (import.meta.hot) {
import.meta.hot.accept()
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { createFromReadableStream } from '@vitejs/plugin-rsc/ssr'
import React from 'react'
import type { ReactFormState } from 'react-dom/client'
import { renderToReadableStream } from 'react-dom/server.edge'
import { injectRSCPayload } from 'rsc-html-stream/server'
import type { RscPayload } from './entry.rsc'

export async function renderHTML(
rscStream: ReadableStream<Uint8Array>,
options: {
formState?: ReactFormState
nonce?: string
debugNojs?: boolean
},
): Promise<{ stream: ReadableStream<Uint8Array>; status?: number }> {
const [rscStream1, rscStream2] = rscStream.tee()

let payload: Promise<RscPayload> | undefined
function SsrRoot() {
payload ??= createFromReadableStream<RscPayload>(rscStream1)
return React.use(payload).root
}

const bootstrapScriptContent =
await import.meta.viteRsc.loadBootstrapScriptContent('index')
let htmlStream: ReadableStream<Uint8Array>
let status: number | undefined
try {
htmlStream = await renderToReadableStream(<SsrRoot />, {
bootstrapScriptContent: options.debugNojs
? undefined
: bootstrapScriptContent,
nonce: options.nonce,
formState: options.formState,
})
} catch {
status = 500
htmlStream = await renderToReadableStream(
<html>
<body>
<noscript>Internal Server Error: SSR failed</noscript>
</body>
</html>,
{
bootstrapScriptContent:
`self.__NO_HYDRATE=1;` +
(options.debugNojs ? '' : bootstrapScriptContent),
nonce: options.nonce,
},
)
}

let responseStream: ReadableStream<Uint8Array> = htmlStream
if (!options.debugNojs) {
responseStream = responseStream.pipeThrough(
injectRSCPayload(rscStream2, {
nonce: options.nonce,
}),
)
}

return { stream: responseStream, status }
}
Loading
Loading