Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 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
1 change: 1 addition & 0 deletions packages/plugin-rsc/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ npm create vite@latest -- --template rsc

- [`./examples/basic`](./examples/basic) - Comprehensive showcase of standard RSC features and the primary E2E test fixture.
- [`./examples/use-cache`](./examples/use-cache) - Minimal cache feature inspired by Next.js's `"use cache"`, built with generic transform and RSC runtime APIs.
- [`./examples/use-cache-callable`](./examples/use-cache-callable) - Inline cache wrapper exported as a callable Server Function through a custom transform.
- [`./examples/custom-server-function`](./examples/custom-server-function) - Third-party Server Function directive integration using server reference claims.
- [`./examples/ssg`](./examples/ssg) - Static site generation with MDX and client components for interactivity.
- [`./examples/ppr`](./examples/ppr) - Partial prerendering with a reusable static HTML shell and request-time RSC content.
Expand Down
38 changes: 38 additions & 0 deletions packages/plugin-rsc/e2e/use-cache-callable.test.ts
Comment thread
hi-ogawa marked this conversation as resolved.
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { expect, test } from '@playwright/test'
import { type Fixture, useFixture } from './fixture'
import { expectNoPageError, waitForHydration } from './helper'

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

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

function defineTests(f: Fixture) {
test('calls the exported cache wrapper', async ({ page }) => {
using _errors = expectNoPageError(page)
await page.goto(f.url())
await waitForHydration(page)

const example = page.getByTestId('callable-cache')
await expect(example.locator('span')).toHaveText(
'requests: 0; result: none',
)
await example.getByRole('button', { name: 'same' }).click()
await expect(example.locator('span')).toHaveText(
'requests: 1; result: captured:same:1',
)
await example.getByRole('button', { name: 'same' }).click()
await expect(example.locator('span')).toHaveText(
'requests: 2; result: captured:same:1',
)
await example.getByRole('button', { name: 'different' }).click()
await expect(example.locator('span')).toHaveText(
'requests: 3; result: captured:different:2',
)
})
}
2 changes: 2 additions & 0 deletions packages/plugin-rsc/examples/use-cache-callable/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
node_modules
dist
23 changes: 23 additions & 0 deletions packages/plugin-rsc/examples/use-cache-callable/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
{
"name": "@vitejs/plugin-rsc-examples-use-cache-callable",
"private": true,
"license": "MIT",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"react": "^19.2.8",
"react-dom": "^19.2.8"
},
"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,17 @@
type CacheFunction = (...args: string[]) => Promise<string>

export default function cacheWrapper(
implementation: CacheFunction,
): CacheFunction {
const entries = new Map<string, Promise<string>>()

return (...args) => {
const key = JSON.stringify(args)
let result = entries.get(key)
if (!result) {
result = implementation(...args)
entries.set(key, result)
}
return result
}
}
25 changes: 25 additions & 0 deletions packages/plugin-rsc/examples/use-cache-callable/src/client.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
'use client'

import { useState } from 'react'

