diff --git a/packages/plugin-rsc/e2e/cache-replay.test.ts b/packages/plugin-rsc/e2e/cache-replay.test.ts new file mode 100644 index 000000000..0d45e72a5 --- /dev/null +++ b/packages/plugin-rsc/e2e/cache-replay.test.ts @@ -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') + }) +} diff --git a/packages/plugin-rsc/e2e/fixture.ts b/packages/plugin-rsc/e2e/fixture.ts index 76bcbe97c..17ead0219 100644 --- a/packages/plugin-rsc/e2e/fixture.ts +++ b/packages/plugin-rsc/e2e/fixture.ts @@ -71,58 +71,50 @@ export function useFixture(options: { buildCommand?: string cliOptions?: SpawnOptions }) { - let cleanup: (() => Promise) | undefined let baseURL!: string const cwd = path.resolve(options.root) let proc!: ReturnType + 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) @@ -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() + }, } } diff --git a/packages/plugin-rsc/examples/cache-replay/.gitignore b/packages/plugin-rsc/examples/cache-replay/.gitignore new file mode 100644 index 000000000..08605c6fb --- /dev/null +++ b/packages/plugin-rsc/examples/cache-replay/.gitignore @@ -0,0 +1,2 @@ +.flight-cache +dist diff --git a/packages/plugin-rsc/examples/cache-replay/README.md b/packages/plugin-rsc/examples/cache-replay/README.md new file mode 100644 index 000000000..278a3fad8 --- /dev/null +++ b/packages/plugin-rsc/examples/cache-replay/README.md @@ -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. diff --git a/packages/plugin-rsc/examples/cache-replay/package.json b/packages/plugin-rsc/examples/cache-replay/package.json new file mode 100644 index 000000000..81ebd320b --- /dev/null +++ b/packages/plugin-rsc/examples/cache-replay/package.json @@ -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" + } +} diff --git a/packages/plugin-rsc/examples/cache-replay/src/action-state.ts b/packages/plugin-rsc/examples/cache-replay/src/action-state.ts new file mode 100644 index 000000000..3a57dc84c --- /dev/null +++ b/packages/plugin-rsc/examples/cache-replay/src/action-state.ts @@ -0,0 +1 @@ +export const actionState = { imported: false, invoked: false } diff --git a/packages/plugin-rsc/examples/cache-replay/src/action.ts b/packages/plugin-rsc/examples/cache-replay/src/action.ts new file mode 100644 index 000000000..bd008b825 --- /dev/null +++ b/packages/plugin-rsc/examples/cache-replay/src/action.ts @@ -0,0 +1,9 @@ +'use server' + +import { actionState } from './action-state' + +actionState.imported = true + +export async function testAction() { + actionState.invoked = true +} diff --git a/packages/plugin-rsc/examples/cache-replay/src/cached-content.tsx b/packages/plugin-rsc/examples/cache-replay/src/cached-content.tsx new file mode 100644 index 000000000..bf694b03e --- /dev/null +++ b/packages/plugin-rsc/examples/cache-replay/src/cached-content.tsx @@ -0,0 +1,14 @@ +import { testAction } from './action' + +export function CachedContent() { + return ( +
+

Cached content

