From e69bc30ed7ae8980f649017a8cb369d409e9f7b1 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Date: Mon, 27 Jul 2026 18:31:38 +0900 Subject: [PATCH 01/15] feat(rsc): hoist inline directive runtimes Allow inline directive transforms to create one module-level runtime binding while controlling its export independently. Add a callable cache example that verifies the wrapped binding through Server Function transport. Co-authored-by: OpenCode --- packages/plugin-rsc/README.md | 1 + .../plugin-rsc/e2e/use-cache-callable.test.ts | 38 ++++++ .../examples/use-cache-callable/.gitignore | 2 + .../examples/use-cache-callable/package.json | 23 ++++ .../use-cache-callable/src/cache-runtime.ts | 17 +++ .../use-cache-callable/src/client.tsx | 25 ++++ .../src/framework/entry.browser.tsx | 44 ++++++ .../src/framework/entry.rsc.tsx | 61 +++++++++ .../src/framework/entry.ssr.tsx | 22 +++ .../src/framework/request.ts | 29 ++++ .../examples/use-cache-callable/src/root.tsx | 26 ++++ .../examples/use-cache-callable/tsconfig.json | 16 +++ .../use-cache-callable/vite.config.ts | 71 ++++++++++ .../plugin-rsc/src/transforms/hoist.test.ts | 125 +++++++++++++++++- packages/plugin-rsc/src/transforms/hoist.ts | 65 +++++++-- pnpm-lock.yaml | 28 ++++ 16 files changed, 580 insertions(+), 13 deletions(-) create mode 100644 packages/plugin-rsc/e2e/use-cache-callable.test.ts create mode 100644 packages/plugin-rsc/examples/use-cache-callable/.gitignore create mode 100644 packages/plugin-rsc/examples/use-cache-callable/package.json create mode 100644 packages/plugin-rsc/examples/use-cache-callable/src/cache-runtime.ts create mode 100644 packages/plugin-rsc/examples/use-cache-callable/src/client.tsx create mode 100644 packages/plugin-rsc/examples/use-cache-callable/src/framework/entry.browser.tsx create mode 100644 packages/plugin-rsc/examples/use-cache-callable/src/framework/entry.rsc.tsx create mode 100644 packages/plugin-rsc/examples/use-cache-callable/src/framework/entry.ssr.tsx create mode 100644 packages/plugin-rsc/examples/use-cache-callable/src/framework/request.ts create mode 100644 packages/plugin-rsc/examples/use-cache-callable/src/root.tsx create mode 100644 packages/plugin-rsc/examples/use-cache-callable/tsconfig.json create mode 100644 packages/plugin-rsc/examples/use-cache-callable/vite.config.ts diff --git a/packages/plugin-rsc/README.md b/packages/plugin-rsc/README.md index e2d11b867..5e613bd42 100644 --- a/packages/plugin-rsc/README.md +++ b/packages/plugin-rsc/README.md @@ -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. diff --git a/packages/plugin-rsc/e2e/use-cache-callable.test.ts b/packages/plugin-rsc/e2e/use-cache-callable.test.ts new file mode 100644 index 000000000..9eabd8951 --- /dev/null +++ b/packages/plugin-rsc/e2e/use-cache-callable.test.ts @@ -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', + ) + }) +} diff --git a/packages/plugin-rsc/examples/use-cache-callable/.gitignore b/packages/plugin-rsc/examples/use-cache-callable/.gitignore new file mode 100644 index 000000000..f06235c46 --- /dev/null +++ b/packages/plugin-rsc/examples/use-cache-callable/.gitignore @@ -0,0 +1,2 @@ +node_modules +dist diff --git a/packages/plugin-rsc/examples/use-cache-callable/package.json b/packages/plugin-rsc/examples/use-cache-callable/package.json new file mode 100644 index 000000000..ccb12bcab --- /dev/null +++ b/packages/plugin-rsc/examples/use-cache-callable/package.json @@ -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" + } +} diff --git a/packages/plugin-rsc/examples/use-cache-callable/src/cache-runtime.ts b/packages/plugin-rsc/examples/use-cache-callable/src/cache-runtime.ts new file mode 100644 index 000000000..8bd1e8976 --- /dev/null +++ b/packages/plugin-rsc/examples/use-cache-callable/src/cache-runtime.ts @@ -0,0 +1,17 @@ +type CacheFunction = (...args: string[]) => Promise + +export default function cacheWrapper( + implementation: CacheFunction, +): CacheFunction { + const entries = new Map>() + + return (...args) => { + const key = JSON.stringify(args) + let result = entries.get(key) + if (!result) { + result = implementation(...args) + entries.set(key, result) + } + return result + } +} diff --git a/packages/plugin-rsc/examples/use-cache-callable/src/client.tsx b/packages/plugin-rsc/examples/use-cache-callable/src/client.tsx new file mode 100644 index 000000000..02750edbe --- /dev/null +++ b/packages/plugin-rsc/examples/use-cache-callable/src/client.tsx @@ -0,0 +1,25 @@ +'use client' + +import { useState } from 'react' + +export function CallableCacheClient(props: { + action: (argument: string) => Promise +}) { + 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 ( +
+ + + + requests: {requests}; result: {result} + +
+ ) +} diff --git a/packages/plugin-rsc/examples/use-cache-callable/src/framework/entry.browser.tsx b/packages/plugin-rsc/examples/use-cache-callable/src/framework/entry.browser.tsx new file mode 100644 index 000000000..465578f0b --- /dev/null +++ b/packages/plugin-rsc/examples/use-cache-callable/src/framework/entry.browser.tsx @@ -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(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(fetch(request), { + temporaryReferences, + }) + updatePayload(payload) + const { ok, data } = payload.returnValue! + if (!ok) throw data + return data + }) + + hydrateRoot(document, ) +} + +main() diff --git a/packages/plugin-rsc/examples/use-cache-callable/src/framework/entry.rsc.tsx b/packages/plugin-rsc/examples/use-cache-callable/src/framework/entry.rsc.tsx new file mode 100644 index 000000000..2d57a67c9 --- /dev/null +++ b/packages/plugin-rsc/examples/use-cache-callable/src/framework/entry.rsc.tsx @@ -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 { + 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: , + returnValue, + } + const rscStream = renderToReadableStream(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() diff --git a/packages/plugin-rsc/examples/use-cache-callable/src/framework/entry.ssr.tsx b/packages/plugin-rsc/examples/use-cache-callable/src/framework/entry.ssr.tsx new file mode 100644 index 000000000..d65026bbb --- /dev/null +++ b/packages/plugin-rsc/examples/use-cache-callable/src/framework/entry.ssr.tsx @@ -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) { + const [ssrStream, browserStream] = rscStream.tee() + let payload: Promise | undefined + + function SsrRoot() { + payload ??= createFromReadableStream(ssrStream) + return use(payload).root + } + + const bootstrapScriptContent = + await import.meta.viteRsc.loadBootstrapScriptContent('index') + const htmlStream = await renderToReadableStream(, { + bootstrapScriptContent, + }) + return htmlStream.pipeThrough(injectRSCPayload(browserStream)) +} diff --git a/packages/plugin-rsc/examples/use-cache-callable/src/framework/request.ts b/packages/plugin-rsc/examples/use-cache-callable/src/framework/request.ts new file mode 100644 index 000000000..8a694878c --- /dev/null +++ b/packages/plugin-rsc/examples/use-cache-callable/src/framework/request.ts @@ -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, + } +} diff --git a/packages/plugin-rsc/examples/use-cache-callable/src/root.tsx b/packages/plugin-rsc/examples/use-cache-callable/src/root.tsx new file mode 100644 index 000000000..b7fd00cc5 --- /dev/null +++ b/packages/plugin-rsc/examples/use-cache-callable/src/root.tsx @@ -0,0 +1,26 @@ +import { CallableCacheClient } from './client' + +let implementationCalls = 0 + +export function Root() { + const captured = 'captured' + + async function cachedAction(argument: string) { + 'use cache' + implementationCalls++ + return `${captured}:${argument}:${implementationCalls}` + } + + return ( + + + + RSC callable use cache + + +

RSC callable use cache

+ + + + ) +} diff --git a/packages/plugin-rsc/examples/use-cache-callable/tsconfig.json b/packages/plugin-rsc/examples/use-cache-callable/tsconfig.json new file mode 100644 index 000000000..42ce9773e --- /dev/null +++ b/packages/plugin-rsc/examples/use-cache-callable/tsconfig.json @@ -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" + } +} diff --git a/packages/plugin-rsc/examples/use-cache-callable/vite.config.ts b/packages/plugin-rsc/examples/use-cache-callable/vite.config.ts new file mode 100644 index 000000000..95a798df8 --- /dev/null +++ b/packages/plugin-rsc/examples/use-cache-callable/vite.config.ts @@ -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' }), + } + }, + } +} diff --git a/packages/plugin-rsc/src/transforms/hoist.test.ts b/packages/plugin-rsc/src/transforms/hoist.test.ts index 3dde6bc91..781fe75c3 100644 --- a/packages/plugin-rsc/src/transforms/hoist.test.ts +++ b/packages/plugin-rsc/src/transforms/hoist.test.ts @@ -52,6 +52,7 @@ describe(transformHoistInlineDirective, () => { options?: { encode?: boolean noExport?: boolean + hoistRuntime?: boolean directive?: string | RegExp }, ) { @@ -68,6 +69,7 @@ describe(transformHoistInlineDirective, () => { encode: options?.encode ? (v) => `__enc(${v})` : undefined, decode: options?.encode ? (v) => `__dec(${v})` : undefined, noExport: options?.noExport, + hoistRuntime: options?.hoistRuntime, }) if (!output.hasChanged()) { return @@ -80,12 +82,16 @@ describe(transformHoistInlineDirective, () => { return transformed } - async function testTransformNames(input: string) { + async function testTransformNames( + input: string, + options?: { hoistRuntime?: boolean }, + ) { const ast = await parseAstAsync(input) const result = transformHoistInlineDirective(input, ast, { runtime: (value, name) => `$$register(${value}, "", ${JSON.stringify(name)})`, directive: 'use server', + hoistRuntime: options?.hoistRuntime, }) return result.names } @@ -442,6 +448,123 @@ export async function test() { `) }) + it('hoistRuntime', async () => { + const input = ` +async function noCapture() { + "use server"; +} + +function Component() { + const value = "value"; + async function capture() { + "use server"; + return value; + } + return capture; +} +` + expect(await testTransform(input, { hoistRuntime: true, encode: true })) + .toMatchInlineSnapshot(` + " + export const $$hoist_0_noCapture = /* #__PURE__ */ $$register($$hoist_0_noCapture$$impl, "", "$$hoist_0_noCapture"); + export const $$hoist_1_capture = /* #__PURE__ */ $$register($$hoist_1_capture$$impl, "", "$$hoist_1_capture"); + const noCapture = $$hoist_0_noCapture; + + function Component() { + const value = "value"; + const capture = $$hoist_1_capture.bind(null, __enc([value])); + return capture; + } + + ;async function $$hoist_0_noCapture$$impl() { + "use server"; + }; + /* #__PURE__ */ Object.defineProperty($$hoist_0_noCapture$$impl, "name", { value: "noCapture" }); + + ;async function $$hoist_1_capture$$impl($$hoist_encoded) { + const [value] = __dec($$hoist_encoded); + "use server"; + return value; + }; + /* #__PURE__ */ Object.defineProperty($$hoist_1_capture$$impl, "name", { value: "capture" }); + " + `) + + expect(await testTransformNames(input, { hoistRuntime: true })) + .toMatchInlineSnapshot(` + [ + "$$hoist_0_noCapture", + "$$hoist_1_capture", + ] + `) + }) + + it('hoistRuntime with noExport', async () => { + const input = ` +export async function test() { + "use server"; +} +` + expect(await testTransform(input, { hoistRuntime: true, noExport: true })) + .toMatchInlineSnapshot(` + " + const $$hoist_0_test = /* #__PURE__ */ $$register($$hoist_0_test$$impl, "", "$$hoist_0_test"); + export const test = $$hoist_0_test; + + ;async function $$hoist_0_test$$impl() { + "use server"; + }; + /* #__PURE__ */ Object.defineProperty($$hoist_0_test$$impl, "name", { value: "test" }); + " + `) + }) + + it('hoistRuntime preserves the directive and import prologue', async () => { + const input = ` +"custom directive"; +import "./setup"; +const initialized = setup(); + +async function topLevel() { + "use server"; +} + +function Component() { + async function nested() { + "use server"; + } + return nested; +} +` + expect(await testTransform(input, { hoistRuntime: true })) + .toMatchInlineSnapshot(` + " + "custom directive"; + import "./setup"; + export const $$hoist_0_topLevel = /* #__PURE__ */ $$register($$hoist_0_topLevel$$impl, "", "$$hoist_0_topLevel"); + export const $$hoist_1_nested = /* #__PURE__ */ $$register($$hoist_1_nested$$impl, "", "$$hoist_1_nested"); + const initialized = setup(); + + const topLevel = $$hoist_0_topLevel; + + function Component() { + const nested = $$hoist_1_nested; + return nested; + } + + ;async function $$hoist_0_topLevel$$impl() { + "use server"; + }; + /* #__PURE__ */ Object.defineProperty($$hoist_0_topLevel$$impl, "name", { value: "topLevel" }); + + ;async function $$hoist_1_nested$$impl() { + "use server"; + }; + /* #__PURE__ */ Object.defineProperty($$hoist_1_nested$$impl, "name", { value: "nested" }); + " + `) + }) + it('directive pattern', async () => { const input = ` export async function none() { diff --git a/packages/plugin-rsc/src/transforms/hoist.ts b/packages/plugin-rsc/src/transforms/hoist.ts index 8d1665a70..eed511545 100644 --- a/packages/plugin-rsc/src/transforms/hoist.ts +++ b/packages/plugin-rsc/src/transforms/hoist.ts @@ -28,6 +28,12 @@ export function transformHoistInlineDirective( encode?: (value: string) => string decode?: (value: string) => string noExport?: boolean + /** + * Evaluate the runtime expression once during module initialization. + * The expression can reference imports and the hoisted implementation, but + * must not depend on other module-local initialization. + */ + hoistRuntime?: boolean }, ): { output: MagicString @@ -45,15 +51,16 @@ export function transformHoistInlineDirective( const scopeTree = buildScopeTree(ast) const names: string[] = [] + const runtimeHoists: string[] = [] walk(ast, { enter(node, parent) { - if ( - (node.type === 'FunctionExpression' || - node.type === 'FunctionDeclaration' || - node.type === 'ArrowFunctionExpression') && - node.body.type === 'BlockStatement' - ) { + const isFunction = + node.type === 'FunctionExpression' || + node.type === 'FunctionDeclaration' || + node.type === 'ArrowFunctionExpression' + if (isFunction) { + if (node.body.type !== 'BlockStatement') return const match = matchDirective(node.body.body, directive)?.match if (!match) return if (!node.async && rejectNonAsyncFunction) { @@ -95,25 +102,36 @@ export function transformHoistInlineDirective( const newName = `$$hoist_${names.length}` + (originalName ? `_${originalName}` : '') names.push(newName) + const implementationName = options.hoistRuntime + ? `${newName}$$impl` + : newName output.update( node.start, node.body.start, - `\n;${options.noExport ? '' : 'export '}${ + `\n;${options.noExport || options.hoistRuntime ? '' : 'export '}${ node.async ? 'async ' : '' - }function${node.generator ? '*' : ''} ${newName}(${newParams}) `, + }function${node.generator ? '*' : ''} ${implementationName}(${newParams}) `, ) + const runtimeCode = `/* #__PURE__ */ ${runtime( + implementationName, + newName, + { directiveMatch: match }, + )}` + if (options.hoistRuntime) { + runtimeHoists.push( + `${options.noExport ? '' : 'export '}const ${newName} = ${runtimeCode};\n`, + ) + } output.appendLeft( node.end, - `;\n/* #__PURE__ */ Object.defineProperty(${newName}, "name", { value: ${JSON.stringify( + `;\n/* #__PURE__ */ Object.defineProperty(${implementationName}, "name", { value: ${JSON.stringify( originalName, )} });\n`, ) output.move(node.start, node.end, input.length) // replace original declartion with action register + bind - let newCode = `/* #__PURE__ */ ${runtime(newName, newName, { - directiveMatch: match, - })}` + let newCode = options.hoistRuntime ? newName : runtimeCode if (bindVars.length > 0) { const bindArgs = options.encode ? options.encode('[' + bindVars.map((b) => b.expr).join(', ') + ']') @@ -132,12 +150,35 @@ export function transformHoistInlineDirective( }, }) + if (runtimeHoists.length > 0) { + output.prependLeft( + getRuntimeHoistPosition(ast, input.length), + runtimeHoists.join(''), + ) + } + return { output, names, } } +function getRuntimeHoistPosition(ast: Program, fallback: number): number { + let inDirectivePrologue = true + for (const statement of ast.body) { + const isDirective = + inDirectivePrologue && + statement.type === 'ExpressionStatement' && + statement.expression.type === 'Literal' && + typeof statement.expression.value === 'string' + if (isDirective) continue + inDirectivePrologue = false + if (statement.type === 'ImportDeclaration') continue + return statement.start + } + return fallback +} + const exactRegex = (s: string): RegExp => new RegExp('^' + s.replace(/[-[\]{}()*+?.,\\^$|#\s]/g, '\\$&') + '$') diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 20b094432..82489eded 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -974,6 +974,34 @@ importers: specifier: ^8.1.4 version: 8.1.4(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0) + packages/plugin-rsc/examples/use-cache-callable: + dependencies: + react: + specifier: ^19.2.8 + version: 19.2.8 + react-dom: + specifier: ^19.2.8 + version: 19.2.8(react@19.2.8) + 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.9.0) + playground: devDependencies: kill-port: From 68336383b62c5d4c3c362e3df7513c97c7fe5d2d Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Date: Mon, 27 Jul 2026 18:37:06 +0900 Subject: [PATCH 02/15] fix: lockfile --- pnpm-lock.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0f55d1eef..732c13971 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1000,7 +1000,7 @@ importers: 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.9.0) + version: 8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0) playground: devDependencies: From f3ff93efeaf6d1d47f5481ca62e7515a4480fabd Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Date: Mon, 27 Jul 2026 19:08:04 +0900 Subject: [PATCH 03/15] test(rsc): use fixtures for hoisted runtimes Co-authored-by: OpenCode --- .../fixtures/hoist-runtime/basic.js | 21 +++ .../fixtures/hoist-runtime/basic.js.snap.js | 34 ++++ .../hoist-runtime/basic.js.snap.names.json | 5 + .../hoist-runtime/basic.js.snap.no-export.js | 34 ++++ .../plugin-rsc/src/transforms/hoist.test.ts | 162 ++++-------------- 5 files changed, 132 insertions(+), 124 deletions(-) create mode 100644 packages/plugin-rsc/src/transforms/fixtures/hoist-runtime/basic.js create mode 100644 packages/plugin-rsc/src/transforms/fixtures/hoist-runtime/basic.js.snap.js create mode 100644 packages/plugin-rsc/src/transforms/fixtures/hoist-runtime/basic.js.snap.names.json create mode 100644 packages/plugin-rsc/src/transforms/fixtures/hoist-runtime/basic.js.snap.no-export.js diff --git a/packages/plugin-rsc/src/transforms/fixtures/hoist-runtime/basic.js b/packages/plugin-rsc/src/transforms/fixtures/hoist-runtime/basic.js new file mode 100644 index 000000000..05fa2464e --- /dev/null +++ b/packages/plugin-rsc/src/transforms/fixtures/hoist-runtime/basic.js @@ -0,0 +1,21 @@ +'custom directive' +import './setup' + +const initialized = setup() + +async function noCapture() { + 'use server' +} + +function Component() { + const value = 'value' + async function capture() { + 'use server' + return value + } + return capture +} + +export async function exported() { + 'use server' +} diff --git a/packages/plugin-rsc/src/transforms/fixtures/hoist-runtime/basic.js.snap.js b/packages/plugin-rsc/src/transforms/fixtures/hoist-runtime/basic.js.snap.js new file mode 100644 index 000000000..94e0855d8 --- /dev/null +++ b/packages/plugin-rsc/src/transforms/fixtures/hoist-runtime/basic.js.snap.js @@ -0,0 +1,34 @@ +"custom directive" +import "./setup" + +export const $$hoist_0_noCapture = /* #__PURE__ */ $$register($$hoist_0_noCapture$$impl, "", "$$hoist_0_noCapture"); +export const $$hoist_1_capture = /* #__PURE__ */ $$register($$hoist_1_capture$$impl, "", "$$hoist_1_capture"); +export const $$hoist_2_exported = /* #__PURE__ */ $$register($$hoist_2_exported$$impl, "", "$$hoist_2_exported"); +const initialized = setup() + +const noCapture = $$hoist_0_noCapture; + +function Component() { + const value = "value" + const capture = $$hoist_1_capture.bind(null, __enc([value])); + return capture +} + +export const exported = $$hoist_2_exported; + +;async function $$hoist_0_noCapture$$impl() { + "use server" +}; +/* #__PURE__ */ Object.defineProperty($$hoist_0_noCapture$$impl, "name", { value: "noCapture" }); + +;async function $$hoist_1_capture$$impl($$hoist_encoded) { + const [value] = __dec($$hoist_encoded); +"use server" + return value + }; +/* #__PURE__ */ Object.defineProperty($$hoist_1_capture$$impl, "name", { value: "capture" }); + +;async function $$hoist_2_exported$$impl() { + "use server" +}; +/* #__PURE__ */ Object.defineProperty($$hoist_2_exported$$impl, "name", { value: "exported" }); diff --git a/packages/plugin-rsc/src/transforms/fixtures/hoist-runtime/basic.js.snap.names.json b/packages/plugin-rsc/src/transforms/fixtures/hoist-runtime/basic.js.snap.names.json new file mode 100644 index 000000000..78975ca73 --- /dev/null +++ b/packages/plugin-rsc/src/transforms/fixtures/hoist-runtime/basic.js.snap.names.json @@ -0,0 +1,5 @@ +[ + "$$hoist_0_noCapture", + "$$hoist_1_capture", + "$$hoist_2_exported" +] diff --git a/packages/plugin-rsc/src/transforms/fixtures/hoist-runtime/basic.js.snap.no-export.js b/packages/plugin-rsc/src/transforms/fixtures/hoist-runtime/basic.js.snap.no-export.js new file mode 100644 index 000000000..7f4370402 --- /dev/null +++ b/packages/plugin-rsc/src/transforms/fixtures/hoist-runtime/basic.js.snap.no-export.js @@ -0,0 +1,34 @@ +"custom directive" +import "./setup" + +const $$hoist_0_noCapture = /* #__PURE__ */ $$register($$hoist_0_noCapture$$impl, "", "$$hoist_0_noCapture"); +const $$hoist_1_capture = /* #__PURE__ */ $$register($$hoist_1_capture$$impl, "", "$$hoist_1_capture"); +const $$hoist_2_exported = /* #__PURE__ */ $$register($$hoist_2_exported$$impl, "", "$$hoist_2_exported"); +const initialized = setup() + +const noCapture = $$hoist_0_noCapture; + +function Component() { + const value = "value" + const capture = $$hoist_1_capture.bind(null, __enc([value])); + return capture +} + +export const exported = $$hoist_2_exported; + +;async function $$hoist_0_noCapture$$impl() { + "use server" +}; +/* #__PURE__ */ Object.defineProperty($$hoist_0_noCapture$$impl, "name", { value: "noCapture" }); + +;async function $$hoist_1_capture$$impl($$hoist_encoded) { + const [value] = __dec($$hoist_encoded); +"use server" + return value + }; +/* #__PURE__ */ Object.defineProperty($$hoist_1_capture$$impl, "name", { value: "capture" }); + +;async function $$hoist_2_exported$$impl() { + "use server" +}; +/* #__PURE__ */ Object.defineProperty($$hoist_2_exported$$impl, "name", { value: "exported" }); diff --git a/packages/plugin-rsc/src/transforms/hoist.test.ts b/packages/plugin-rsc/src/transforms/hoist.test.ts index 781fe75c3..7f3077223 100644 --- a/packages/plugin-rsc/src/transforms/hoist.test.ts +++ b/packages/plugin-rsc/src/transforms/hoist.test.ts @@ -46,13 +46,49 @@ describe('fixtures', () => { } }) +describe('hoistRuntime fixtures', () => { + const fixtures = import.meta.glob( + ['./fixtures/hoist-runtime/**/*.js', '!**/*.snap.*'], + { query: 'raw' }, + ) + + async function transformFixture(input: string, noExport = false) { + const ast = await parseAstAsync(input) + const result = transformHoistInlineDirective(input, ast, { + directive: 'use server', + runtime: (value, name) => + `$$register(${value}, "", ${JSON.stringify(name)})`, + encode: (value) => `__enc(${value})`, + decode: (value) => `__dec(${value})`, + hoistRuntime: true, + noExport, + }) + const transformed = result.output.toString() + await parseAstAsync(transformed) + return { transformed, names: result.names } + } + + for (const [file, mod] of Object.entries(fixtures)) { + it(path.basename(file), async () => { + const input = ((await mod()) as any).default as string + const result = await transformFixture(input) + await expect(result.transformed).toMatchFileSnapshot(file + '.snap.js') + await expect( + (await transformFixture(input, true)).transformed, + ).toMatchFileSnapshot(file + '.snap.no-export.js') + await expect( + JSON.stringify(result.names, null, 2) + '\n', + ).toMatchFileSnapshot(file + '.snap.names.json') + }) + } +}) + describe(transformHoistInlineDirective, () => { async function testTransform( input: string, options?: { encode?: boolean noExport?: boolean - hoistRuntime?: boolean directive?: string | RegExp }, ) { @@ -69,7 +105,6 @@ describe(transformHoistInlineDirective, () => { encode: options?.encode ? (v) => `__enc(${v})` : undefined, decode: options?.encode ? (v) => `__dec(${v})` : undefined, noExport: options?.noExport, - hoistRuntime: options?.hoistRuntime, }) if (!output.hasChanged()) { return @@ -82,16 +117,12 @@ describe(transformHoistInlineDirective, () => { return transformed } - async function testTransformNames( - input: string, - options?: { hoistRuntime?: boolean }, - ) { + async function testTransformNames(input: string) { const ast = await parseAstAsync(input) const result = transformHoistInlineDirective(input, ast, { runtime: (value, name) => `$$register(${value}, "", ${JSON.stringify(name)})`, directive: 'use server', - hoistRuntime: options?.hoistRuntime, }) return result.names } @@ -448,123 +479,6 @@ export async function test() { `) }) - it('hoistRuntime', async () => { - const input = ` -async function noCapture() { - "use server"; -} - -function Component() { - const value = "value"; - async function capture() { - "use server"; - return value; - } - return capture; -} -` - expect(await testTransform(input, { hoistRuntime: true, encode: true })) - .toMatchInlineSnapshot(` - " - export const $$hoist_0_noCapture = /* #__PURE__ */ $$register($$hoist_0_noCapture$$impl, "", "$$hoist_0_noCapture"); - export const $$hoist_1_capture = /* #__PURE__ */ $$register($$hoist_1_capture$$impl, "", "$$hoist_1_capture"); - const noCapture = $$hoist_0_noCapture; - - function Component() { - const value = "value"; - const capture = $$hoist_1_capture.bind(null, __enc([value])); - return capture; - } - - ;async function $$hoist_0_noCapture$$impl() { - "use server"; - }; - /* #__PURE__ */ Object.defineProperty($$hoist_0_noCapture$$impl, "name", { value: "noCapture" }); - - ;async function $$hoist_1_capture$$impl($$hoist_encoded) { - const [value] = __dec($$hoist_encoded); - "use server"; - return value; - }; - /* #__PURE__ */ Object.defineProperty($$hoist_1_capture$$impl, "name", { value: "capture" }); - " - `) - - expect(await testTransformNames(input, { hoistRuntime: true })) - .toMatchInlineSnapshot(` - [ - "$$hoist_0_noCapture", - "$$hoist_1_capture", - ] - `) - }) - - it('hoistRuntime with noExport', async () => { - const input = ` -export async function test() { - "use server"; -} -` - expect(await testTransform(input, { hoistRuntime: true, noExport: true })) - .toMatchInlineSnapshot(` - " - const $$hoist_0_test = /* #__PURE__ */ $$register($$hoist_0_test$$impl, "", "$$hoist_0_test"); - export const test = $$hoist_0_test; - - ;async function $$hoist_0_test$$impl() { - "use server"; - }; - /* #__PURE__ */ Object.defineProperty($$hoist_0_test$$impl, "name", { value: "test" }); - " - `) - }) - - it('hoistRuntime preserves the directive and import prologue', async () => { - const input = ` -"custom directive"; -import "./setup"; -const initialized = setup(); - -async function topLevel() { - "use server"; -} - -function Component() { - async function nested() { - "use server"; - } - return nested; -} -` - expect(await testTransform(input, { hoistRuntime: true })) - .toMatchInlineSnapshot(` - " - "custom directive"; - import "./setup"; - export const $$hoist_0_topLevel = /* #__PURE__ */ $$register($$hoist_0_topLevel$$impl, "", "$$hoist_0_topLevel"); - export const $$hoist_1_nested = /* #__PURE__ */ $$register($$hoist_1_nested$$impl, "", "$$hoist_1_nested"); - const initialized = setup(); - - const topLevel = $$hoist_0_topLevel; - - function Component() { - const nested = $$hoist_1_nested; - return nested; - } - - ;async function $$hoist_0_topLevel$$impl() { - "use server"; - }; - /* #__PURE__ */ Object.defineProperty($$hoist_0_topLevel$$impl, "name", { value: "topLevel" }); - - ;async function $$hoist_1_nested$$impl() { - "use server"; - }; - /* #__PURE__ */ Object.defineProperty($$hoist_1_nested$$impl, "name", { value: "nested" }); - " - `) - }) - it('directive pattern', async () => { const input = ` export async function none() { From f27b72c0c283001c1a6e03f3d2de0077b20050ed Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Date: Mon, 27 Jul 2026 19:12:24 +0900 Subject: [PATCH 04/15] test(rsc): simplify hoisted runtime fixture Co-authored-by: OpenCode --- .../fixtures/hoist-runtime/basic.js.snap.js | 14 ++++---- .../hoist-runtime/basic.js.snap.names.json | 5 --- .../hoist-runtime/basic.js.snap.no-export.js | 34 ------------------- .../plugin-rsc/src/transforms/hoist.test.ts | 16 +++------ 4 files changed, 13 insertions(+), 56 deletions(-) delete mode 100644 packages/plugin-rsc/src/transforms/fixtures/hoist-runtime/basic.js.snap.names.json delete mode 100644 packages/plugin-rsc/src/transforms/fixtures/hoist-runtime/basic.js.snap.no-export.js diff --git a/packages/plugin-rsc/src/transforms/fixtures/hoist-runtime/basic.js.snap.js b/packages/plugin-rsc/src/transforms/fixtures/hoist-runtime/basic.js.snap.js index 94e0855d8..ba7ae91ed 100644 --- a/packages/plugin-rsc/src/transforms/fixtures/hoist-runtime/basic.js.snap.js +++ b/packages/plugin-rsc/src/transforms/fixtures/hoist-runtime/basic.js.snap.js @@ -1,5 +1,7 @@ -"custom directive" -import "./setup" +// names: ["$$hoist_0_noCapture","$$hoist_1_capture","$$hoist_2_exported"] + +'custom directive' +import './setup' export const $$hoist_0_noCapture = /* #__PURE__ */ $$register($$hoist_0_noCapture$$impl, "", "$$hoist_0_noCapture"); export const $$hoist_1_capture = /* #__PURE__ */ $$register($$hoist_1_capture$$impl, "", "$$hoist_1_capture"); @@ -9,7 +11,7 @@ const initialized = setup() const noCapture = $$hoist_0_noCapture; function Component() { - const value = "value" + const value = 'value' const capture = $$hoist_1_capture.bind(null, __enc([value])); return capture } @@ -17,18 +19,18 @@ function Component() { export const exported = $$hoist_2_exported; ;async function $$hoist_0_noCapture$$impl() { - "use server" + 'use server' }; /* #__PURE__ */ Object.defineProperty($$hoist_0_noCapture$$impl, "name", { value: "noCapture" }); ;async function $$hoist_1_capture$$impl($$hoist_encoded) { const [value] = __dec($$hoist_encoded); -"use server" +'use server' return value }; /* #__PURE__ */ Object.defineProperty($$hoist_1_capture$$impl, "name", { value: "capture" }); ;async function $$hoist_2_exported$$impl() { - "use server" + 'use server' }; /* #__PURE__ */ Object.defineProperty($$hoist_2_exported$$impl, "name", { value: "exported" }); diff --git a/packages/plugin-rsc/src/transforms/fixtures/hoist-runtime/basic.js.snap.names.json b/packages/plugin-rsc/src/transforms/fixtures/hoist-runtime/basic.js.snap.names.json deleted file mode 100644 index 78975ca73..000000000 --- a/packages/plugin-rsc/src/transforms/fixtures/hoist-runtime/basic.js.snap.names.json +++ /dev/null @@ -1,5 +0,0 @@ -[ - "$$hoist_0_noCapture", - "$$hoist_1_capture", - "$$hoist_2_exported" -] diff --git a/packages/plugin-rsc/src/transforms/fixtures/hoist-runtime/basic.js.snap.no-export.js b/packages/plugin-rsc/src/transforms/fixtures/hoist-runtime/basic.js.snap.no-export.js deleted file mode 100644 index 7f4370402..000000000 --- a/packages/plugin-rsc/src/transforms/fixtures/hoist-runtime/basic.js.snap.no-export.js +++ /dev/null @@ -1,34 +0,0 @@ -"custom directive" -import "./setup" - -const $$hoist_0_noCapture = /* #__PURE__ */ $$register($$hoist_0_noCapture$$impl, "", "$$hoist_0_noCapture"); -const $$hoist_1_capture = /* #__PURE__ */ $$register($$hoist_1_capture$$impl, "", "$$hoist_1_capture"); -const $$hoist_2_exported = /* #__PURE__ */ $$register($$hoist_2_exported$$impl, "", "$$hoist_2_exported"); -const initialized = setup() - -const noCapture = $$hoist_0_noCapture; - -function Component() { - const value = "value" - const capture = $$hoist_1_capture.bind(null, __enc([value])); - return capture -} - -export const exported = $$hoist_2_exported; - -;async function $$hoist_0_noCapture$$impl() { - "use server" -}; -/* #__PURE__ */ Object.defineProperty($$hoist_0_noCapture$$impl, "name", { value: "noCapture" }); - -;async function $$hoist_1_capture$$impl($$hoist_encoded) { - const [value] = __dec($$hoist_encoded); -"use server" - return value - }; -/* #__PURE__ */ Object.defineProperty($$hoist_1_capture$$impl, "name", { value: "capture" }); - -;async function $$hoist_2_exported$$impl() { - "use server" -}; -/* #__PURE__ */ Object.defineProperty($$hoist_2_exported$$impl, "name", { value: "exported" }); diff --git a/packages/plugin-rsc/src/transforms/hoist.test.ts b/packages/plugin-rsc/src/transforms/hoist.test.ts index 7f3077223..752699d78 100644 --- a/packages/plugin-rsc/src/transforms/hoist.test.ts +++ b/packages/plugin-rsc/src/transforms/hoist.test.ts @@ -52,7 +52,7 @@ describe('hoistRuntime fixtures', () => { { query: 'raw' }, ) - async function transformFixture(input: string, noExport = false) { + async function transformFixture(input: string) { const ast = await parseAstAsync(input) const result = transformHoistInlineDirective(input, ast, { directive: 'use server', @@ -61,24 +61,18 @@ describe('hoistRuntime fixtures', () => { encode: (value) => `__enc(${value})`, decode: (value) => `__dec(${value})`, hoistRuntime: true, - noExport, }) const transformed = result.output.toString() await parseAstAsync(transformed) - return { transformed, names: result.names } + return `// names: ${JSON.stringify(result.names)}\n\n${transformed}` } for (const [file, mod] of Object.entries(fixtures)) { it(path.basename(file), async () => { const input = ((await mod()) as any).default as string - const result = await transformFixture(input) - await expect(result.transformed).toMatchFileSnapshot(file + '.snap.js') - await expect( - (await transformFixture(input, true)).transformed, - ).toMatchFileSnapshot(file + '.snap.no-export.js') - await expect( - JSON.stringify(result.names, null, 2) + '\n', - ).toMatchFileSnapshot(file + '.snap.names.json') + await expect(await transformFixture(input)).toMatchFileSnapshot( + file + '.snap.js', + ) }) } }) From 39d38933a1ed75cf9aa3c20a6902c205c7c90411 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Date: Mon, 27 Jul 2026 19:48:50 +0900 Subject: [PATCH 05/15] refactor(rsc): place runtime hoists before imports Co-authored-by: OpenCode --- .../fixtures/hoist-runtime/basic.js.snap.js | 4 +-- packages/plugin-rsc/src/transforms/hoist.ts | 26 +++++++------------ 2 files changed, 11 insertions(+), 19 deletions(-) diff --git a/packages/plugin-rsc/src/transforms/fixtures/hoist-runtime/basic.js.snap.js b/packages/plugin-rsc/src/transforms/fixtures/hoist-runtime/basic.js.snap.js index ba7ae91ed..edd6348eb 100644 --- a/packages/plugin-rsc/src/transforms/fixtures/hoist-runtime/basic.js.snap.js +++ b/packages/plugin-rsc/src/transforms/fixtures/hoist-runtime/basic.js.snap.js @@ -1,11 +1,11 @@ // names: ["$$hoist_0_noCapture","$$hoist_1_capture","$$hoist_2_exported"] 'custom directive' -import './setup' - export const $$hoist_0_noCapture = /* #__PURE__ */ $$register($$hoist_0_noCapture$$impl, "", "$$hoist_0_noCapture"); export const $$hoist_1_capture = /* #__PURE__ */ $$register($$hoist_1_capture$$impl, "", "$$hoist_1_capture"); export const $$hoist_2_exported = /* #__PURE__ */ $$register($$hoist_2_exported$$impl, "", "$$hoist_2_exported"); +import './setup' + const initialized = setup() const noCapture = $$hoist_0_noCapture; diff --git a/packages/plugin-rsc/src/transforms/hoist.ts b/packages/plugin-rsc/src/transforms/hoist.ts index e9c666642..c5a6d39ce 100644 --- a/packages/plugin-rsc/src/transforms/hoist.ts +++ b/packages/plugin-rsc/src/transforms/hoist.ts @@ -244,13 +244,8 @@ export function transformHoistInlineDirective( }) if (runtimeHoists.length > 0) { - // Runtime results must exist before any transformed source site can read - // them. Keep the original directive and import prologue ahead of generated - // declarations, then initialize every runtime before user module code. - output.prependLeft( - getRuntimeHoistPosition(ast, input.length), - runtimeHoists.join(''), - ) + // Define hoisted runtime wrappers after leading directives. + output.prependLeft(getRuntimeHoistPosition(ast), runtimeHoists.join('')) } // Expose the canonical generated names. They identify the moved functions by @@ -262,22 +257,19 @@ export function transformHoistInlineDirective( } } -function getRuntimeHoistPosition(ast: Program, fallback: number): number { - // Preserve directives and imports at the beginning of the module. The first - // user statement is the earliest safe boundary for generated declarations. - let inDirectivePrologue = true +function getRuntimeHoistPosition(ast: Program): number { + // Preserve leading directives so directive-based transforms can + // still compose just in case. for (const statement of ast.body) { const isDirective = - inDirectivePrologue && statement.type === 'ExpressionStatement' && statement.expression.type === 'Literal' && typeof statement.expression.value === 'string' - if (isDirective) continue - inDirectivePrologue = false - if (statement.type === 'ImportDeclaration') continue - return statement.start + if (!isDirective) { + return statement.start + } } - return fallback + return 0 } const exactRegex = (s: string): RegExp => From a0413a337181e630d2194ca0561e04f3625dc1da Mon Sep 17 00:00:00 2001 From: hi-ogawa-agent <266689927+hi-ogawa-agent@users.noreply.github.com> Date: Tue, 28 Jul 2026 12:19:51 +0900 Subject: [PATCH 06/15] test(rsc): cover callable cache file directives Co-authored-by: OpenCode --- .../plugin-rsc/e2e/use-cache-callable.test.ts | 42 ++++++++++- .../file-directive-from-client/action.ts | 8 +++ .../file-directive-from-client/client.tsx | 23 +++++++ .../file-directive-from-server/action.ts | 8 +++ .../file-directive-from-server/client.tsx | 24 +++++++ .../file-directive-from-server/server.tsx | 6 ++ .../inline-directive}/client.tsx | 4 +- .../src/features/inline-directive/server.tsx | 15 ++++ .../src/{ => framework}/cache-runtime.ts | 0 .../examples/use-cache-callable/src/root.tsx | 18 ++--- .../use-cache-callable/vite.config.ts | 69 ++++++++++++++++--- 11 files changed, 190 insertions(+), 27 deletions(-) create mode 100644 packages/plugin-rsc/examples/use-cache-callable/src/features/file-directive-from-client/action.ts create mode 100644 packages/plugin-rsc/examples/use-cache-callable/src/features/file-directive-from-client/client.tsx create mode 100644 packages/plugin-rsc/examples/use-cache-callable/src/features/file-directive-from-server/action.ts create mode 100644 packages/plugin-rsc/examples/use-cache-callable/src/features/file-directive-from-server/client.tsx create mode 100644 packages/plugin-rsc/examples/use-cache-callable/src/features/file-directive-from-server/server.tsx rename packages/plugin-rsc/examples/use-cache-callable/src/{ => features/inline-directive}/client.tsx (86%) create mode 100644 packages/plugin-rsc/examples/use-cache-callable/src/features/inline-directive/server.tsx rename packages/plugin-rsc/examples/use-cache-callable/src/{ => framework}/cache-runtime.ts (100%) diff --git a/packages/plugin-rsc/e2e/use-cache-callable.test.ts b/packages/plugin-rsc/e2e/use-cache-callable.test.ts index 9eabd8951..b9f9f54e2 100644 --- a/packages/plugin-rsc/e2e/use-cache-callable.test.ts +++ b/packages/plugin-rsc/e2e/use-cache-callable.test.ts @@ -13,12 +13,12 @@ test.describe('build', () => { }) function defineTests(f: Fixture) { - test('calls the exported cache wrapper', async ({ page }) => { + test('inline directive', async ({ page }) => { using _errors = expectNoPageError(page) await page.goto(f.url()) await waitForHydration(page) - const example = page.getByTestId('callable-cache') + const example = page.getByTestId('inline-directive') await expect(example.locator('span')).toHaveText( 'requests: 0; result: none', ) @@ -35,4 +35,42 @@ function defineTests(f: Fixture) { 'requests: 3; result: captured:different:2', ) }) + + test('file directive from server', async ({ page }) => { + using _errors = expectNoPageError(page) + await page.goto(f.url()) + await waitForHydration(page) + + const example = page.getByTestId('file-directive-from-server') + await expect(example.locator('span')).toHaveText( + 'requests: 0; result: none', + ) + await example.getByRole('button', { name: 'call' }).click() + await expect(example.locator('span')).toHaveText( + 'requests: 1; result: server:same:1', + ) + await example.getByRole('button', { name: 'call' }).click() + await expect(example.locator('span')).toHaveText( + 'requests: 2; result: server:same:1', + ) + }) + + test('file directive from client', async ({ page }) => { + using _errors = expectNoPageError(page) + await page.goto(f.url()) + await waitForHydration(page) + + const example = page.getByTestId('file-directive-from-client') + await expect(example.locator('span')).toHaveText( + 'requests: 0; result: none', + ) + await example.getByRole('button', { name: 'call' }).click() + await expect(example.locator('span')).toHaveText( + 'requests: 1; result: client:same:1', + ) + await example.getByRole('button', { name: 'call' }).click() + await expect(example.locator('span')).toHaveText( + 'requests: 2; result: client:same:1', + ) + }) } diff --git a/packages/plugin-rsc/examples/use-cache-callable/src/features/file-directive-from-client/action.ts b/packages/plugin-rsc/examples/use-cache-callable/src/features/file-directive-from-client/action.ts new file mode 100644 index 000000000..a9b6e0d1b --- /dev/null +++ b/packages/plugin-rsc/examples/use-cache-callable/src/features/file-directive-from-client/action.ts @@ -0,0 +1,8 @@ +'use cache' + +let implementationCalls = 0 + +export async function cachedFromClient(argument: string) { + implementationCalls++ + return `client:${argument}:${implementationCalls}` +} diff --git a/packages/plugin-rsc/examples/use-cache-callable/src/features/file-directive-from-client/client.tsx b/packages/plugin-rsc/examples/use-cache-callable/src/features/file-directive-from-client/client.tsx new file mode 100644 index 000000000..566997029 --- /dev/null +++ b/packages/plugin-rsc/examples/use-cache-callable/src/features/file-directive-from-client/client.tsx @@ -0,0 +1,23 @@ +'use client' + +import { useState } from 'react' +import { cachedFromClient } from './action' + +export function FileDirectiveFromClient() { + const [requests, setRequests] = useState(0) + const [result, setResult] = useState('none') + + async function call() { + setRequests((value) => value + 1) + setResult(await cachedFromClient('same')) + } + + return ( +
+ + + requests: {requests}; result: {result} + +
+ ) +} diff --git a/packages/plugin-rsc/examples/use-cache-callable/src/features/file-directive-from-server/action.ts b/packages/plugin-rsc/examples/use-cache-callable/src/features/file-directive-from-server/action.ts new file mode 100644 index 000000000..27d37347f --- /dev/null +++ b/packages/plugin-rsc/examples/use-cache-callable/src/features/file-directive-from-server/action.ts @@ -0,0 +1,8 @@ +'use cache' + +let implementationCalls = 0 + +export async function cachedFromServer(argument: string) { + implementationCalls++ + return `server:${argument}:${implementationCalls}` +} diff --git a/packages/plugin-rsc/examples/use-cache-callable/src/features/file-directive-from-server/client.tsx b/packages/plugin-rsc/examples/use-cache-callable/src/features/file-directive-from-server/client.tsx new file mode 100644 index 000000000..1b82217ae --- /dev/null +++ b/packages/plugin-rsc/examples/use-cache-callable/src/features/file-directive-from-server/client.tsx @@ -0,0 +1,24 @@ +'use client' + +import { useState } from 'react' + +export function FileDirectiveFromServerClient(props: { + action: (argument: string) => Promise +}) { + const [requests, setRequests] = useState(0) + const [result, setResult] = useState('none') + + async function call() { + setRequests((value) => value + 1) + setResult(await props.action('same')) + } + + return ( +
+ + + requests: {requests}; result: {result} + +
+ ) +} diff --git a/packages/plugin-rsc/examples/use-cache-callable/src/features/file-directive-from-server/server.tsx b/packages/plugin-rsc/examples/use-cache-callable/src/features/file-directive-from-server/server.tsx new file mode 100644 index 000000000..a40c41e53 --- /dev/null +++ b/packages/plugin-rsc/examples/use-cache-callable/src/features/file-directive-from-server/server.tsx @@ -0,0 +1,6 @@ +import { cachedFromServer } from './action' +import { FileDirectiveFromServerClient } from './client' + +export function FileDirectiveFromServer() { + return +} diff --git a/packages/plugin-rsc/examples/use-cache-callable/src/client.tsx b/packages/plugin-rsc/examples/use-cache-callable/src/features/inline-directive/client.tsx similarity index 86% rename from packages/plugin-rsc/examples/use-cache-callable/src/client.tsx rename to packages/plugin-rsc/examples/use-cache-callable/src/features/inline-directive/client.tsx index 02750edbe..716bfa13a 100644 --- a/packages/plugin-rsc/examples/use-cache-callable/src/client.tsx +++ b/packages/plugin-rsc/examples/use-cache-callable/src/features/inline-directive/client.tsx @@ -2,7 +2,7 @@ import { useState } from 'react' -export function CallableCacheClient(props: { +export function InlineDirectiveClient(props: { action: (argument: string) => Promise }) { const [requests, setRequests] = useState(0) @@ -14,7 +14,7 @@ export function CallableCacheClient(props: { } return ( -
+
diff --git a/packages/plugin-rsc/examples/use-cache-callable/src/features/inline-directive/server.tsx b/packages/plugin-rsc/examples/use-cache-callable/src/features/inline-directive/server.tsx new file mode 100644 index 000000000..73bfe0189 --- /dev/null +++ b/packages/plugin-rsc/examples/use-cache-callable/src/features/inline-directive/server.tsx @@ -0,0 +1,15 @@ +import { InlineDirectiveClient } from './client' + +let implementationCalls = 0 + +export function InlineDirective() { + const captured = 'captured' + + async function cachedAction(argument: string) { + 'use cache' + implementationCalls++ + return `${captured}:${argument}:${implementationCalls}` + } + + return +} diff --git a/packages/plugin-rsc/examples/use-cache-callable/src/cache-runtime.ts b/packages/plugin-rsc/examples/use-cache-callable/src/framework/cache-runtime.ts similarity index 100% rename from packages/plugin-rsc/examples/use-cache-callable/src/cache-runtime.ts rename to packages/plugin-rsc/examples/use-cache-callable/src/framework/cache-runtime.ts diff --git a/packages/plugin-rsc/examples/use-cache-callable/src/root.tsx b/packages/plugin-rsc/examples/use-cache-callable/src/root.tsx index b7fd00cc5..07781e4f2 100644 --- a/packages/plugin-rsc/examples/use-cache-callable/src/root.tsx +++ b/packages/plugin-rsc/examples/use-cache-callable/src/root.tsx @@ -1,16 +1,8 @@ -import { CallableCacheClient } from './client' - -let implementationCalls = 0 +import { FileDirectiveFromClient } from './features/file-directive-from-client/client' +import { FileDirectiveFromServer } from './features/file-directive-from-server/server' +import { InlineDirective } from './features/inline-directive/server' export function Root() { - const captured = 'captured' - - async function cachedAction(argument: string) { - 'use cache' - implementationCalls++ - return `${captured}:${argument}:${implementationCalls}` - } - return ( @@ -19,7 +11,9 @@ export function Root() {

RSC callable use cache

- + + + ) diff --git a/packages/plugin-rsc/examples/use-cache-callable/vite.config.ts b/packages/plugin-rsc/examples/use-cache-callable/vite.config.ts index 95a798df8..2bca4d379 100644 --- a/packages/plugin-rsc/examples/use-cache-callable/vite.config.ts +++ b/packages/plugin-rsc/examples/use-cache-callable/vite.config.ts @@ -1,6 +1,11 @@ import react from '@vitejs/plugin-react' import rsc, { getPluginApi, type RscPluginManager } from '@vitejs/plugin-rsc' -import { transformHoistInlineDirective } from '@vitejs/plugin-rsc/transforms' +import { + hasDirective, + transformDirectiveProxyExport, + transformHoistInlineDirective, + transformWrapExport, +} from '@vitejs/plugin-rsc/transforms' import { defineConfig, parseAstAsync, type Plugin } from 'vite' const directive = 'use cache' @@ -29,7 +34,6 @@ function callableCachePlugin(): Plugin { manager = getPluginApi(config)!.manager }, async transform(code, id) { - if (this.environment.name !== 'rsc') return if (!code.includes(directive)) { manager.serverReferences.deleteClaim(pluginName, id) return @@ -39,28 +43,71 @@ function callableCachePlugin(): Plugin { 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) => + const environmentName = this.environment.name + + if (environmentName === 'rsc') { + const runtime = (value: string, name: string) => `$$ReactServer.registerServerReference(` + `$$cacheWrapper(${value}),` + `${JSON.stringify(reference.referenceKey)},` + + `${JSON.stringify(name)})` + const result = hasDirective(ast.body, directive) + ? transformWrapExport(code, ast, { + runtime, + rejectNonAsyncFunction: true, + }) + : transformHoistInlineDirective(code, ast, { + directive, + rejectNonAsyncFunction: true, + hoistRuntime: true, + runtime, + }) + if (!result.output.hasChanged()) { + manager.serverReferences.deleteClaim(pluginName, id) + return + } + + manager.serverReferences.replaceClaim(pluginName, id, { + ...reference, + exportNames: 'names' in result ? result.names : result.exportNames, + }) + result.output.prepend( + `import $$cacheWrapper from "/src/framework/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' }), + } + } + + const result = transformDirectiveProxyExport(ast, { + code, + directive, + rejectNonAsyncFunction: true, + runtime: (name) => + `$$ReactClient.createServerReference(` + + `${JSON.stringify(reference.referenceKey + '#' + name)},` + + `$$ReactClient.callServer,` + + `undefined,` + + (this.environment.mode === 'dev' + ? `$$ReactClient.findSourceMapURL,` + : `undefined,`) + `${JSON.stringify(name)})`, }) - if (!result.output.hasChanged()) { + if (!result?.output.hasChanged()) { manager.serverReferences.deleteClaim(pluginName, id) return } manager.serverReferences.replaceClaim(pluginName, id, { ...reference, - exportNames: result.names, + exportNames: result.exportNames, }) + const runtimeEnvironment = + environmentName === 'client' ? 'browser' : 'ssr' result.output.prepend( - `import $$cacheWrapper from "/src/cache-runtime";\n` + - `import * as $$ReactServer from "@vitejs/plugin-rsc/react/rsc/server";\n`, + `import * as $$ReactClient from "@vitejs/plugin-rsc/react/${runtimeEnvironment}";\n`, ) return { code: result.output.toString(), From bbbae0dc860577fb4cc9a6c349c46f5b22c33b64 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Date: Tue, 28 Jul 2026 14:29:22 +0900 Subject: [PATCH 07/15] refactor(rsc): align callable cache example framework Co-authored-by: OpenCode --- .../callable-cache-plugin.ts | 103 +++++++++++++++ .../src/framework/cache-runtime.ts | 17 --- .../src/framework/entry.browser.tsx | 120 +++++++++++++++-- .../src/framework/entry.rsc.tsx | 125 +++++++++++++----- .../src/framework/entry.ssr.tsx | 72 ++++++++-- .../src/framework/error-boundary.tsx | 81 ++++++++++++ .../src/framework/request.ts | 29 ---- .../src/framework/request.tsx | 58 ++++++++ .../src/framework/use-cache-runtime.tsx | 100 ++++++++++++++ .../examples/use-cache-callable/src/root.tsx | 2 +- .../use-cache-callable/vite.config.ts | 106 +-------------- 11 files changed, 608 insertions(+), 205 deletions(-) create mode 100644 packages/plugin-rsc/examples/use-cache-callable/callable-cache-plugin.ts delete mode 100644 packages/plugin-rsc/examples/use-cache-callable/src/framework/cache-runtime.ts create mode 100644 packages/plugin-rsc/examples/use-cache-callable/src/framework/error-boundary.tsx delete mode 100644 packages/plugin-rsc/examples/use-cache-callable/src/framework/request.ts create mode 100644 packages/plugin-rsc/examples/use-cache-callable/src/framework/request.tsx create mode 100644 packages/plugin-rsc/examples/use-cache-callable/src/framework/use-cache-runtime.tsx diff --git a/packages/plugin-rsc/examples/use-cache-callable/callable-cache-plugin.ts b/packages/plugin-rsc/examples/use-cache-callable/callable-cache-plugin.ts new file mode 100644 index 000000000..50c4e385b --- /dev/null +++ b/packages/plugin-rsc/examples/use-cache-callable/callable-cache-plugin.ts @@ -0,0 +1,103 @@ +import { getPluginApi, type RscPluginManager } from '@vitejs/plugin-rsc' +import { + hasDirective, + transformDirectiveProxyExport, + transformHoistInlineDirective, + transformWrapExport, +} from '@vitejs/plugin-rsc/transforms' +import { parseAstAsync, type Plugin } from 'vite' + +const directive = 'use cache' +const pluginName = 'example:use-cache-callable' + +export function callableCachePlugin(): Plugin { + let manager: RscPluginManager + + return { + name: pluginName, + configResolved(config) { + manager = getPluginApi(config)!.manager + }, + async transform(code, id) { + 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 environmentName = this.environment.name + + if (environmentName === 'rsc') { + const runtime = (value: string, name: string) => + `$$ReactServer.registerServerReference(` + + `$$cacheWrapper(${value}),` + + `${JSON.stringify(reference.referenceKey)},` + + `${JSON.stringify(name)})` + const result = hasDirective(ast.body, directive) + ? transformWrapExport(code, ast, { + runtime, + rejectNonAsyncFunction: true, + }) + : transformHoistInlineDirective(code, ast, { + directive, + rejectNonAsyncFunction: true, + hoistRuntime: true, + runtime, + }) + if (!result.output.hasChanged()) { + manager.serverReferences.deleteClaim(pluginName, id) + return + } + + manager.serverReferences.replaceClaim(pluginName, id, { + ...reference, + exportNames: 'names' in result ? result.names : result.exportNames, + }) + result.output.prepend( + `import $$cacheWrapper from "/src/framework/use-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' }), + } + } + + const result = transformDirectiveProxyExport(ast, { + code, + directive, + rejectNonAsyncFunction: true, + runtime: (name) => + `$$ReactClient.createServerReference(` + + `${JSON.stringify(reference.referenceKey + '#' + name)},` + + `$$ReactClient.callServer,` + + `undefined,` + + (this.environment.mode === 'dev' + ? `$$ReactClient.findSourceMapURL,` + : `undefined,`) + + `${JSON.stringify(name)})`, + }) + if (!result?.output.hasChanged()) { + manager.serverReferences.deleteClaim(pluginName, id) + return + } + + manager.serverReferences.replaceClaim(pluginName, id, { + ...reference, + exportNames: result.exportNames, + }) + const runtimeEnvironment = + environmentName === 'client' ? 'browser' : 'ssr' + result.output.prepend( + `import * as $$ReactClient from "@vitejs/plugin-rsc/react/${runtimeEnvironment}";\n`, + ) + return { + code: result.output.toString(), + map: result.output.generateMap({ hires: 'boundary' }), + } + }, + } +} diff --git a/packages/plugin-rsc/examples/use-cache-callable/src/framework/cache-runtime.ts b/packages/plugin-rsc/examples/use-cache-callable/src/framework/cache-runtime.ts deleted file mode 100644 index 8bd1e8976..000000000 --- a/packages/plugin-rsc/examples/use-cache-callable/src/framework/cache-runtime.ts +++ /dev/null @@ -1,17 +0,0 @@ -type CacheFunction = (...args: string[]) => Promise - -export default function cacheWrapper( - implementation: CacheFunction, -): CacheFunction { - const entries = new Map>() - - return (...args) => { - const key = JSON.stringify(args) - let result = entries.get(key) - if (!result) { - result = implementation(...args) - entries.set(key, result) - } - return result - } -} diff --git a/packages/plugin-rsc/examples/use-cache-callable/src/framework/entry.browser.tsx b/packages/plugin-rsc/examples/use-cache-callable/src/framework/entry.browser.tsx index 465578f0b..5b48ebdfa 100644 --- a/packages/plugin-rsc/examples/use-cache-callable/src/framework/entry.browser.tsx +++ b/packages/plugin-rsc/examples/use-cache-callable/src/framework/entry.browser.tsx @@ -1,44 +1,138 @@ import { - createFromFetch, createFromReadableStream, + createFromFetch, + setServerCallback, createTemporaryReferenceSet, encodeReply, - setServerCallback, } from '@vitejs/plugin-rsc/browser' -import { useEffect, useState } from 'react' -import { hydrateRoot } from 'react-dom/client' +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() { - const initialPayload = await createFromReadableStream(rscStream) - let updatePayload: (payload: RscPayload) => void + // stash `setPayload` function to trigger re-rendering + // from outside of `BrowserRoot` component (e.g. server function call, navigation, hmr) + let setPayload: (v: RscPayload) => void + // deserialize RSC stream back to React VDOM for CSR + const initialPayload = await createFromReadableStream( + // initial RSC stream is injected in SSR stream as + rscStream, + ) + + // browser root component to (re-)render RSC payload as state function BrowserRoot() { - const [payload, setPayload] = useState(initialPayload) - useEffect(() => { - updatePayload = setPayload + const [payload, setPayload_] = React.useState(initialPayload) + + React.useEffect(() => { + setPayload = (v) => React.startTransition(() => setPayload_(v)) + }, [setPayload_]) + + // re-fetch/render on client side navigation + React.useEffect(() => { + return listenNavigation(() => fetchRscPayload()) }, []) + return payload.root } + // re-fetch RSC and trigger re-rendering + async function fetchRscPayload() { + const renderRequest = createRscRenderRequest(window.location.href) + const payload = await createFromFetch(fetch(renderRequest)) + setPayload(payload) + } + + // register a handler which will be internally called by React + // on server function request after hydration. setServerCallback(async (id, args) => { const temporaryReferences = createTemporaryReferenceSet() - const request = createRscRenderRequest(window.location.href, { + const renderRequest = createRscRenderRequest(window.location.href, { id, body: await encodeReply(args, { temporaryReferences }), }) - const payload = await createFromFetch(fetch(request), { + const payload = await createFromFetch(fetch(renderRequest), { temporaryReferences, }) - updatePayload(payload) + setPayload(payload) const { ok, data } = payload.returnValue! if (!ok) throw data return data }) - hydrateRoot(document, ) + // hydration + const browserRoot = ( + + + + + + ) + if ('__NO_HYDRATE' in globalThis) { + createRoot(document).render(browserRoot) + } else { + hydrateRoot(document, browserRoot, { + formState: initialPayload.formState, + }) + } + + // implement server HMR by triggering re-fetch/render of RSC upon server code change + if (import.meta.hot) { + import.meta.hot.on('rsc:update', () => { + fetchRscPayload() + }) + } +} + +// a little helper to setup events interception for client side navigation +function listenNavigation(onNavigation: () => void) { + window.addEventListener('popstate', onNavigation) + + const oldPushState = window.history.pushState + window.history.pushState = function (...args) { + const res = oldPushState.apply(this, args) + onNavigation() + return res + } + + const oldReplaceState = window.history.replaceState + window.history.replaceState = function (...args) { + const res = oldReplaceState.apply(this, args) + onNavigation() + return res + } + + function onClick(e: MouseEvent) { + let link = (e.target as Element).closest('a') + if ( + link && + link instanceof HTMLAnchorElement && + link.href && + (!link.target || link.target === '_self') && + link.origin === location.origin && + !link.hasAttribute('download') && + e.button === 0 && // left clicks only + !e.metaKey && // open in new tab (mac) + !e.ctrlKey && // open in new tab (windows) + !e.altKey && // download + !e.shiftKey && + !e.defaultPrevented + ) { + e.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/use-cache-callable/src/framework/entry.rsc.tsx b/packages/plugin-rsc/examples/use-cache-callable/src/framework/entry.rsc.tsx index 2d57a67c9..786ce67f7 100644 --- a/packages/plugin-rsc/examples/use-cache-callable/src/framework/entry.rsc.tsx +++ b/packages/plugin-rsc/examples/use-cache-callable/src/framework/entry.rsc.tsx @@ -1,61 +1,122 @@ import { + renderToReadableStream, createTemporaryReferenceSet, decodeReply, loadServerAction, - renderToReadableStream, + decodeAction, + decodeFormState, } from '@vitejs/plugin-rsc/rsc' -import { Root } from '../root' -import { parseRenderRequest } from './request' +import type { ReactFormState } from 'react-dom/client' +import { Root } from '../root.tsx' +import { parseRenderRequest } from './request.tsx' +// The schema of payload which is serialized into RSC stream on rsc environment +// and deserialized on ssr/client environments. export type RscPayload = { + // this demo renders/serializes/deserializes the entire root HTML element + // but this mechanism can be changed to render/fetch different parts of components + // based on your own route conventions. root: React.ReactNode + // server action return value of non-progressive enhancement case returnValue?: { ok: boolean; data: unknown } + // server action form state (e.g. useActionState) of progressive enhancement case + formState?: ReactFormState } +// The plugin assumes by default that the `rsc` entry has a default export of a request handler. +// However, server entries can be executed differently by registering your own server handler. export default { fetch: handler } async function handler(request: Request): Promise { + // differentiate RSC, SSR, action, etc. 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 + request = renderRequest.request + + // handle server function 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) { + // action is called via `ReactClient.setServerCallback`. + 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 (e) { + returnValue = { ok: false, data: e } + actionStatus = 500 + } + } else { + // otherwise server function is called via `
` + // before hydration (e.g. when javascript is disabled). + // aka progressive enhancement. + const formData = await request.formData() + const decodedAction = await decodeAction(formData) + try { + const result = await decodedAction() + formState = await decodeFormState(result, formData) + } catch (e) { + // there's no single general obvious way to surface this error, + // so explicitly return classic 500 response. + return new Response('Internal Server Error: server action failed', { + status: 500, + }) + } } } - const payload: RscPayload = { - root: , + // serialization from React VDOM tree to RSC stream. + // we render RSC stream after handling server function request + // so that new render reflects updated state from server function call + // to achieve single round trip to mutate and fetch from server. + const rscPayload: RscPayload = { + root: , + formState, returnValue, } - const rscStream = renderToReadableStream(payload, { - temporaryReferences, - }) + const rscOptions = { temporaryReferences } + const rscStream = renderToReadableStream(rscPayload, rscOptions) + + // Respond RSC stream without HTML rendering as decided by `RenderRequest` if (renderRequest.isRsc) { return new Response(rscStream, { - status, - headers: { 'content-type': 'text/x-component;charset=utf-8' }, + status: actionStatus, + headers: { + 'content-type': 'text/x-component;charset=utf-8', + }, }) } - const ssr = await import.meta.viteRsc.loadModule< - typeof import('./entry.ssr') + // Delegate to SSR environment for html rendering. + // The plugin provides `loadModule` helper to allow loading SSR environment entry module + // in RSC environment. however this can be customized by implementing own runtime communication + // e.g. `@cloudflare/vite-plugin`'s service binding. + const ssrEntryModule = await import.meta.viteRsc.loadModule< + typeof import('./entry.ssr.tsx') >('ssr', 'index') - const htmlStream = await ssr.renderHTML(rscStream) - return new Response(htmlStream, { - status, - headers: { 'content-type': 'text/html' }, + const ssrResult = await ssrEntryModule.renderHTML(rscStream, { + formState, + // allow quick simulation of javascript disabled browser + debugNojs: renderRequest.url.searchParams.has('__nojs'), + }) + + // respond html + return new Response(ssrResult.stream, { + status: ssrResult.status, + headers: { + 'Content-type': 'text/html', + }, }) } -if (import.meta.hot) import.meta.hot.accept() +if (import.meta.hot) { + import.meta.hot.accept() +} diff --git a/packages/plugin-rsc/examples/use-cache-callable/src/framework/entry.ssr.tsx b/packages/plugin-rsc/examples/use-cache-callable/src/framework/entry.ssr.tsx index d65026bbb..7fc5a9564 100644 --- a/packages/plugin-rsc/examples/use-cache-callable/src/framework/entry.ssr.tsx +++ b/packages/plugin-rsc/examples/use-cache-callable/src/framework/entry.ssr.tsx @@ -1,22 +1,74 @@ import { createFromReadableStream } from '@vitejs/plugin-rsc/ssr' -import { use } from 'react' +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) { - const [ssrStream, browserStream] = rscStream.tee() - let payload: Promise | undefined +export async function renderHTML( + rscStream: ReadableStream, + options: { + formState?: ReactFormState + nonce?: string + debugNojs?: boolean + }, +): Promise<{ stream: ReadableStream; status?: number }> { + // duplicate one RSC stream into two. + // - one for SSR (ReactClient.createFromReadableStream below) + // - another for browser hydration payload by injecting . + const [rscStream1, rscStream2] = rscStream.tee() + // deserialize RSC stream back to React VDOM + let payload: Promise | undefined function SsrRoot() { - payload ??= createFromReadableStream(ssrStream) - return use(payload).root + // deserialization needs to be kicked off inside ReactDOMServer context + // for ReactDomServer preinit/preloading to work + payload ??= createFromReadableStream(rscStream1) + return React.use(payload).root } + // render html (traditional SSR) const bootstrapScriptContent = await import.meta.viteRsc.loadBootstrapScriptContent('index') - const htmlStream = await renderToReadableStream(, { - bootstrapScriptContent, - }) - return htmlStream.pipeThrough(injectRSCPayload(browserStream)) + let htmlStream: ReadableStream + let status: number | undefined + try { + htmlStream = await renderToReadableStream(, { + bootstrapScriptContent: options?.debugNojs + ? undefined + : bootstrapScriptContent, + nonce: options?.nonce, + formState: options?.formState, + }) + } catch (e) { + // fallback to render an empty shell and run pure CSR on browser, + // which can replay server component error and trigger error boundary. + status = 500 + htmlStream = await renderToReadableStream( + + + + + , + { + bootstrapScriptContent: + `self.__NO_HYDRATE=1;` + + (options?.debugNojs ? '' : bootstrapScriptContent), + nonce: options?.nonce, + }, + ) + } + + let responseStream: ReadableStream = htmlStream + if (!options?.debugNojs) { + // initial RSC stream is injected in HTML stream as + // using utility made by devongovett https://github.com/devongovett/rsc-html-stream + responseStream = responseStream.pipeThrough( + injectRSCPayload(rscStream2, { + nonce: options?.nonce, + }), + ) + } + + return { stream: responseStream, status } } diff --git a/packages/plugin-rsc/examples/use-cache-callable/src/framework/error-boundary.tsx b/packages/plugin-rsc/examples/use-cache-callable/src/framework/error-boundary.tsx new file mode 100644 index 000000000..39d916510 --- /dev/null +++ b/packages/plugin-rsc/examples/use-cache-callable/src/framework/error-boundary.tsx @@ -0,0 +1,81 @@ +'use client' + +import React from 'react' + +// Minimal ErrorBoundary example to handle errors globally on browser +export function GlobalErrorBoundary(props: { children?: React.ReactNode }) { + return ( + + {props.children} + + ) +} + +// https://github.com/vercel/next.js/blob/33f8428f7066bf8b2ec61f025427ceb2a54c4bdf/packages/next/src/client/components/error-boundary.tsx +// https://react.dev/reference/react/Component#catching-rendering-errors-with-an-error-boundary +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 + } +} + +// https://github.com/vercel/next.js/blob/677c9b372faef680d17e9ba224743f44e1107661/packages/next/src/build/webpack/loaders/next-app-loader.ts#L73 +// https://github.com/vercel/next.js/blob/677c9b372faef680d17e9ba224743f44e1107661/packages/next/src/client/components/error-boundary.tsx#L145 +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/use-cache-callable/src/framework/request.ts b/packages/plugin-rsc/examples/use-cache-callable/src/framework/request.ts deleted file mode 100644 index 8a694878c..000000000 --- a/packages/plugin-rsc/examples/use-cache-callable/src/framework/request.ts +++ /dev/null @@ -1,29 +0,0 @@ -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, - } -} diff --git a/packages/plugin-rsc/examples/use-cache-callable/src/framework/request.tsx b/packages/plugin-rsc/examples/use-cache-callable/src/framework/request.tsx new file mode 100644 index 000000000..4c7c666e8 --- /dev/null +++ b/packages/plugin-rsc/examples/use-cache-callable/src/framework/request.tsx @@ -0,0 +1,58 @@ +// Framework conventions (arbitrary choices for this demo): +// - Use `_.rsc` URL suffix to differentiate RSC requests from SSR requests +// - Use `x-rsc-action` header to pass server action ID +const URL_POSTFIX = '_.rsc' +const HEADER_ACTION_ID = 'x-rsc-action' + +// Parsed request information used to route between RSC/SSR rendering and action handling. +// Created by parseRenderRequest() from incoming HTTP requests. +type RenderRequest = { + isRsc: boolean // true if request should return RSC payload (via _.rsc suffix) + isAction: boolean // true if this is a server action call (POST request) + actionId?: string // server action ID from x-rsc-action header + request: Request // normalized Request with _.rsc suffix removed from URL + url: URL // normalized URL with _.rsc suffix removed +} + +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, + } + } else { + return { + isRsc: false, + isAction, + request, + url, + } + } +} diff --git a/packages/plugin-rsc/examples/use-cache-callable/src/framework/use-cache-runtime.tsx b/packages/plugin-rsc/examples/use-cache-callable/src/framework/use-cache-runtime.tsx new file mode 100644 index 000000000..a25e56c6c --- /dev/null +++ b/packages/plugin-rsc/examples/use-cache-callable/src/framework/use-cache-runtime.tsx @@ -0,0 +1,100 @@ +import { + createClientTemporaryReferenceSet, + encodeReply, + createTemporaryReferenceSet, + decodeReply, + renderToReadableStream, + createFromReadableStream, +} from '@vitejs/plugin-rsc/rsc' + +// based on +// https://github.com/vercel/next.js/pull/70435 +// https://github.com/vercel/next.js/blob/09a2167b0a970757606b7f91ff2d470f77f13f8c/packages/next/src/server/use-cache/use-cache-wrapper.ts + +const cachedFnMap = new WeakMap() +const cachedFnCacheEntries = new WeakMap< + Function, + Record> +>() + +export default function cacheWrapper(fn: (...args: any[]) => Promise) { + if (cachedFnMap.has(fn)) { + return cachedFnMap.get(fn)! + } + + async function cachedFn(...args: any[]): Promise { + let cacheEntries = cachedFnCacheEntries.get(cachedFn) + if (!cacheEntries) { + cacheEntries = {} + cachedFnCacheEntries.set(cachedFn, cacheEntries) + } + + // Serialize arguments to a cache key via `encodeReply` from `react-server-dom/client`. + // NOTE: using `renderToReadableStream` here for arguments serialization would end up + // serializing react elements (e.g. children props), which causes + // those arguments to be included as a cache key and it doesn't achieve + // "use cache static shell + dynamic children props" pattern. + // cf. https://nextjs.org/docs/app/api-reference/directives/use-cache#non-serializable-arguments + const clientTemporaryReferences = createClientTemporaryReferenceSet() + const encodedArguments = await encodeReply(args, { + temporaryReferences: clientTemporaryReferences, + }) + const serializedCacheKey = await replyToCacheKey(encodedArguments) + + // cache `fn` result as stream + // (cache value is promise so that it dedupes concurrent async calls) + const entryPromise = (cacheEntries[serializedCacheKey] ??= (async () => { + const temporaryReferences = createTemporaryReferenceSet() + const decodedArgs = await decodeReply(encodedArguments, { + temporaryReferences, + }) + + // run the original function + const result = await fn(...decodedArgs) + + // serialize result to a ReadableStream + const stream = renderToReadableStream(result, { + environmentName: 'Cache', + temporaryReferences, + }) + return new StreamCacher(stream) + })()) + + // deserialized cached stream + const stream = (await entryPromise).get() + const result = createFromReadableStream(stream, { + environmentName: 'Cache', + replayConsoleLogs: true, + temporaryReferences: clientTemporaryReferences, + }) + return result + } + + cachedFnMap.set(fn, cachedFn) + + return cachedFn +} + +export function revalidateCache(cachedFn: Function) { + cachedFnCacheEntries.delete(cachedFn) +} + +class StreamCacher { + constructor(private stream: ReadableStream) {} + get(): ReadableStream { + const [returnStream, savedStream] = this.stream.tee() + this.stream = savedStream + return returnStream + } +} + +async function replyToCacheKey(reply: string | FormData) { + if (typeof reply === 'string') { + return reply + } + const buffer = await crypto.subtle.digest( + 'SHA-256', + await new Response(reply).arrayBuffer(), + ) + return btoa(String.fromCharCode(...new Uint8Array(buffer))) +} diff --git a/packages/plugin-rsc/examples/use-cache-callable/src/root.tsx b/packages/plugin-rsc/examples/use-cache-callable/src/root.tsx index 07781e4f2..2c6f89b2f 100644 --- a/packages/plugin-rsc/examples/use-cache-callable/src/root.tsx +++ b/packages/plugin-rsc/examples/use-cache-callable/src/root.tsx @@ -2,7 +2,7 @@ import { FileDirectiveFromClient } from './features/file-directive-from-client/c import { FileDirectiveFromServer } from './features/file-directive-from-server/server' import { InlineDirective } from './features/inline-directive/server' -export function Root() { +export function Root(_props: { url: URL }) { return ( diff --git a/packages/plugin-rsc/examples/use-cache-callable/vite.config.ts b/packages/plugin-rsc/examples/use-cache-callable/vite.config.ts index 2bca4d379..0241d75af 100644 --- a/packages/plugin-rsc/examples/use-cache-callable/vite.config.ts +++ b/packages/plugin-rsc/examples/use-cache-callable/vite.config.ts @@ -1,15 +1,7 @@ import react from '@vitejs/plugin-react' -import rsc, { getPluginApi, type RscPluginManager } from '@vitejs/plugin-rsc' -import { - hasDirective, - transformDirectiveProxyExport, - transformHoistInlineDirective, - transformWrapExport, -} from '@vitejs/plugin-rsc/transforms' -import { defineConfig, parseAstAsync, type Plugin } from 'vite' - -const directive = 'use cache' -const pluginName = 'example:use-cache-callable' +import rsc from '@vitejs/plugin-rsc' +import { defineConfig } from 'vite' +import { callableCachePlugin } from './callable-cache-plugin.ts' export default defineConfig({ plugins: [ @@ -24,95 +16,3 @@ export default defineConfig({ }), ], }) - -function callableCachePlugin(): Plugin { - let manager: RscPluginManager - - return { - name: pluginName, - configResolved(config) { - manager = getPluginApi(config)!.manager - }, - async transform(code, id) { - 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 environmentName = this.environment.name - - if (environmentName === 'rsc') { - const runtime = (value: string, name: string) => - `$$ReactServer.registerServerReference(` + - `$$cacheWrapper(${value}),` + - `${JSON.stringify(reference.referenceKey)},` + - `${JSON.stringify(name)})` - const result = hasDirective(ast.body, directive) - ? transformWrapExport(code, ast, { - runtime, - rejectNonAsyncFunction: true, - }) - : transformHoistInlineDirective(code, ast, { - directive, - rejectNonAsyncFunction: true, - hoistRuntime: true, - runtime, - }) - if (!result.output.hasChanged()) { - manager.serverReferences.deleteClaim(pluginName, id) - return - } - - manager.serverReferences.replaceClaim(pluginName, id, { - ...reference, - exportNames: 'names' in result ? result.names : result.exportNames, - }) - result.output.prepend( - `import $$cacheWrapper from "/src/framework/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' }), - } - } - - const result = transformDirectiveProxyExport(ast, { - code, - directive, - rejectNonAsyncFunction: true, - runtime: (name) => - `$$ReactClient.createServerReference(` + - `${JSON.stringify(reference.referenceKey + '#' + name)},` + - `$$ReactClient.callServer,` + - `undefined,` + - (this.environment.mode === 'dev' - ? `$$ReactClient.findSourceMapURL,` - : `undefined,`) + - `${JSON.stringify(name)})`, - }) - if (!result?.output.hasChanged()) { - manager.serverReferences.deleteClaim(pluginName, id) - return - } - - manager.serverReferences.replaceClaim(pluginName, id, { - ...reference, - exportNames: result.exportNames, - }) - const runtimeEnvironment = - environmentName === 'client' ? 'browser' : 'ssr' - result.output.prepend( - `import * as $$ReactClient from "@vitejs/plugin-rsc/react/${runtimeEnvironment}";\n`, - ) - return { - code: result.output.toString(), - map: result.output.generateMap({ hires: 'boundary' }), - } - }, - } -} From 2a5ddefa03bae0b01ec215fe45e1a4c53960ef07 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Date: Tue, 28 Jul 2026 14:38:05 +0900 Subject: [PATCH 08/15] refactor(rsc): route callable cache examples Co-authored-by: OpenCode --- .../plugin-rsc/e2e/use-cache-callable.test.ts | 6 +- .../examples/use-cache-callable/src/root.tsx | 57 +++++++++++++++++-- 2 files changed, 57 insertions(+), 6 deletions(-) diff --git a/packages/plugin-rsc/e2e/use-cache-callable.test.ts b/packages/plugin-rsc/e2e/use-cache-callable.test.ts index b9f9f54e2..fd2a5d222 100644 --- a/packages/plugin-rsc/e2e/use-cache-callable.test.ts +++ b/packages/plugin-rsc/e2e/use-cache-callable.test.ts @@ -17,6 +17,8 @@ function defineTests(f: Fixture) { using _errors = expectNoPageError(page) await page.goto(f.url()) await waitForHydration(page) + await page.getByRole('link', { name: 'Inline directive' }).click() + await expect(page).toHaveURL(f.url('/inline-directive')) const example = page.getByTestId('inline-directive') await expect(example.locator('span')).toHaveText( @@ -38,7 +40,7 @@ function defineTests(f: Fixture) { test('file directive from server', async ({ page }) => { using _errors = expectNoPageError(page) - await page.goto(f.url()) + await page.goto(f.url('/file-directive-from-server')) await waitForHydration(page) const example = page.getByTestId('file-directive-from-server') @@ -57,7 +59,7 @@ function defineTests(f: Fixture) { test('file directive from client', async ({ page }) => { using _errors = expectNoPageError(page) - await page.goto(f.url()) + await page.goto(f.url('/file-directive-from-client')) await waitForHydration(page) const example = page.getByTestId('file-directive-from-client') diff --git a/packages/plugin-rsc/examples/use-cache-callable/src/root.tsx b/packages/plugin-rsc/examples/use-cache-callable/src/root.tsx index 2c6f89b2f..0791f349d 100644 --- a/packages/plugin-rsc/examples/use-cache-callable/src/root.tsx +++ b/packages/plugin-rsc/examples/use-cache-callable/src/root.tsx @@ -2,7 +2,34 @@ import { FileDirectiveFromClient } from './features/file-directive-from-client/c import { FileDirectiveFromServer } from './features/file-directive-from-server/server' import { InlineDirective } from './features/inline-directive/server' -export function Root(_props: { url: URL }) { +const routes = [ + { + path: '/inline-directive', + title: 'Inline directive', + description: + 'A cached function captures server component state and is passed to a client component.', + Component: InlineDirective, + }, + { + path: '/file-directive-from-server', + title: 'File directive from server', + description: + 'A cached module export is imported by a server component and passed to a client component.', + Component: FileDirectiveFromServer, + }, + { + path: '/file-directive-from-client', + title: 'File directive from client', + description: + 'A client component imports a cached module export through its generated proxy.', + Component: FileDirectiveFromClient, + }, +] + +export function Root({ url }: { url: URL }) { + const route = routes.find((item) => item.path === url.pathname) + const Example = route?.Component + return ( @@ -11,9 +38,31 @@ export function Root(_props: { url: URL }) {

RSC callable use cache

- - - + +
+ {route && Example ? ( + <> +

{route.title}

+

{route.description}

+ + + ) : ( +

Select an example.

+ )} +
) From 018f11038e201dcd59affbb60fbc62032bf0f21a Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Date: Tue, 28 Jul 2026 14:53:44 +0900 Subject: [PATCH 09/15] test(rsc): cover callable progressive enhancement Co-authored-by: OpenCode --- .../plugin-rsc/e2e/use-cache-callable.test.ts | 46 +++++++++++++++---- .../file-directive-from-client/action.ts | 9 ++-- .../file-directive-from-client/client.tsx | 21 ++++----- .../file-directive-from-client/server.tsx | 6 +++ .../file-directive-from-client/state.ts | 4 ++ .../file-directive-from-server/action.ts | 9 ++-- .../file-directive-from-server/client.tsx | 22 ++++----- .../file-directive-from-server/server.tsx | 8 +++- .../file-directive-from-server/state.ts | 4 ++ .../src/features/inline-directive/client.tsx | 23 +++++----- .../src/features/inline-directive/server.tsx | 8 ++-- .../src/framework/use-cache-runtime.tsx | 23 +++++++++- .../examples/use-cache-callable/src/root.tsx | 4 +- .../src/framework/use-cache-runtime.tsx | 23 +++++++++- 14 files changed, 152 insertions(+), 58 deletions(-) create mode 100644 packages/plugin-rsc/examples/use-cache-callable/src/features/file-directive-from-client/server.tsx create mode 100644 packages/plugin-rsc/examples/use-cache-callable/src/features/file-directive-from-client/state.ts create mode 100644 packages/plugin-rsc/examples/use-cache-callable/src/features/file-directive-from-server/state.ts diff --git a/packages/plugin-rsc/e2e/use-cache-callable.test.ts b/packages/plugin-rsc/e2e/use-cache-callable.test.ts index fd2a5d222..8ed941ffd 100644 --- a/packages/plugin-rsc/e2e/use-cache-callable.test.ts +++ b/packages/plugin-rsc/e2e/use-cache-callable.test.ts @@ -1,4 +1,4 @@ -import { expect, test } from '@playwright/test' +import { expect, test, type Locator, type Page } from '@playwright/test' import { type Fixture, useFixture } from './fixture' import { expectNoPageError, waitForHydration } from './helper' @@ -24,20 +24,39 @@ function defineTests(f: Fixture) { await expect(example.locator('span')).toHaveText( 'requests: 0; result: none', ) - await example.getByRole('button', { name: 'same' }).click() + const argument = example.getByRole('textbox', { name: 'argument' }) + await submit(page, example) await expect(example.locator('span')).toHaveText( 'requests: 1; result: captured:same:1', ) - await example.getByRole('button', { name: 'same' }).click() + await submit(page, example) await expect(example.locator('span')).toHaveText( 'requests: 2; result: captured:same:1', ) - await example.getByRole('button', { name: 'different' }).click() + await argument.fill('different') + await submit(page, example) await expect(example.locator('span')).toHaveText( 'requests: 3; result: captured:different:2', ) }) + test('inline directive progressive enhancement', async ({ browser }) => { + const context = await browser.newContext({ javaScriptEnabled: false }) + const page = await context.newPage() + await page.goto(f.url('/inline-directive')) + + const example = page.getByTestId('inline-directive') + await example.getByRole('textbox', { name: 'argument' }).fill('progressive') + await example.getByRole('button', { name: 'call' }).click() + const result = await example.locator('span').textContent() + expect(result).toMatch(/^requests: 0; result: captured:progressive:\d+$/) + + await example.getByRole('textbox', { name: 'argument' }).fill('progressive') + await example.getByRole('button', { name: 'call' }).click() + await expect(example.locator('span')).toHaveText(result!) + await context.close() + }) + test('file directive from server', async ({ page }) => { using _errors = expectNoPageError(page) await page.goto(f.url('/file-directive-from-server')) @@ -47,11 +66,11 @@ function defineTests(f: Fixture) { await expect(example.locator('span')).toHaveText( 'requests: 0; result: none', ) - await example.getByRole('button', { name: 'call' }).click() + await submit(page, example) await expect(example.locator('span')).toHaveText( 'requests: 1; result: server:same:1', ) - await example.getByRole('button', { name: 'call' }).click() + await submit(page, example) await expect(example.locator('span')).toHaveText( 'requests: 2; result: server:same:1', ) @@ -66,13 +85,24 @@ function defineTests(f: Fixture) { await expect(example.locator('span')).toHaveText( 'requests: 0; result: none', ) - await example.getByRole('button', { name: 'call' }).click() + await submit(page, example) await expect(example.locator('span')).toHaveText( 'requests: 1; result: client:same:1', ) - await example.getByRole('button', { name: 'call' }).click() + await submit(page, example) await expect(example.locator('span')).toHaveText( 'requests: 2; result: client:same:1', ) }) } + +async function submit(page: Page, form: Locator) { + await Promise.all([ + page.waitForResponse( + (response) => + response.request().method() === 'POST' && + response.url().includes('_.rsc'), + ), + form.getByRole('button', { name: 'call' }).click(), + ]) +} diff --git a/packages/plugin-rsc/examples/use-cache-callable/src/features/file-directive-from-client/action.ts b/packages/plugin-rsc/examples/use-cache-callable/src/features/file-directive-from-client/action.ts index a9b6e0d1b..66544d1a3 100644 --- a/packages/plugin-rsc/examples/use-cache-callable/src/features/file-directive-from-client/action.ts +++ b/packages/plugin-rsc/examples/use-cache-callable/src/features/file-directive-from-client/action.ts @@ -1,8 +1,9 @@ 'use cache' -let implementationCalls = 0 +import { state } from './state' -export async function cachedFromClient(argument: string) { - implementationCalls++ - return `client:${argument}:${implementationCalls}` +export async function cachedFromClient(formData: FormData) { + const argument = String(formData.get('argument')) + state.implementationCalls++ + state.result = `client:${argument}:${state.implementationCalls}` } diff --git a/packages/plugin-rsc/examples/use-cache-callable/src/features/file-directive-from-client/client.tsx b/packages/plugin-rsc/examples/use-cache-callable/src/features/file-directive-from-client/client.tsx index 566997029..581335e6e 100644 --- a/packages/plugin-rsc/examples/use-cache-callable/src/features/file-directive-from-client/client.tsx +++ b/packages/plugin-rsc/examples/use-cache-callable/src/features/file-directive-from-client/client.tsx @@ -3,21 +3,20 @@ import { useState } from 'react' import { cachedFromClient } from './action' -export function FileDirectiveFromClient() { +export function FileDirectiveFromClient(props: { result: string }) { const [requests, setRequests] = useState(0) - const [result, setResult] = useState('none') - - async function call() { - setRequests((value) => value + 1) - setResult(await cachedFromClient('same')) - } return ( -
- + setRequests((value) => value + 1)} + > + + - requests: {requests}; result: {result} + requests: {requests}; result: {props.result} -
+ ) } diff --git a/packages/plugin-rsc/examples/use-cache-callable/src/features/file-directive-from-client/server.tsx b/packages/plugin-rsc/examples/use-cache-callable/src/features/file-directive-from-client/server.tsx new file mode 100644 index 000000000..a21ad2de6 --- /dev/null +++ b/packages/plugin-rsc/examples/use-cache-callable/src/features/file-directive-from-client/server.tsx @@ -0,0 +1,6 @@ +import { FileDirectiveFromClient } from './client' +import { state } from './state' + +export function FileDirectiveFromClientServer() { + return +} diff --git a/packages/plugin-rsc/examples/use-cache-callable/src/features/file-directive-from-client/state.ts b/packages/plugin-rsc/examples/use-cache-callable/src/features/file-directive-from-client/state.ts new file mode 100644 index 000000000..61bd8f097 --- /dev/null +++ b/packages/plugin-rsc/examples/use-cache-callable/src/features/file-directive-from-client/state.ts @@ -0,0 +1,4 @@ +export const state = { + implementationCalls: 0, + result: 'none', +} diff --git a/packages/plugin-rsc/examples/use-cache-callable/src/features/file-directive-from-server/action.ts b/packages/plugin-rsc/examples/use-cache-callable/src/features/file-directive-from-server/action.ts index 27d37347f..4d4737e88 100644 --- a/packages/plugin-rsc/examples/use-cache-callable/src/features/file-directive-from-server/action.ts +++ b/packages/plugin-rsc/examples/use-cache-callable/src/features/file-directive-from-server/action.ts @@ -1,8 +1,9 @@ 'use cache' -let implementationCalls = 0 +import { state } from './state' -export async function cachedFromServer(argument: string) { - implementationCalls++ - return `server:${argument}:${implementationCalls}` +export async function cachedFromServer(formData: FormData) { + const argument = String(formData.get('argument')) + state.implementationCalls++ + state.result = `server:${argument}:${state.implementationCalls}` } diff --git a/packages/plugin-rsc/examples/use-cache-callable/src/features/file-directive-from-server/client.tsx b/packages/plugin-rsc/examples/use-cache-callable/src/features/file-directive-from-server/client.tsx index 1b82217ae..d78b33c0c 100644 --- a/packages/plugin-rsc/examples/use-cache-callable/src/features/file-directive-from-server/client.tsx +++ b/packages/plugin-rsc/examples/use-cache-callable/src/features/file-directive-from-server/client.tsx @@ -3,22 +3,22 @@ import { useState } from 'react' export function FileDirectiveFromServerClient(props: { - action: (argument: string) => Promise + action: (formData: FormData) => Promise + result: string }) { const [requests, setRequests] = useState(0) - const [result, setResult] = useState('none') - - async function call() { - setRequests((value) => value + 1) - setResult(await props.action('same')) - } return ( -
- +
setRequests((value) => value + 1)} + > + + - requests: {requests}; result: {result} + requests: {requests}; result: {props.result} -
+ ) } diff --git a/packages/plugin-rsc/examples/use-cache-callable/src/features/file-directive-from-server/server.tsx b/packages/plugin-rsc/examples/use-cache-callable/src/features/file-directive-from-server/server.tsx index a40c41e53..31ac2d986 100644 --- a/packages/plugin-rsc/examples/use-cache-callable/src/features/file-directive-from-server/server.tsx +++ b/packages/plugin-rsc/examples/use-cache-callable/src/features/file-directive-from-server/server.tsx @@ -1,6 +1,12 @@ import { cachedFromServer } from './action' import { FileDirectiveFromServerClient } from './client' +import { state } from './state' export function FileDirectiveFromServer() { - return + return ( + + ) } diff --git a/packages/plugin-rsc/examples/use-cache-callable/src/features/file-directive-from-server/state.ts b/packages/plugin-rsc/examples/use-cache-callable/src/features/file-directive-from-server/state.ts new file mode 100644 index 000000000..61bd8f097 --- /dev/null +++ b/packages/plugin-rsc/examples/use-cache-callable/src/features/file-directive-from-server/state.ts @@ -0,0 +1,4 @@ +export const state = { + implementationCalls: 0, + result: 'none', +} diff --git a/packages/plugin-rsc/examples/use-cache-callable/src/features/inline-directive/client.tsx b/packages/plugin-rsc/examples/use-cache-callable/src/features/inline-directive/client.tsx index 716bfa13a..905420f25 100644 --- a/packages/plugin-rsc/examples/use-cache-callable/src/features/inline-directive/client.tsx +++ b/packages/plugin-rsc/examples/use-cache-callable/src/features/inline-directive/client.tsx @@ -3,23 +3,22 @@ import { useState } from 'react' export function InlineDirectiveClient(props: { - action: (argument: string) => Promise + action: (formData: FormData) => Promise + result: 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 ( -
- - +
setRequests((value) => value + 1)} + > + + - requests: {requests}; result: {result} + requests: {requests}; result: {props.result} -
+ ) } diff --git a/packages/plugin-rsc/examples/use-cache-callable/src/features/inline-directive/server.tsx b/packages/plugin-rsc/examples/use-cache-callable/src/features/inline-directive/server.tsx index 73bfe0189..2e7c4c945 100644 --- a/packages/plugin-rsc/examples/use-cache-callable/src/features/inline-directive/server.tsx +++ b/packages/plugin-rsc/examples/use-cache-callable/src/features/inline-directive/server.tsx @@ -1,15 +1,17 @@ import { InlineDirectiveClient } from './client' let implementationCalls = 0 +let result = 'none' export function InlineDirective() { const captured = 'captured' - async function cachedAction(argument: string) { + async function cachedAction(formData: FormData) { 'use cache' + const argument = String(formData.get('argument')) implementationCalls++ - return `${captured}:${argument}:${implementationCalls}` + result = `${captured}:${argument}:${implementationCalls}` } - return + return } diff --git a/packages/plugin-rsc/examples/use-cache-callable/src/framework/use-cache-runtime.tsx b/packages/plugin-rsc/examples/use-cache-callable/src/framework/use-cache-runtime.tsx index a25e56c6c..62c5bcc61 100644 --- a/packages/plugin-rsc/examples/use-cache-callable/src/framework/use-cache-runtime.tsx +++ b/packages/plugin-rsc/examples/use-cache-callable/src/framework/use-cache-runtime.tsx @@ -92,9 +92,30 @@ async function replyToCacheKey(reply: string | FormData) { if (typeof reply === 'string') { return reply } + // Multipart serialization generates a random boundary, so hash the entries directly. + const parts: BlobPart[] = [] + for (const [name, value] of reply) { + if (typeof value === 'string') { + parts.push(JSON.stringify([name, 'string', value]), '\0') + } else { + parts.push( + JSON.stringify([ + name, + 'file', + value.name, + value.type, + value.size, + value.lastModified, + ]), + '\0', + await value.arrayBuffer(), + '\0', + ) + } + } const buffer = await crypto.subtle.digest( 'SHA-256', - await new Response(reply).arrayBuffer(), + await new Blob(parts).arrayBuffer(), ) return btoa(String.fromCharCode(...new Uint8Array(buffer))) } diff --git a/packages/plugin-rsc/examples/use-cache-callable/src/root.tsx b/packages/plugin-rsc/examples/use-cache-callable/src/root.tsx index 0791f349d..c1782f388 100644 --- a/packages/plugin-rsc/examples/use-cache-callable/src/root.tsx +++ b/packages/plugin-rsc/examples/use-cache-callable/src/root.tsx @@ -1,4 +1,4 @@ -import { FileDirectiveFromClient } from './features/file-directive-from-client/client' +import { FileDirectiveFromClientServer } from './features/file-directive-from-client/server' import { FileDirectiveFromServer } from './features/file-directive-from-server/server' import { InlineDirective } from './features/inline-directive/server' @@ -22,7 +22,7 @@ const routes = [ title: 'File directive from client', description: 'A client component imports a cached module export through its generated proxy.', - Component: FileDirectiveFromClient, + Component: FileDirectiveFromClientServer, }, ] diff --git a/packages/plugin-rsc/examples/use-cache/src/framework/use-cache-runtime.tsx b/packages/plugin-rsc/examples/use-cache/src/framework/use-cache-runtime.tsx index a25e56c6c..62c5bcc61 100644 --- a/packages/plugin-rsc/examples/use-cache/src/framework/use-cache-runtime.tsx +++ b/packages/plugin-rsc/examples/use-cache/src/framework/use-cache-runtime.tsx @@ -92,9 +92,30 @@ async function replyToCacheKey(reply: string | FormData) { if (typeof reply === 'string') { return reply } + // Multipart serialization generates a random boundary, so hash the entries directly. + const parts: BlobPart[] = [] + for (const [name, value] of reply) { + if (typeof value === 'string') { + parts.push(JSON.stringify([name, 'string', value]), '\0') + } else { + parts.push( + JSON.stringify([ + name, + 'file', + value.name, + value.type, + value.size, + value.lastModified, + ]), + '\0', + await value.arrayBuffer(), + '\0', + ) + } + } const buffer = await crypto.subtle.digest( 'SHA-256', - await new Response(reply).arrayBuffer(), + await new Blob(parts).arrayBuffer(), ) return btoa(String.fromCharCode(...new Uint8Array(buffer))) } From a60e1b7b9203ed6f60b46c94dc4dd2cd559cd027 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Date: Wed, 29 Jul 2026 17:01:12 +0900 Subject: [PATCH 10/15] chore(rsc): align callable cache example framework Co-authored-by: OpenCode --- .../examples/use-cache-callable/package.json | 2 +- .../src/framework/entry.browser.tsx | 24 ++++----------- .../src/framework/entry.rsc.tsx | 30 +------------------ .../src/framework/entry.ssr.tsx | 11 ------- .../src/framework/error-boundary.tsx | 5 ---- .../src/framework/request.tsx | 15 ++++------ .../src/framework/use-cache-runtime.tsx | 6 ++-- pnpm-lock.yaml | 2 +- 8 files changed, 17 insertions(+), 78 deletions(-) diff --git a/packages/plugin-rsc/examples/use-cache-callable/package.json b/packages/plugin-rsc/examples/use-cache-callable/package.json index ccb12bcab..4cdba0d47 100644 --- a/packages/plugin-rsc/examples/use-cache-callable/package.json +++ b/packages/plugin-rsc/examples/use-cache-callable/package.json @@ -18,6 +18,6 @@ "@vitejs/plugin-react": "latest", "@vitejs/plugin-rsc": "latest", "rsc-html-stream": "^0.0.7", - "vite": "^8.1.4" + "vite": "^8.1.5" } } diff --git a/packages/plugin-rsc/examples/use-cache-callable/src/framework/entry.browser.tsx b/packages/plugin-rsc/examples/use-cache-callable/src/framework/entry.browser.tsx index 5b48ebdfa..00dc9beef 100644 --- a/packages/plugin-rsc/examples/use-cache-callable/src/framework/entry.browser.tsx +++ b/packages/plugin-rsc/examples/use-cache-callable/src/framework/entry.browser.tsx @@ -13,17 +13,10 @@ import { GlobalErrorBoundary } from './error-boundary' import { createRscRenderRequest } from './request' async function main() { - // stash `setPayload` function to trigger re-rendering - // from outside of `BrowserRoot` component (e.g. server function call, navigation, hmr) let setPayload: (v: RscPayload) => void - // deserialize RSC stream back to React VDOM for CSR - const initialPayload = await createFromReadableStream( - // initial RSC stream is injected in SSR stream as - rscStream, - ) + const initialPayload = await createFromReadableStream(rscStream) - // browser root component to (re-)render RSC payload as state function BrowserRoot() { const [payload, setPayload_] = React.useState(initialPayload) @@ -31,7 +24,6 @@ async function main() { setPayload = (v) => React.startTransition(() => setPayload_(v)) }, [setPayload_]) - // re-fetch/render on client side navigation React.useEffect(() => { return listenNavigation(() => fetchRscPayload()) }, []) @@ -39,15 +31,12 @@ async function main() { return payload.root } - // re-fetch RSC and trigger re-rendering async function fetchRscPayload() { const renderRequest = createRscRenderRequest(window.location.href) const payload = await createFromFetch(fetch(renderRequest)) setPayload(payload) } - // register a handler which will be internally called by React - // on server function request after hydration. setServerCallback(async (id, args) => { const temporaryReferences = createTemporaryReferenceSet() const renderRequest = createRscRenderRequest(window.location.href, { @@ -63,7 +52,6 @@ async function main() { return data }) - // hydration const browserRoot = ( @@ -79,7 +67,6 @@ async function main() { }) } - // implement server HMR by triggering re-fetch/render of RSC upon server code change if (import.meta.hot) { import.meta.hot.on('rsc:update', () => { fetchRscPayload() @@ -87,7 +74,6 @@ async function main() { } } -// a little helper to setup events interception for client side navigation function listenNavigation(onNavigation: () => void) { window.addEventListener('popstate', onNavigation) @@ -114,10 +100,10 @@ function listenNavigation(onNavigation: () => void) { (!link.target || link.target === '_self') && link.origin === location.origin && !link.hasAttribute('download') && - e.button === 0 && // left clicks only - !e.metaKey && // open in new tab (mac) - !e.ctrlKey && // open in new tab (windows) - !e.altKey && // download + e.button === 0 && + !e.metaKey && + !e.ctrlKey && + !e.altKey && !e.shiftKey && !e.defaultPrevented ) { diff --git a/packages/plugin-rsc/examples/use-cache-callable/src/framework/entry.rsc.tsx b/packages/plugin-rsc/examples/use-cache-callable/src/framework/entry.rsc.tsx index 786ce67f7..75eeb23d7 100644 --- a/packages/plugin-rsc/examples/use-cache-callable/src/framework/entry.rsc.tsx +++ b/packages/plugin-rsc/examples/use-cache-callable/src/framework/entry.rsc.tsx @@ -5,41 +5,29 @@ import { loadServerAction, decodeAction, decodeFormState, -} from '@vitejs/plugin-rsc/rsc' +} from '@vitejs/plugin-rsc/rsc/server' import type { ReactFormState } from 'react-dom/client' import { Root } from '../root.tsx' import { parseRenderRequest } from './request.tsx' -// The schema of payload which is serialized into RSC stream on rsc environment -// and deserialized on ssr/client environments. export type RscPayload = { - // this demo renders/serializes/deserializes the entire root HTML element - // but this mechanism can be changed to render/fetch different parts of components - // based on your own route conventions. root: React.ReactNode - // server action return value of non-progressive enhancement case returnValue?: { ok: boolean; data: unknown } - // server action form state (e.g. useActionState) of progressive enhancement case formState?: ReactFormState } -// The plugin assumes by default that the `rsc` entry has a default export of a request handler. -// However, server entries can be executed differently by registering your own server handler. export default { fetch: handler } async function handler(request: Request): Promise { - // differentiate RSC, SSR, action, etc. const renderRequest = parseRenderRequest(request) request = renderRequest.request - // handle server function 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) { - // action is called via `ReactClient.setServerCallback`. const contentType = request.headers.get('content-type') const body = contentType?.startsWith('multipart/form-data') ? await request.formData() @@ -55,17 +43,12 @@ async function handler(request: Request): Promise { actionStatus = 500 } } else { - // otherwise server function is called via `
` - // before hydration (e.g. when javascript is disabled). - // aka progressive enhancement. const formData = await request.formData() const decodedAction = await decodeAction(formData) try { const result = await decodedAction() formState = await decodeFormState(result, formData) } catch (e) { - // there's no single general obvious way to surface this error, - // so explicitly return classic 500 response. return new Response('Internal Server Error: server action failed', { status: 500, }) @@ -73,10 +56,6 @@ async function handler(request: Request): Promise { } } - // serialization from React VDOM tree to RSC stream. - // we render RSC stream after handling server function request - // so that new render reflects updated state from server function call - // to achieve single round trip to mutate and fetch from server. const rscPayload: RscPayload = { root: , formState, @@ -85,7 +64,6 @@ async function handler(request: Request): Promise { const rscOptions = { temporaryReferences } const rscStream = renderToReadableStream(rscPayload, rscOptions) - // Respond RSC stream without HTML rendering as decided by `RenderRequest` if (renderRequest.isRsc) { return new Response(rscStream, { status: actionStatus, @@ -95,20 +73,14 @@ async function handler(request: Request): Promise { }) } - // Delegate to SSR environment for html rendering. - // The plugin provides `loadModule` helper to allow loading SSR environment entry module - // in RSC environment. however this can be customized by implementing own runtime communication - // e.g. `@cloudflare/vite-plugin`'s service binding. const ssrEntryModule = await import.meta.viteRsc.loadModule< typeof import('./entry.ssr.tsx') >('ssr', 'index') const ssrResult = await ssrEntryModule.renderHTML(rscStream, { formState, - // allow quick simulation of javascript disabled browser debugNojs: renderRequest.url.searchParams.has('__nojs'), }) - // respond html return new Response(ssrResult.stream, { status: ssrResult.status, headers: { diff --git a/packages/plugin-rsc/examples/use-cache-callable/src/framework/entry.ssr.tsx b/packages/plugin-rsc/examples/use-cache-callable/src/framework/entry.ssr.tsx index 7fc5a9564..6eb6b3650 100644 --- a/packages/plugin-rsc/examples/use-cache-callable/src/framework/entry.ssr.tsx +++ b/packages/plugin-rsc/examples/use-cache-callable/src/framework/entry.ssr.tsx @@ -13,21 +13,14 @@ export async function renderHTML( debugNojs?: boolean }, ): Promise<{ stream: ReadableStream; status?: number }> { - // duplicate one RSC stream into two. - // - one for SSR (ReactClient.createFromReadableStream below) - // - another for browser hydration payload by injecting . const [rscStream1, rscStream2] = rscStream.tee() - // deserialize RSC stream back to React VDOM let payload: Promise | undefined function SsrRoot() { - // deserialization needs to be kicked off inside ReactDOMServer context - // for ReactDomServer preinit/preloading to work payload ??= createFromReadableStream(rscStream1) return React.use(payload).root } - // render html (traditional SSR) const bootstrapScriptContent = await import.meta.viteRsc.loadBootstrapScriptContent('index') let htmlStream: ReadableStream @@ -41,8 +34,6 @@ export async function renderHTML( formState: options?.formState, }) } catch (e) { - // fallback to render an empty shell and run pure CSR on browser, - // which can replay server component error and trigger error boundary. status = 500 htmlStream = await renderToReadableStream( @@ -61,8 +52,6 @@ export async function renderHTML( let responseStream: ReadableStream = htmlStream if (!options?.debugNojs) { - // initial RSC stream is injected in HTML stream as - // using utility made by devongovett https://github.com/devongovett/rsc-html-stream responseStream = responseStream.pipeThrough( injectRSCPayload(rscStream2, { nonce: options?.nonce, diff --git a/packages/plugin-rsc/examples/use-cache-callable/src/framework/error-boundary.tsx b/packages/plugin-rsc/examples/use-cache-callable/src/framework/error-boundary.tsx index 39d916510..1c7e047c1 100644 --- a/packages/plugin-rsc/examples/use-cache-callable/src/framework/error-boundary.tsx +++ b/packages/plugin-rsc/examples/use-cache-callable/src/framework/error-boundary.tsx @@ -2,7 +2,6 @@ import React from 'react' -// Minimal ErrorBoundary example to handle errors globally on browser export function GlobalErrorBoundary(props: { children?: React.ReactNode }) { return ( @@ -11,8 +10,6 @@ export function GlobalErrorBoundary(props: { children?: React.ReactNode }) { ) } -// https://github.com/vercel/next.js/blob/33f8428f7066bf8b2ec61f025427ceb2a54c4bdf/packages/next/src/client/components/error-boundary.tsx -// https://react.dev/reference/react/Component#catching-rendering-errors-with-an-error-boundary class ErrorBoundary extends React.Component<{ children?: React.ReactNode errorComponent: React.FC<{ @@ -39,8 +36,6 @@ class ErrorBoundary extends React.Component<{ } } -// https://github.com/vercel/next.js/blob/677c9b372faef680d17e9ba224743f44e1107661/packages/next/src/build/webpack/loaders/next-app-loader.ts#L73 -// https://github.com/vercel/next.js/blob/677c9b372faef680d17e9ba224743f44e1107661/packages/next/src/client/components/error-boundary.tsx#L145 function DefaultGlobalErrorPage(props: { error: Error; reset: () => void }) { return ( diff --git a/packages/plugin-rsc/examples/use-cache-callable/src/framework/request.tsx b/packages/plugin-rsc/examples/use-cache-callable/src/framework/request.tsx index 4c7c666e8..4cf961973 100644 --- a/packages/plugin-rsc/examples/use-cache-callable/src/framework/request.tsx +++ b/packages/plugin-rsc/examples/use-cache-callable/src/framework/request.tsx @@ -1,17 +1,12 @@ -// Framework conventions (arbitrary choices for this demo): -// - Use `_.rsc` URL suffix to differentiate RSC requests from SSR requests -// - Use `x-rsc-action` header to pass server action ID const URL_POSTFIX = '_.rsc' const HEADER_ACTION_ID = 'x-rsc-action' -// Parsed request information used to route between RSC/SSR rendering and action handling. -// Created by parseRenderRequest() from incoming HTTP requests. type RenderRequest = { - isRsc: boolean // true if request should return RSC payload (via _.rsc suffix) - isAction: boolean // true if this is a server action call (POST request) - actionId?: string // server action ID from x-rsc-action header - request: Request // normalized Request with _.rsc suffix removed from URL - url: URL // normalized URL with _.rsc suffix removed + isRsc: boolean + isAction: boolean + actionId?: string + request: Request + url: URL } export function createRscRenderRequest( diff --git a/packages/plugin-rsc/examples/use-cache-callable/src/framework/use-cache-runtime.tsx b/packages/plugin-rsc/examples/use-cache-callable/src/framework/use-cache-runtime.tsx index 62c5bcc61..08051a57e 100644 --- a/packages/plugin-rsc/examples/use-cache-callable/src/framework/use-cache-runtime.tsx +++ b/packages/plugin-rsc/examples/use-cache-callable/src/framework/use-cache-runtime.tsx @@ -1,11 +1,13 @@ import { createClientTemporaryReferenceSet, + createFromReadableStream, encodeReply, +} from '@vitejs/plugin-rsc/rsc/client' +import { createTemporaryReferenceSet, decodeReply, renderToReadableStream, - createFromReadableStream, -} from '@vitejs/plugin-rsc/rsc' +} from '@vitejs/plugin-rsc/rsc/server' // based on // https://github.com/vercel/next.js/pull/70435 diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c1c441839..ce5289ec6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1030,7 +1030,7 @@ importers: specifier: ^0.0.7 version: 0.0.7 vite: - specifier: ^8.1.4 + specifier: ^8.1.5 version: 8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0) playground: From 019c955ada6ce55385aedcc90ac257659bb23e1c Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Date: Wed, 29 Jul 2026 17:55:25 +0900 Subject: [PATCH 11/15] refactor(rsc): simplify example FormData cache keys Co-authored-by: OpenCode --- .../src/framework/use-cache-runtime.tsx | 12 +++--------- .../use-cache/src/framework/use-cache-runtime.tsx | 12 +++--------- 2 files changed, 6 insertions(+), 18 deletions(-) diff --git a/packages/plugin-rsc/examples/use-cache-callable/src/framework/use-cache-runtime.tsx b/packages/plugin-rsc/examples/use-cache-callable/src/framework/use-cache-runtime.tsx index 08051a57e..d60cba155 100644 --- a/packages/plugin-rsc/examples/use-cache-callable/src/framework/use-cache-runtime.tsx +++ b/packages/plugin-rsc/examples/use-cache-callable/src/framework/use-cache-runtime.tsx @@ -94,21 +94,15 @@ async function replyToCacheKey(reply: string | FormData) { if (typeof reply === 'string') { return reply } - // Multipart serialization generates a random boundary, so hash the entries directly. + // `new Response(reply).arrayBuffer()` would serialize FormData with a random + // multipart boundary, so encode entries directly to keep cache keys stable. const parts: BlobPart[] = [] for (const [name, value] of reply) { if (typeof value === 'string') { parts.push(JSON.stringify([name, 'string', value]), '\0') } else { parts.push( - JSON.stringify([ - name, - 'file', - value.name, - value.type, - value.size, - value.lastModified, - ]), + JSON.stringify([name, 'file']), '\0', await value.arrayBuffer(), '\0', diff --git a/packages/plugin-rsc/examples/use-cache/src/framework/use-cache-runtime.tsx b/packages/plugin-rsc/examples/use-cache/src/framework/use-cache-runtime.tsx index 08051a57e..d60cba155 100644 --- a/packages/plugin-rsc/examples/use-cache/src/framework/use-cache-runtime.tsx +++ b/packages/plugin-rsc/examples/use-cache/src/framework/use-cache-runtime.tsx @@ -94,21 +94,15 @@ async function replyToCacheKey(reply: string | FormData) { if (typeof reply === 'string') { return reply } - // Multipart serialization generates a random boundary, so hash the entries directly. + // `new Response(reply).arrayBuffer()` would serialize FormData with a random + // multipart boundary, so encode entries directly to keep cache keys stable. const parts: BlobPart[] = [] for (const [name, value] of reply) { if (typeof value === 'string') { parts.push(JSON.stringify([name, 'string', value]), '\0') } else { parts.push( - JSON.stringify([ - name, - 'file', - value.name, - value.type, - value.size, - value.lastModified, - ]), + JSON.stringify([name, 'file']), '\0', await value.arrayBuffer(), '\0', From 9b2688795a28bc1300ede48c5aa0d9ab8ce32bfe Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Date: Thu, 30 Jul 2026 10:36:37 +0900 Subject: [PATCH 12/15] test(rsc): clarify callable cache scenarios Co-authored-by: OpenCode --- .../plugin-rsc/e2e/use-cache-callable.test.ts | 141 ++++++++++++------ .../file-directive-from-client/action.ts | 4 +- .../file-directive-from-client/client.tsx | 33 +++- .../file-directive-from-client/server.tsx | 7 +- .../file-directive-from-client/state.ts | 4 +- .../file-directive-from-server/action.ts | 4 +- .../file-directive-from-server/client.tsx | 29 +++- .../file-directive-from-server/server.tsx | 1 + .../file-directive-from-server/state.ts | 4 +- .../src/features/inline-directive/client.tsx | 47 ++++-- .../src/features/inline-directive/reset.ts | 10 ++ .../src/features/inline-directive/server.tsx | 18 ++- .../src/features/inline-directive/state.ts | 4 + .../src/framework/use-cache-runtime.tsx | 6 +- .../examples/use-cache-callable/src/root.tsx | 6 +- 15 files changed, 228 insertions(+), 90 deletions(-) create mode 100644 packages/plugin-rsc/examples/use-cache-callable/src/features/inline-directive/reset.ts create mode 100644 packages/plugin-rsc/examples/use-cache-callable/src/features/inline-directive/state.ts diff --git a/packages/plugin-rsc/e2e/use-cache-callable.test.ts b/packages/plugin-rsc/e2e/use-cache-callable.test.ts index 8ed941ffd..9176185c0 100644 --- a/packages/plugin-rsc/e2e/use-cache-callable.test.ts +++ b/packages/plugin-rsc/e2e/use-cache-callable.test.ts @@ -1,6 +1,6 @@ import { expect, test, type Locator, type Page } from '@playwright/test' import { type Fixture, useFixture } from './fixture' -import { expectNoPageError, waitForHydration } from './helper' +import { expectNoPageError, testNoJs, waitForHydration } from './helper' test.describe('dev', () => { const f = useFixture({ root: 'examples/use-cache-callable', mode: 'dev' }) @@ -21,40 +21,63 @@ function defineTests(f: Fixture) { await expect(page).toHaveURL(f.url('/inline-directive')) const example = page.getByTestId('inline-directive') - await expect(example.locator('span')).toHaveText( - 'requests: 0; result: none', - ) - const argument = example.getByRole('textbox', { name: 'argument' }) + const submissionCount = example.getByTestId('submission-count') + const executionCount = example.getByTestId('execution-count') + const result = example.getByTestId('result') + const argument = example.getByRole('textbox', { name: 'Cache key' }) + await page.getByRole('button', { name: 'Reset' }).click() + await expect(submissionCount).toHaveText('0') + await expect(executionCount).toHaveText('0') + await expect(result).toHaveText('not called') + + // The callable is submitted every time, but the cached implementation runs once per argument. + // alpha (cache miss) await submit(page, example) - await expect(example.locator('span')).toHaveText( - 'requests: 1; result: captured:same:1', - ) + await expect(submissionCount).toHaveText('1') + await expect(executionCount).toHaveText('1') + await expect(result).toHaveText('captured + alpha') + + // alpha (cache hit) await submit(page, example) - await expect(example.locator('span')).toHaveText( - 'requests: 2; result: captured:same:1', - ) - await argument.fill('different') + await expect(submissionCount).toHaveText('2') + await expect(executionCount).toHaveText('1') + await expect(result).toHaveText('captured + alpha') + + // beta (cache miss) + await argument.fill('beta') await submit(page, example) - await expect(example.locator('span')).toHaveText( - 'requests: 3; result: captured:different:2', - ) + await expect(submissionCount).toHaveText('3') + await expect(executionCount).toHaveText('2') + await expect(result).toHaveText('captured + beta') }) - test('inline directive progressive enhancement', async ({ browser }) => { - const context = await browser.newContext({ javaScriptEnabled: false }) - const page = await context.newPage() + testNoJs('inline directive progressive enhancement', async ({ page }) => { await page.goto(f.url('/inline-directive')) const example = page.getByTestId('inline-directive') - await example.getByRole('textbox', { name: 'argument' }).fill('progressive') - await example.getByRole('button', { name: 'call' }).click() - const result = await example.locator('span').textContent() - expect(result).toMatch(/^requests: 0; result: captured:progressive:\d+$/) - - await example.getByRole('textbox', { name: 'argument' }).fill('progressive') - await example.getByRole('button', { name: 'call' }).click() - await expect(example.locator('span')).toHaveText(result!) - await context.close() + const submissionCount = example.getByTestId('submission-count') + const executionCount = example.getByTestId('execution-count') + const result = example.getByTestId('result') + + await page.getByRole('button', { name: 'Reset' }).click() + await expect(executionCount).toHaveText('0') + + // Native form submissions call the same cached function without hydration. + await example + .getByRole('textbox', { name: 'Cache key' }) + .fill('progressive') + await example.getByRole('button', { name: 'Call cached function' }).click() + await expect(submissionCount).toHaveText('0') + await expect(executionCount).toHaveText('1') + await expect(result).toHaveText('captured + progressive') + + await example + .getByRole('textbox', { name: 'Cache key' }) + .fill('progressive') + await example.getByRole('button', { name: 'Call cached function' }).click() + await expect(submissionCount).toHaveText('0') + await expect(executionCount).toHaveText('1') + await expect(result).toHaveText('captured + progressive') }) test('file directive from server', async ({ page }) => { @@ -63,17 +86,30 @@ function defineTests(f: Fixture) { await waitForHydration(page) const example = page.getByTestId('file-directive-from-server') - await expect(example.locator('span')).toHaveText( - 'requests: 0; result: none', - ) + const submissionCount = example.getByTestId('submission-count') + const executionCount = example.getByTestId('execution-count') + const result = example.getByTestId('result') + const argument = example.getByRole('textbox', { name: 'Cache key' }) + await expect(submissionCount).toHaveText('0') + await expect(executionCount).toHaveText('0') + await expect(result).toHaveText('not called') + + // The wrapped export is passed from a Server Component to a Client Component. + await submit(page, example) + await expect(submissionCount).toHaveText('1') + await expect(executionCount).toHaveText('1') + await expect(result).toHaveText('server import + alpha') await submit(page, example) - await expect(example.locator('span')).toHaveText( - 'requests: 1; result: server:same:1', - ) + await expect(submissionCount).toHaveText('2') + await expect(executionCount).toHaveText('1') + await expect(result).toHaveText('server import + alpha') + + // A different FormData argument causes a cache miss. + await argument.fill('beta') await submit(page, example) - await expect(example.locator('span')).toHaveText( - 'requests: 2; result: server:same:1', - ) + await expect(submissionCount).toHaveText('3') + await expect(executionCount).toHaveText('2') + await expect(result).toHaveText('server import + beta') }) test('file directive from client', async ({ page }) => { @@ -82,17 +118,30 @@ function defineTests(f: Fixture) { await waitForHydration(page) const example = page.getByTestId('file-directive-from-client') - await expect(example.locator('span')).toHaveText( - 'requests: 0; result: none', - ) + const submissionCount = example.getByTestId('submission-count') + const executionCount = example.getByTestId('execution-count') + const result = example.getByTestId('result') + const argument = example.getByRole('textbox', { name: 'Cache key' }) + await expect(submissionCount).toHaveText('0') + await expect(executionCount).toHaveText('0') + await expect(result).toHaveText('not called') + + // The generated client proxy calls the wrapped export. + await submit(page, example) + await expect(submissionCount).toHaveText('1') + await expect(executionCount).toHaveText('1') + await expect(result).toHaveText('client import + alpha') await submit(page, example) - await expect(example.locator('span')).toHaveText( - 'requests: 1; result: client:same:1', - ) + await expect(submissionCount).toHaveText('2') + await expect(executionCount).toHaveText('1') + await expect(result).toHaveText('client import + alpha') + + // A different FormData argument causes a cache miss. + await argument.fill('beta') await submit(page, example) - await expect(example.locator('span')).toHaveText( - 'requests: 2; result: client:same:1', - ) + await expect(submissionCount).toHaveText('3') + await expect(executionCount).toHaveText('2') + await expect(result).toHaveText('client import + beta') }) } @@ -103,6 +152,6 @@ async function submit(page: Page, form: Locator) { response.request().method() === 'POST' && response.url().includes('_.rsc'), ), - form.getByRole('button', { name: 'call' }).click(), + form.getByRole('button', { name: 'Call cached function' }).click(), ]) } diff --git a/packages/plugin-rsc/examples/use-cache-callable/src/features/file-directive-from-client/action.ts b/packages/plugin-rsc/examples/use-cache-callable/src/features/file-directive-from-client/action.ts index 66544d1a3..90dc25108 100644 --- a/packages/plugin-rsc/examples/use-cache-callable/src/features/file-directive-from-client/action.ts +++ b/packages/plugin-rsc/examples/use-cache-callable/src/features/file-directive-from-client/action.ts @@ -4,6 +4,6 @@ import { state } from './state' export async function cachedFromClient(formData: FormData) { const argument = String(formData.get('argument')) - state.implementationCalls++ - state.result = `client:${argument}:${state.implementationCalls}` + state.executionCount++ + state.result = `client import + ${argument}` } diff --git a/packages/plugin-rsc/examples/use-cache-callable/src/features/file-directive-from-client/client.tsx b/packages/plugin-rsc/examples/use-cache-callable/src/features/file-directive-from-client/client.tsx index 581335e6e..a70845ed7 100644 --- a/packages/plugin-rsc/examples/use-cache-callable/src/features/file-directive-from-client/client.tsx +++ b/packages/plugin-rsc/examples/use-cache-callable/src/features/file-directive-from-client/client.tsx @@ -3,20 +3,37 @@ import { useState } from 'react' import { cachedFromClient } from './action' -export function FileDirectiveFromClient(props: { result: string }) { - const [requests, setRequests] = useState(0) +export function FileDirectiveFromClient(props: { + executionCount: number + result: string +}) { + const [submissions, setSubmissions] = useState(0) return ( setRequests((value) => value + 1)} + onSubmit={() => setSubmissions((value) => value + 1)} > - - - - requests: {requests}; result: {props.result} - +

+ +

+

+ +

+

+ + Submission count:{' '} + {submissions} +
+ Execution count:{' '} + {props.executionCount} +
+ Result: {props.result} +
+

) } diff --git a/packages/plugin-rsc/examples/use-cache-callable/src/features/file-directive-from-client/server.tsx b/packages/plugin-rsc/examples/use-cache-callable/src/features/file-directive-from-client/server.tsx index a21ad2de6..35be00fc3 100644 --- a/packages/plugin-rsc/examples/use-cache-callable/src/features/file-directive-from-client/server.tsx +++ b/packages/plugin-rsc/examples/use-cache-callable/src/features/file-directive-from-client/server.tsx @@ -2,5 +2,10 @@ import { FileDirectiveFromClient } from './client' import { state } from './state' export function FileDirectiveFromClientServer() { - return + return ( + + ) } diff --git a/packages/plugin-rsc/examples/use-cache-callable/src/features/file-directive-from-client/state.ts b/packages/plugin-rsc/examples/use-cache-callable/src/features/file-directive-from-client/state.ts index 61bd8f097..3b013aeb4 100644 --- a/packages/plugin-rsc/examples/use-cache-callable/src/features/file-directive-from-client/state.ts +++ b/packages/plugin-rsc/examples/use-cache-callable/src/features/file-directive-from-client/state.ts @@ -1,4 +1,4 @@ export const state = { - implementationCalls: 0, - result: 'none', + executionCount: 0, + result: 'not called', } diff --git a/packages/plugin-rsc/examples/use-cache-callable/src/features/file-directive-from-server/action.ts b/packages/plugin-rsc/examples/use-cache-callable/src/features/file-directive-from-server/action.ts index 4d4737e88..c912d165f 100644 --- a/packages/plugin-rsc/examples/use-cache-callable/src/features/file-directive-from-server/action.ts +++ b/packages/plugin-rsc/examples/use-cache-callable/src/features/file-directive-from-server/action.ts @@ -4,6 +4,6 @@ import { state } from './state' export async function cachedFromServer(formData: FormData) { const argument = String(formData.get('argument')) - state.implementationCalls++ - state.result = `server:${argument}:${state.implementationCalls}` + state.executionCount++ + state.result = `server import + ${argument}` } diff --git a/packages/plugin-rsc/examples/use-cache-callable/src/features/file-directive-from-server/client.tsx b/packages/plugin-rsc/examples/use-cache-callable/src/features/file-directive-from-server/client.tsx index d78b33c0c..7a99d0845 100644 --- a/packages/plugin-rsc/examples/use-cache-callable/src/features/file-directive-from-server/client.tsx +++ b/packages/plugin-rsc/examples/use-cache-callable/src/features/file-directive-from-server/client.tsx @@ -4,21 +4,36 @@ import { useState } from 'react' export function FileDirectiveFromServerClient(props: { action: (formData: FormData) => Promise + executionCount: number result: string }) { - const [requests, setRequests] = useState(0) + const [submissions, setSubmissions] = useState(0) return (
setRequests((value) => value + 1)} + onSubmit={() => setSubmissions((value) => value + 1)} > - - - - requests: {requests}; result: {props.result} - +

+ +

+

+ +

+

+ + Submission count:{' '} + {submissions} +
+ Execution count:{' '} + {props.executionCount} +
+ Result: {props.result} +
+

) } diff --git a/packages/plugin-rsc/examples/use-cache-callable/src/features/file-directive-from-server/server.tsx b/packages/plugin-rsc/examples/use-cache-callable/src/features/file-directive-from-server/server.tsx index 31ac2d986..b7b5393f3 100644 --- a/packages/plugin-rsc/examples/use-cache-callable/src/features/file-directive-from-server/server.tsx +++ b/packages/plugin-rsc/examples/use-cache-callable/src/features/file-directive-from-server/server.tsx @@ -6,6 +6,7 @@ export function FileDirectiveFromServer() { return ( ) diff --git a/packages/plugin-rsc/examples/use-cache-callable/src/features/file-directive-from-server/state.ts b/packages/plugin-rsc/examples/use-cache-callable/src/features/file-directive-from-server/state.ts index 61bd8f097..3b013aeb4 100644 --- a/packages/plugin-rsc/examples/use-cache-callable/src/features/file-directive-from-server/state.ts +++ b/packages/plugin-rsc/examples/use-cache-callable/src/features/file-directive-from-server/state.ts @@ -1,4 +1,4 @@ export const state = { - implementationCalls: 0, - result: 'none', + executionCount: 0, + result: 'not called', } diff --git a/packages/plugin-rsc/examples/use-cache-callable/src/features/inline-directive/client.tsx b/packages/plugin-rsc/examples/use-cache-callable/src/features/inline-directive/client.tsx index 905420f25..88b819e5a 100644 --- a/packages/plugin-rsc/examples/use-cache-callable/src/features/inline-directive/client.tsx +++ b/packages/plugin-rsc/examples/use-cache-callable/src/features/inline-directive/client.tsx @@ -4,21 +4,44 @@ import { useState } from 'react' export function InlineDirectiveClient(props: { action: (formData: FormData) => Promise + executionCount: number + resetAction: () => Promise result: string }) { - const [requests, setRequests] = useState(0) + const [submissions, setSubmissions] = useState(0) return ( -
setRequests((value) => value + 1)} - > - - - - requests: {requests}; result: {props.result} - -
+ <> +
setSubmissions((value) => value + 1)} + > +

+ +

+

+ +

+

+ + Submission count:{' '} + {submissions} +
+ Execution count:{' '} + + {props.executionCount} + +
+ Result: {props.result} +
+

+
+
+ +
+ ) } diff --git a/packages/plugin-rsc/examples/use-cache-callable/src/features/inline-directive/reset.ts b/packages/plugin-rsc/examples/use-cache-callable/src/features/inline-directive/reset.ts new file mode 100644 index 000000000..159f5fdd4 --- /dev/null +++ b/packages/plugin-rsc/examples/use-cache-callable/src/features/inline-directive/reset.ts @@ -0,0 +1,10 @@ +'use server' + +import { resetCache } from '../../framework/use-cache-runtime' +import { state } from './state' + +export async function resetAction() { + resetCache() + state.executionCount = 0 + state.result = 'not called' +} diff --git a/packages/plugin-rsc/examples/use-cache-callable/src/features/inline-directive/server.tsx b/packages/plugin-rsc/examples/use-cache-callable/src/features/inline-directive/server.tsx index 2e7c4c945..dbdfa85b4 100644 --- a/packages/plugin-rsc/examples/use-cache-callable/src/features/inline-directive/server.tsx +++ b/packages/plugin-rsc/examples/use-cache-callable/src/features/inline-directive/server.tsx @@ -1,7 +1,6 @@ import { InlineDirectiveClient } from './client' - -let implementationCalls = 0 -let result = 'none' +import { resetAction } from './reset' +import { state } from './state' export function InlineDirective() { const captured = 'captured' @@ -9,9 +8,16 @@ export function InlineDirective() { async function cachedAction(formData: FormData) { 'use cache' const argument = String(formData.get('argument')) - implementationCalls++ - result = `${captured}:${argument}:${implementationCalls}` + state.executionCount++ + state.result = `${captured} + ${argument}` } - return + return ( + + ) } diff --git a/packages/plugin-rsc/examples/use-cache-callable/src/features/inline-directive/state.ts b/packages/plugin-rsc/examples/use-cache-callable/src/features/inline-directive/state.ts new file mode 100644 index 000000000..3b013aeb4 --- /dev/null +++ b/packages/plugin-rsc/examples/use-cache-callable/src/features/inline-directive/state.ts @@ -0,0 +1,4 @@ +export const state = { + executionCount: 0, + result: 'not called', +} diff --git a/packages/plugin-rsc/examples/use-cache-callable/src/framework/use-cache-runtime.tsx b/packages/plugin-rsc/examples/use-cache-callable/src/framework/use-cache-runtime.tsx index d60cba155..b12bcf5a9 100644 --- a/packages/plugin-rsc/examples/use-cache-callable/src/framework/use-cache-runtime.tsx +++ b/packages/plugin-rsc/examples/use-cache-callable/src/framework/use-cache-runtime.tsx @@ -14,7 +14,7 @@ import { // https://github.com/vercel/next.js/blob/09a2167b0a970757606b7f91ff2d470f77f13f8c/packages/next/src/server/use-cache/use-cache-wrapper.ts const cachedFnMap = new WeakMap() -const cachedFnCacheEntries = new WeakMap< +let cachedFnCacheEntries = new WeakMap< Function, Record> >() @@ -81,6 +81,10 @@ export function revalidateCache(cachedFn: Function) { cachedFnCacheEntries.delete(cachedFn) } +export function resetCache() { + cachedFnCacheEntries = new WeakMap() +} + class StreamCacher { constructor(private stream: ReadableStream) {} get(): ReadableStream { diff --git a/packages/plugin-rsc/examples/use-cache-callable/src/root.tsx b/packages/plugin-rsc/examples/use-cache-callable/src/root.tsx index c1782f388..126ecc1d3 100644 --- a/packages/plugin-rsc/examples/use-cache-callable/src/root.tsx +++ b/packages/plugin-rsc/examples/use-cache-callable/src/root.tsx @@ -7,7 +7,7 @@ const routes = [ path: '/inline-directive', title: 'Inline directive', description: - 'A cached function captures server component state and is passed to a client component.', + 'This Server Component defines an inline cached function, captures a value, and passes the function to the client form.', Component: InlineDirective, }, { @@ -38,6 +38,10 @@ export function Root({ url }: { url: URL }) {

RSC callable use cache

+

+ Submit the same cache key twice. Submissions increase on every call, + while executions increase only on a cache miss. +