export function CallableCacheClient(props: {
action: (argument: string) => Promise<string>
}) {
const [requests, setRequests] = useState(0)
const [result, setResult] = useState('none')

async function call(argument: string) {
setRequests((value) => value + 1)
setResult(await props.action(argument))
}

return (
<div data-testid="callable-cache">
<button onClick={() => void call('same')}>same</button>
<button onClick={() => void call('different')}>different</button>
<span>
requests: {requests}; result: {result}
</span>
</div>
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import {
createFromFetch,
createFromReadableStream,
createTemporaryReferenceSet,
encodeReply,
setServerCallback,
} from '@vitejs/plugin-rsc/browser'
import { useEffect, useState } from 'react'
import { hydrateRoot } from 'react-dom/client'
import { rscStream } from 'rsc-html-stream/client'
import type { RscPayload } from './entry.rsc'
import { createRscRenderRequest } from './request'

async function main() {
const initialPayload = await createFromReadableStream<RscPayload>(rscStream)
let updatePayload: (payload: RscPayload) => void

function BrowserRoot() {
const [payload, setPayload] = useState(initialPayload)
useEffect(() => {
updatePayload = setPayload
}, [])
return payload.root
}

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

hydrateRoot(document, <BrowserRoot />)
}

main()
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import {
createTemporaryReferenceSet,
decodeReply,
loadServerAction,
renderToReadableStream,
} from '@vitejs/plugin-rsc/rsc'
import { Root } from '../root'
import { parseRenderRequest } from './request'

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

export default { fetch: handler }

async function handler(request: Request): Promise<Response> {
const renderRequest = parseRenderRequest(request)
let returnValue: RscPayload['returnValue']
let temporaryReferences: unknown
let status: number | undefined

if (renderRequest.actionId) {
temporaryReferences = createTemporaryReferenceSet()
const args = await decodeReply(await renderRequest.request.text(), {
temporaryReferences,
})
const action = await loadServerAction(renderRequest.actionId)
try {
returnValue = { ok: true, data: await action.apply(null, args) }
} catch (error) {
returnValue = { ok: false, data: error }
status = 500
}
}

const payload: RscPayload = {
root: <Root />,
returnValue,
}
const rscStream = renderToReadableStream<RscPayload>(payload, {
temporaryReferences,
})
if (renderRequest.isRsc) {
return new Response(rscStream, {
status,
headers: { 'content-type': 'text/x-component;charset=utf-8' },
})
}

const ssr = await import.meta.viteRsc.loadModule<
typeof import('./entry.ssr')
>('ssr', 'index')
const htmlStream = await ssr.renderHTML(rscStream)
return new Response(htmlStream, {
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,22 @@
import { createFromReadableStream } from '@vitejs/plugin-rsc/ssr'
import { use } from 'react'
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>) {
const [ssrStream, browserStream] = rscStream.tee()
let payload: Promise<RscPayload> | undefined

function SsrRoot() {
payload ??= createFromReadableStream<RscPayload>(ssrStream)
return use(payload).root
}

const bootstrapScriptContent =
await import.meta.viteRsc.loadBootstrapScriptContent('index')
const htmlStream = await renderToReadableStream(<SsrRoot />, {
bootstrapScriptContent,
})
return htmlStream.pipeThrough(injectRSCPayload(browserStream))
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
const URL_POSTFIX = '_.rsc'
const HEADER_ACTION_ID = 'x-rsc-action'

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, {
method: action ? 'POST' : 'GET',
headers,
body: action?.body,
})
}

export function parseRenderRequest(request: Request) {
const url = new URL(request.url)
const isRsc = url.pathname.endsWith(URL_POSTFIX)
if (isRsc) url.pathname = url.pathname.slice(0, -URL_POSTFIX.length)
return {
isRsc,
actionId: request.headers.get(HEADER_ACTION_ID) || undefined,
request: new Request(url, request),
url,
}
}
26 changes: 26 additions & 0 deletions packages/plugin-rsc/examples/use-cache-callable/src/root.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { CallableCacheClient } from './client'

let implementationCalls = 0

export function Root() {
const captured = 'captured'

async function cachedAction(argument: string) {
'use cache'
Comment thread
hi-ogawa marked this conversation as resolved.
Outdated
implementationCalls++
return `${captured}:${argument}:${implementationCalls}`
}

return (
<html>
<head>
<meta charSet="utf-8" />
<title>RSC callable use cache</title>
</head>
<body>
<h1>RSC callable use cache</h1>
<CallableCacheClient action={cachedAction} />
</body>
</html>
)
}
16 changes: 16 additions & 0 deletions packages/plugin-rsc/examples/use-cache-callable/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
{
"compilerOptions": {
"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"
}
}
71 changes: 71 additions & 0 deletions packages/plugin-rsc/examples/use-cache-callable/vite.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import react from '@vitejs/plugin-react'
import rsc, { getPluginApi, type RscPluginManager } from '@vitejs/plugin-rsc'
import { transformHoistInlineDirective } from '@vitejs/plugin-rsc/transforms'
import { defineConfig, parseAstAsync, type Plugin } from 'vite'

const directive = 'use cache'
const pluginName = 'example:use-cache-callable'

export default defineConfig({
plugins: [
react(),
callableCachePlugin(),
rsc({
entries: {
client: './src/framework/entry.browser.tsx',
ssr: './src/framework/entry.ssr.tsx',
rsc: './src/framework/entry.rsc.tsx',
},
}),
],
})

function callableCachePlugin(): Plugin {
let manager: RscPluginManager

return {
name: pluginName,
configResolved(config) {
manager = getPluginApi(config)!.manager
},
async transform(code, id) {
if (this.environment.name !== 'rsc') return
if (!code.includes(directive)) {
manager.serverReferences.deleteClaim(pluginName, id)
return
}

const reference = manager.serverReferences.resolve(id, 'rsc')
const ast = (await parseAstAsync(code)) as unknown as Parameters<
typeof transformHoistInlineDirective
>[1]
const result = transformHoistInlineDirective(code, ast, {
directive,
rejectNonAsyncFunction: true,
hoistRuntime: true,
runtime: (value, name) =>
`$$ReactServer.registerServerReference(` +
`$$cacheWrapper(${value}),` +
`${JSON.stringify(reference.referenceKey)},` +
`${JSON.stringify(name)})`,
})
if (!result.output.hasChanged()) {
manager.serverReferences.deleteClaim(pluginName, id)
return
}

manager.serverReferences.replaceClaim(pluginName, id, {
...reference,
exportNames: result.names,
})
result.output.prepend(
`import $$cacheWrapper from "/src/cache-runtime";\n` +
`import * as $$ReactServer from "@vitejs/plugin-rsc/react/rsc/server";\n`,
)
return {
code: result.output.toString(),
map: result.output.generateMap({ hires: 'boundary' }),
}
},
}
}
Loading
Loading