+
+ +
+
+ ) +} diff --git a/packages/plugin-rsc/examples/cache-replay/src/framework/entry.browser.tsx b/packages/plugin-rsc/examples/cache-replay/src/framework/entry.browser.tsx new file mode 100644 index 000000000..8ac1393be --- /dev/null +++ b/packages/plugin-rsc/examples/cache-replay/src/framework/entry.browser.tsx @@ -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(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(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(fetch(renderRequest), { + temporaryReferences, + }) + setPayload(payload) + const { ok, data } = payload.returnValue! + if (!ok) throw data + return data + }) + + const browserRoot = ( + + + + + + ) + 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() diff --git a/packages/plugin-rsc/examples/cache-replay/src/framework/entry.rsc.tsx b/packages/plugin-rsc/examples/cache-replay/src/framework/entry.rsc.tsx new file mode 100644 index 000000000..c5d7b1a5c --- /dev/null +++ b/packages/plugin-rsc/examples/cache-replay/src/framework/entry.rsc.tsx @@ -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 { + 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: , + formState, + returnValue, + } + const rscOptions = { temporaryReferences } + const rscStream = renderToReadableStream(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() +} diff --git a/packages/plugin-rsc/examples/cache-replay/src/framework/entry.ssr.tsx b/packages/plugin-rsc/examples/cache-replay/src/framework/entry.ssr.tsx new file mode 100644 index 000000000..d28ef5e5a --- /dev/null +++ b/packages/plugin-rsc/examples/cache-replay/src/framework/entry.ssr.tsx @@ -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, + options: { + formState?: ReactFormState + nonce?: string + debugNojs?: boolean + }, +): Promise<{ stream: ReadableStream; status?: number }> { + const [rscStream1, rscStream2] = rscStream.tee() + + let payload: Promise | undefined + function SsrRoot() { + payload ??= createFromReadableStream(rscStream1) + return React.use(payload).root + } + + const bootstrapScriptContent = + await import.meta.viteRsc.loadBootstrapScriptContent('index') + let htmlStream: ReadableStream + let status: number | undefined + try { + htmlStream = await renderToReadableStream(, { + bootstrapScriptContent: options.debugNojs + ? undefined + : bootstrapScriptContent, + nonce: options.nonce, + formState: options.formState, + }) + } catch { + status = 500 + htmlStream = await renderToReadableStream( + + + + + , + { + bootstrapScriptContent: + `self.__NO_HYDRATE=1;` + + (options.debugNojs ? '' : bootstrapScriptContent), + nonce: options.nonce, + }, + ) + } + + let responseStream: ReadableStream = htmlStream + if (!options.debugNojs) { + responseStream = responseStream.pipeThrough( + injectRSCPayload(rscStream2, { + nonce: options.nonce, + }), + ) + } + + return { stream: responseStream, status } +} diff --git a/packages/plugin-rsc/examples/cache-replay/src/framework/error-boundary.tsx b/packages/plugin-rsc/examples/cache-replay/src/framework/error-boundary.tsx new file mode 100644 index 000000000..62e31b065 --- /dev/null +++ b/packages/plugin-rsc/examples/cache-replay/src/framework/error-boundary.tsx @@ -0,0 +1,65 @@ +'use client' + +import React from 'react' + +export function GlobalErrorBoundary(props: { children?: React.ReactNode }) { + return ( + + {props.children} + + ) +} + +class ErrorBoundary extends React.Component<{ + children?: React.ReactNode + errorComponent: React.FC<{ + error: Error + reset: () => void + }> +}> { + state: { error?: Error } = {} + + static getDerivedStateFromError(error: Error) { + return { error } + } + + reset = () => { + this.setState({ error: null }) + } + + render() { + const error = this.state.error + if (error) { + return + } + return this.props.children + } +} + +function DefaultGlobalErrorPage(props: { error: Error; reset: () => void }) { + return ( + + + Unexpected Error + + +

Caught an unexpected error

+
+          Error:{' '}
+          {import.meta.env.DEV && 'message' in props.error
+            ? props.error.message
+            : '(Unknown)'}
+        
+ + + + ) +} diff --git a/packages/plugin-rsc/examples/cache-replay/src/framework/request.tsx b/packages/plugin-rsc/examples/cache-replay/src/framework/request.tsx new file mode 100644 index 000000000..cbde5e286 --- /dev/null +++ b/packages/plugin-rsc/examples/cache-replay/src/framework/request.tsx @@ -0,0 +1,52 @@ +const URL_POSTFIX = '_.rsc' +const HEADER_ACTION_ID = 'x-rsc-action' + +type RenderRequest = { + isRsc: boolean + isAction: boolean + actionId?: string + request: Request + url: URL +} + +export function createRscRenderRequest( + urlString: string, + action?: { id: string; body: BodyInit }, +): Request { + const url = new URL(urlString) + url.pathname += URL_POSTFIX + const headers = new Headers() + if (action) { + headers.set(HEADER_ACTION_ID, action.id) + } + return new Request(url.toString(), { + method: action ? 'POST' : 'GET', + headers, + body: action?.body, + }) +} + +export function parseRenderRequest(request: Request): RenderRequest { + const url = new URL(request.url) + const isAction = request.method === 'POST' + if (url.pathname.endsWith(URL_POSTFIX)) { + url.pathname = url.pathname.slice(0, -URL_POSTFIX.length) + const actionId = request.headers.get(HEADER_ACTION_ID) || undefined + if (request.method === 'POST' && !actionId) { + throw new Error('Missing action id header for RSC action request') + } + return { + isRsc: true, + isAction, + actionId, + request: new Request(url, request), + url, + } + } + return { + isRsc: false, + isAction, + request, + url, + } +} diff --git a/packages/plugin-rsc/examples/cache-replay/src/root.tsx b/packages/plugin-rsc/examples/cache-replay/src/root.tsx new file mode 100644 index 000000000..680fa5c29 --- /dev/null +++ b/packages/plugin-rsc/examples/cache-replay/src/root.tsx @@ -0,0 +1,67 @@ +import { existsSync } from 'node:fs' +import { readFile, rm, writeFile } from 'node:fs/promises' +import { resolve } from 'node:path' +import { + createFromReadableStream, + renderToReadableStream, +} from '@vitejs/plugin-rsc/rsc' +import { actionState } from './action-state' + +export async function Root(props: { url: URL }) { + const cacheFile = resolve('.flight-cache') + let cachedContent: React.ReactNode + if (props.url.pathname === '/cache') { + const { CachedContent } = await import('./cached-content') + cachedContent = + const stream = renderToReadableStream(cachedContent) + const bytes = new Uint8Array(await new Response(stream).arrayBuffer()) + await writeFile(cacheFile, bytes) + } else if (props.url.pathname === '/read-cache') { + const bytes = await readFile(cacheFile) + cachedContent = await createFromReadableStream( + new Blob([bytes]).stream(), + ) + } else if (props.url.pathname === '/read-cache-preserve') { + const bytes = await readFile(cacheFile) + cachedContent = await createFromReadableStream( + new Blob([bytes]).stream(), + {}, + { preserveServerReferences: true }, + ) + } else if (props.url.pathname === '/delete-cache') { + await rm(cacheFile, { force: true }) + } + + return ( + + +

Persisted Flight server reference

+ +

+ Action imported in the RSC environment:{' '} + + {String(actionState.imported)} + +

+

+ Action invoked:{' '} + + {String(actionState.invoked)} + +

+

+ Cache file exists:{' '} + + {String(existsSync(cacheFile))} + +

+ {cachedContent} + + + ) +} diff --git a/packages/plugin-rsc/examples/cache-replay/tsconfig.json b/packages/plugin-rsc/examples/cache-replay/tsconfig.json new file mode 100644 index 000000000..b212cd7a7 --- /dev/null +++ b/packages/plugin-rsc/examples/cache-replay/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "erasableSyntaxOnly": true, + "allowImportingTsExtensions": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "skipLibCheck": true, + "verbatimModuleSyntax": true, + "noEmit": true, + "moduleResolution": "Bundler", + "module": "ESNext", + "target": "ESNext", + "lib": ["ESNext", "DOM"], + "types": ["vite/client", "@vitejs/plugin-rsc/types"], + "jsx": "react-jsx" + } +} diff --git a/packages/plugin-rsc/examples/cache-replay/vite.config.ts b/packages/plugin-rsc/examples/cache-replay/vite.config.ts new file mode 100644 index 000000000..0cace0701 --- /dev/null +++ b/packages/plugin-rsc/examples/cache-replay/vite.config.ts @@ -0,0 +1,30 @@ +import react from '@vitejs/plugin-react' +import rsc from '@vitejs/plugin-rsc' +import { defineConfig } from 'vite' + +export default defineConfig({ + plugins: [rsc(), react()], + environments: { + rsc: { + build: { + rollupOptions: { + input: { index: './src/framework/entry.rsc.tsx' }, + }, + }, + }, + ssr: { + build: { + rollupOptions: { + input: { index: './src/framework/entry.ssr.tsx' }, + }, + }, + }, + client: { + build: { + rollupOptions: { + input: { index: './src/framework/entry.browser.tsx' }, + }, + }, + }, + }, +}) diff --git a/packages/plugin-rsc/src/core/rsc.ts b/packages/plugin-rsc/src/core/rsc.ts index ecd46ea47..c4c65f4a2 100644 --- a/packages/plugin-rsc/src/core/rsc.ts +++ b/packages/plugin-rsc/src/core/rsc.ts @@ -2,6 +2,7 @@ import { memoize, tinyassert } from '@hiogawa/utils' import type { BundlerConfig, ImportManifestEntry, ModuleMap } from '../types' import { SERVER_DECODE_CLIENT_PREFIX, + SERVER_REFERENCE_PRESERVE_PREFIX, SERVER_REFERENCE_PREFIX, createReferenceCacheTag, removeReferenceCacheTag, @@ -27,6 +28,31 @@ export function setRequireModule(options: { // need memoize to return stable promise from __webpack_require__ ;(globalThis as any).__vite_rsc_server_require__ = memoize( async (id: string) => { + if (id.startsWith(SERVER_REFERENCE_PRESERVE_PREFIX)) { + id = id.slice(SERVER_REFERENCE_PRESERVE_PREFIX.length) + id = removeReferenceCacheTag(id) + const target = {} as any + const getOrCreateServerReference = (name: string) => { + return (target[name] ??= ReactServer.registerServerReference( + () => { + throw new Error( + `Unexpectedly decoded server reference '${id}#${name}' is called on server`, + ) + }, + id, + name, + )) + } + return new Proxy(target, { + getOwnPropertyDescriptor(_target, name) { + if (typeof name !== 'string' || name === 'then') { + return Reflect.getOwnPropertyDescriptor(target, name) + } + getOrCreateServerReference(name) + return Reflect.getOwnPropertyDescriptor(target, name) + }, + }) + } if (id.startsWith(SERVER_DECODE_CLIENT_PREFIX)) { // decode client reference on the server id = id.slice(SERVER_DECODE_CLIENT_PREFIX.length) @@ -71,7 +97,12 @@ export async function loadServerAction(id: string): Promise { return mod[name] } -export function createServerManifest(): BundlerConfig { +export function createServerManifest(options?: { + preserveServerReferences?: boolean +}): BundlerConfig { + const prefix = options?.preserveServerReferences + ? SERVER_REFERENCE_PRESERVE_PREFIX + : '' const cacheTag = import.meta.env.DEV ? createReferenceCacheTag() : '' return new Proxy( @@ -83,7 +114,7 @@ export function createServerManifest(): BundlerConfig { tinyassert(id) tinyassert(name) return { - id: SERVER_REFERENCE_PREFIX + id + cacheTag, + id: SERVER_REFERENCE_PREFIX + prefix + id + cacheTag, name, chunks: [], async: true, diff --git a/packages/plugin-rsc/src/core/shared.ts b/packages/plugin-rsc/src/core/shared.ts index bd3d18af3..adfd1a9fb 100644 --- a/packages/plugin-rsc/src/core/shared.ts +++ b/packages/plugin-rsc/src/core/shared.ts @@ -3,6 +3,8 @@ export const SERVER_REFERENCE_PREFIX = '$$server:' export const SERVER_DECODE_CLIENT_PREFIX = '$$decode-client:' +export const SERVER_REFERENCE_PRESERVE_PREFIX = '$$preserve:' + // cache bust memoized require promise during dev export function createReferenceCacheTag(): string { const cache = Math.random().toString(36).slice(2) diff --git a/packages/plugin-rsc/src/plugin.ts b/packages/plugin-rsc/src/plugin.ts index 05f576357..a17fbc79c 100644 --- a/packages/plugin-rsc/src/plugin.ts +++ b/packages/plugin-rsc/src/plugin.ts @@ -21,6 +21,7 @@ import { type ViteDevServer, defaultServerConditions, isCSSRequest, + isFileLoadingAllowed, normalizePath, parseAstAsync, } from 'vite' @@ -62,6 +63,7 @@ import { evalValue, normalizeViteImportAnalysisUrl, prepareError, + slash, } from './plugins/vite-utils' import { type TransformWrapExportFilter, @@ -346,10 +348,11 @@ export function vitePluginRscMinimal( apply: 'serve', load: { filter: { id: prefixRegex('\0virtual:vite-rsc/reference-validation?') }, - handler(id, _options) { + async handler(id, _options) { if (id.startsWith('\0virtual:vite-rsc/reference-validation?')) { const parsed = parseReferenceValidationVirtual(id) assert(parsed) + assert(this.environment.mode === 'dev') if (parsed.type === 'client') { const meta = Object.values(manager.clientReferenceMetaMap).find( (meta) => meta.referenceKey === parsed.id, @@ -359,9 +362,27 @@ export function vitePluginRscMinimal( } } if (parsed.type === 'server') { - const meta = Object.values(manager.serverReferenceMetaMap).find( + let meta = Object.values(manager.serverReferenceMetaMap).find( (meta) => meta.referenceKey === parsed.id, ) + if (!meta) { + try { + // https://github.com/vitejs/vite/blob/a477454442eff649b430f9e3c6caf2500fcb7183/packages/vite/src/node/server/transformRequest.ts#L170-L175 + const resolved = await this.resolve(parsed.id) + const id = resolved?.id ?? parsed.id + // https://github.com/vitejs/vite/blob/a477454442eff649b430f9e3c6caf2500fcb7183/packages/vite/src/node/server/transformRequest.ts#L271-L282 + const allowed = isFileLoadingAllowed( + manager.config, + slash(cleanUrl(id)), + ) + if (allowed) { + await this.environment.transformRequest(parsed.id) + } + } catch {} + meta = Object.values(manager.serverReferenceMetaMap).find( + (meta) => meta.referenceKey === parsed.id, + ) + } if (meta) { return `export {}` } diff --git a/packages/plugin-rsc/src/react/rsc/client.ts b/packages/plugin-rsc/src/react/rsc/client.ts index 3745d1ef9..ece563011 100644 --- a/packages/plugin-rsc/src/react/rsc/client.ts +++ b/packages/plugin-rsc/src/react/rsc/client.ts @@ -13,12 +13,27 @@ import type { export function createFromReadableStream( stream: ReadableStream, options: CreateFromReadableStreamEdgeOptions = {}, + extraOptions?: { + /** + * Preserve server references for re-serialization without loading their modules. + * Preserved references cannot be invoked in the current RSC environment. + * + * Disabled by default because this API also decodes bound server action + * arguments, which must revive references as callable implementations. + * + * @experimental + * @default false + */ + preserveServerReferences?: boolean + }, ): Promise { return ReactClient.createFromReadableStream(stream, { serverConsumerManifest: { // https://github.com/facebook/react/pull/31300 // https://github.com/vercel/next.js/pull/71527 - serverModuleMap: createServerManifest(), + serverModuleMap: createServerManifest({ + preserveServerReferences: extraOptions?.preserveServerReferences, + }), moduleMap: createServerDecodeClientManifest(), }, ...options, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a3d494845..860df5dcb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -602,6 +602,34 @@ importers: specifier: ^2.0.0 version: 2.0.0(vite@8.1.4(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.8.2)) + packages/plugin-rsc/examples/cache-replay: + dependencies: + react: + specifier: ^19.2.7 + version: 19.2.7 + react-dom: + specifier: ^19.2.7 + version: 19.2.7(react@19.2.7) + devDependencies: + '@types/react': + specifier: ^19.2.17 + version: 19.2.17 + '@types/react-dom': + specifier: ^19.2.3 + version: 19.2.3(@types/react@19.2.17) + '@vitejs/plugin-react': + specifier: latest + version: link:../../../plugin-react + '@vitejs/plugin-rsc': + specifier: latest + version: link:../.. + rsc-html-stream: + specifier: ^0.0.7 + version: 0.0.7 + vite: + specifier: ^8.1.4 + version: 8.1.4(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.8.2) + packages/plugin-rsc/examples/e2e: devDependencies: '@rolldown/plugin-babel':