diff --git a/packages/plugin-rsc/README.md b/packages/plugin-rsc/README.md index d95040d76..23fda67ce 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..e30eb24a6 --- /dev/null +++ b/packages/plugin-rsc/e2e/use-cache-callable.test.ts @@ -0,0 +1,260 @@ +import { expect, test, type Locator, type Page } from '@playwright/test' +import { type Fixture, useFixture } from './fixture' +import { expectNoPageError, testNoJs, 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('inline directive', async ({ page }) => { + 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') + 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(submissionCount).toHaveText('1') + await expect(executionCount).toHaveText('1') + await expect(result).toHaveText('captured + alpha') + + // alpha (cache hit) + await submit(page, example) + 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(submissionCount).toHaveText('3') + await expect(executionCount).toHaveText('2') + await expect(result).toHaveText('captured + beta') + }) + + testNoJs('inline directive progressive enhancement', async ({ page }) => { + await page.goto(f.url('/inline-directive')) + + const example = page.getByTestId('inline-directive') + 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' }) + const call = example.getByRole('button', { name: 'Call cached function' }) + + await page.getByRole('button', { name: 'Reset' }).click() + await expect(submissionCount).toHaveText('0') + await expect(executionCount).toHaveText('0') + await expect(result).toHaveText('not called') + + // Native form submissions call the same cached function without hydration. + // alpha (cache miss) + await argument.fill('alpha') + await call.click() + await expect(submissionCount).toHaveText('0') + await expect(executionCount).toHaveText('1') + await expect(result).toHaveText('captured + alpha') + + // alpha (cache hit) + await argument.fill('alpha') + await call.click() + await expect(submissionCount).toHaveText('0') + await expect(executionCount).toHaveText('1') + await expect(result).toHaveText('captured + alpha') + + // beta (cache miss) + await argument.fill('beta') + await call.click() + await expect(submissionCount).toHaveText('0') + await expect(executionCount).toHaveText('2') + await expect(result).toHaveText('captured + beta') + }) + + test('file directive from server', async ({ page }) => { + using _errors = expectNoPageError(page) + await page.goto(f.url('/file-directive-from-server')) + await waitForHydration(page) + + const example = page.getByTestId('file-directive-from-server') + 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 wrapped export is passed from a Server Component to a Client Component. + // alpha (cache miss) + await submit(page, example) + await expect(submissionCount).toHaveText('1') + await expect(executionCount).toHaveText('1') + await expect(result).toHaveText('server import + alpha') + + // alpha (cache hit) + await submit(page, example) + await expect(submissionCount).toHaveText('2') + await expect(executionCount).toHaveText('1') + await expect(result).toHaveText('server import + alpha') + + // beta (cache miss) + await argument.fill('beta') + await submit(page, example) + await expect(submissionCount).toHaveText('3') + await expect(executionCount).toHaveText('2') + await expect(result).toHaveText('server import + beta') + }) + + testNoJs( + 'file directive from server progressive enhancement', + async ({ page }) => { + await page.goto(f.url('/file-directive-from-server')) + + const example = page.getByTestId('file-directive-from-server') + 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' }) + const call = example.getByRole('button', { name: 'Call cached function' }) + + await page.getByRole('button', { name: 'Reset' }).click() + await expect(submissionCount).toHaveText('0') + await expect(executionCount).toHaveText('0') + await expect(result).toHaveText('not called') + + // The wrapped export remains callable through native form submissions. + // alpha (cache miss) + await argument.fill('alpha') + await call.click() + await expect(submissionCount).toHaveText('0') + await expect(executionCount).toHaveText('1') + await expect(result).toHaveText('server import + alpha') + + // alpha (cache hit) + await argument.fill('alpha') + await call.click() + await expect(submissionCount).toHaveText('0') + await expect(executionCount).toHaveText('1') + await expect(result).toHaveText('server import + alpha') + + // beta (cache miss) + await argument.fill('beta') + await call.click() + await expect(submissionCount).toHaveText('0') + await expect(executionCount).toHaveText('2') + await expect(result).toHaveText('server import + beta') + }, + ) + + test('file directive from client', async ({ page }) => { + using _errors = expectNoPageError(page) + await page.goto(f.url('/file-directive-from-client')) + await waitForHydration(page) + + const example = page.getByTestId('file-directive-from-client') + 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 generated client proxy calls the wrapped export. + // alpha (cache miss) + await submit(page, example) + await expect(submissionCount).toHaveText('1') + await expect(executionCount).toHaveText('1') + await expect(result).toHaveText('client import + alpha') + + // alpha (cache hit) + await submit(page, example) + await expect(submissionCount).toHaveText('2') + await expect(executionCount).toHaveText('1') + await expect(result).toHaveText('client import + alpha') + + // beta (cache miss) + await argument.fill('beta') + await submit(page, example) + await expect(submissionCount).toHaveText('3') + await expect(executionCount).toHaveText('2') + await expect(result).toHaveText('client import + beta') + }) + + testNoJs( + 'file directive from client progressive enhancement', + async ({ page }) => { + await page.goto(f.url('/file-directive-from-client')) + + const example = page.getByTestId('file-directive-from-client') + 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' }) + const call = example.getByRole('button', { name: 'Call cached function' }) + + await page.getByRole('button', { name: 'Reset' }).click() + await expect(submissionCount).toHaveText('0') + await expect(executionCount).toHaveText('0') + await expect(result).toHaveText('not called') + + // The generated client proxy remains callable through native form submissions. + // alpha (cache miss) + await argument.fill('alpha') + await call.click() + await expect(submissionCount).toHaveText('0') + await expect(executionCount).toHaveText('1') + await expect(result).toHaveText('client import + alpha') + + // alpha (cache hit) + await argument.fill('alpha') + await call.click() + await expect(submissionCount).toHaveText('0') + await expect(executionCount).toHaveText('1') + await expect(result).toHaveText('client import + alpha') + + // beta (cache miss) + await argument.fill('beta') + await call.click() + await expect(submissionCount).toHaveText('0') + await expect(executionCount).toHaveText('2') + await expect(result).toHaveText('client import + beta') + }, + ) +} + +async function submit(page: Page, form: Locator) { + // `submissionCount` updates immediately on the client, while a cache hit leaves + // the server-rendered execution count and result unchanged. Those assertions do + // not prove that the server action and subsequent render have completed, so wait + // for the action response before proceeding. + await Promise.all([ + page.waitForResponse( + (response) => + response.request().method() === 'POST' && + response.url().includes('_.rsc'), + ), + form.getByRole('button', { name: 'Call cached function' }).click(), + ]) +} 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/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/package.json b/packages/plugin-rsc/examples/use-cache-callable/package.json new file mode 100644 index 000000000..4cdba0d47 --- /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.5" + } +} 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..90dc25108 --- /dev/null +++ b/packages/plugin-rsc/examples/use-cache-callable/src/features/file-directive-from-client/action.ts @@ -0,0 +1,9 @@ +'use cache' + +import { state } from './state' + +export async function cachedFromClient(formData: FormData) { + const argument = String(formData.get('argument')) + 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 new file mode 100644 index 000000000..838b5435f --- /dev/null +++ b/packages/plugin-rsc/examples/use-cache-callable/src/features/file-directive-from-client/client.tsx @@ -0,0 +1,47 @@ +'use client' + +import { useState } from 'react' +import { cachedFromClient } from './action' + +export function FileDirectiveFromClient(props: { + executionCount: number + resetAction: () => Promise + result: string +}) { + const [submissions, setSubmissions] = useState(0) + + return ( + <> +
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/file-directive-from-client/reset.ts b/packages/plugin-rsc/examples/use-cache-callable/src/features/file-directive-from-client/reset.ts new file mode 100644 index 000000000..159f5fdd4 --- /dev/null +++ b/packages/plugin-rsc/examples/use-cache-callable/src/features/file-directive-from-client/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/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..1b2ebb463 --- /dev/null +++ b/packages/plugin-rsc/examples/use-cache-callable/src/features/file-directive-from-client/server.tsx @@ -0,0 +1,13 @@ +import { FileDirectiveFromClient } from './client' +import { resetAction } from './reset' +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..3b013aeb4 --- /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 = { + 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 new file mode 100644 index 000000000..c912d165f --- /dev/null +++ b/packages/plugin-rsc/examples/use-cache-callable/src/features/file-directive-from-server/action.ts @@ -0,0 +1,9 @@ +'use cache' + +import { state } from './state' + +export async function cachedFromServer(formData: FormData) { + const argument = String(formData.get('argument')) + 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 new file mode 100644 index 000000000..9ffa05f25 --- /dev/null +++ b/packages/plugin-rsc/examples/use-cache-callable/src/features/file-directive-from-server/client.tsx @@ -0,0 +1,47 @@ +'use client' + +import { useState } from 'react' + +export function FileDirectiveFromServerClient(props: { + action: (formData: FormData) => Promise + executionCount: number + resetAction: () => Promise + result: string +}) { + const [submissions, setSubmissions] = useState(0) + + return ( + <> +
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/file-directive-from-server/reset.ts b/packages/plugin-rsc/examples/use-cache-callable/src/features/file-directive-from-server/reset.ts new file mode 100644 index 000000000..159f5fdd4 --- /dev/null +++ b/packages/plugin-rsc/examples/use-cache-callable/src/features/file-directive-from-server/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/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..e6c340114 --- /dev/null +++ b/packages/plugin-rsc/examples/use-cache-callable/src/features/file-directive-from-server/server.tsx @@ -0,0 +1,15 @@ +import { cachedFromServer } from './action' +import { FileDirectiveFromServerClient } from './client' +import { resetAction } from './reset' +import { state } from './state' + +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 new file mode 100644 index 000000000..3b013aeb4 --- /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 = { + 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 new file mode 100644 index 000000000..88b819e5a --- /dev/null +++ b/packages/plugin-rsc/examples/use-cache-callable/src/features/inline-directive/client.tsx @@ -0,0 +1,47 @@ +'use client' + +import { useState } from 'react' + +export function InlineDirectiveClient(props: { + action: (formData: FormData) => Promise + executionCount: number + resetAction: () => Promise + result: string +}) { + const [submissions, setSubmissions] = useState(0) + + return ( + <> +
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 new file mode 100644 index 000000000..dbdfa85b4 --- /dev/null +++ b/packages/plugin-rsc/examples/use-cache-callable/src/features/inline-directive/server.tsx @@ -0,0 +1,23 @@ +import { InlineDirectiveClient } from './client' +import { resetAction } from './reset' +import { state } from './state' + +export function InlineDirective() { + const captured = 'captured' + + async function cachedAction(formData: FormData) { + 'use cache' + const argument = String(formData.get('argument')) + state.executionCount++ + state.result = `${captured} + ${argument}` + } + + 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/entry.browser.tsx b/packages/plugin-rsc/examples/use-cache-callable/src/framework/entry.browser.tsx new file mode 100644 index 000000000..00dc9beef --- /dev/null +++ b/packages/plugin-rsc/examples/use-cache-callable/src/framework/entry.browser.tsx @@ -0,0 +1,124 @@ +import { + createFromReadableStream, + createFromFetch, + setServerCallback, + createTemporaryReferenceSet, + encodeReply, +} from '@vitejs/plugin-rsc/browser' +import React from 'react' +import { createRoot, hydrateRoot } from 'react-dom/client' +import { rscStream } from 'rsc-html-stream/client' +import type { RscPayload } from './entry.rsc' +import { GlobalErrorBoundary } from './error-boundary' +import { createRscRenderRequest } from './request' + +async function main() { + let setPayload: (v: RscPayload) => void + + const initialPayload = await createFromReadableStream(rscStream) + + function BrowserRoot() { + const [payload, setPayload_] = React.useState(initialPayload) + + React.useEffect(() => { + setPayload = (v) => React.startTransition(() => setPayload_(v)) + }, [setPayload_]) + + React.useEffect(() => { + return listenNavigation(() => fetchRscPayload()) + }, []) + + return payload.root + } + + async function fetchRscPayload() { + const renderRequest = createRscRenderRequest(window.location.href) + const payload = await createFromFetch(fetch(renderRequest)) + setPayload(payload) + } + + setServerCallback(async (id, args) => { + const temporaryReferences = createTemporaryReferenceSet() + const renderRequest = createRscRenderRequest(window.location.href, { + id, + body: await encodeReply(args, { temporaryReferences }), + }) + const payload = await createFromFetch(fetch(renderRequest), { + temporaryReferences, + }) + setPayload(payload) + const { ok, data } = payload.returnValue! + if (!ok) throw data + return data + }) + + const browserRoot = ( + + + + + + ) + if ('__NO_HYDRATE' in globalThis) { + createRoot(document).render(browserRoot) + } else { + hydrateRoot(document, browserRoot, { + formState: initialPayload.formState, + }) + } + + if (import.meta.hot) { + import.meta.hot.on('rsc:update', () => { + fetchRscPayload() + }) + } +} + +function listenNavigation(onNavigation: () => void) { + window.addEventListener('popstate', onNavigation) + + const oldPushState = window.history.pushState + window.history.pushState = function (...args) { + const 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 && + !e.metaKey && + !e.ctrlKey && + !e.altKey && + !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 new file mode 100644 index 000000000..75eeb23d7 --- /dev/null +++ b/packages/plugin-rsc/examples/use-cache-callable/src/framework/entry.rsc.tsx @@ -0,0 +1,94 @@ +import { + renderToReadableStream, + createTemporaryReferenceSet, + decodeReply, + loadServerAction, + decodeAction, + decodeFormState, +} from '@vitejs/plugin-rsc/rsc/server' +import type { ReactFormState } from 'react-dom/client' +import { Root } from '../root.tsx' +import { parseRenderRequest } from './request.tsx' + +export type RscPayload = { + root: React.ReactNode + returnValue?: { ok: boolean; data: unknown } + formState?: ReactFormState +} + +export default { fetch: handler } + +async function handler(request: Request): Promise { + const renderRequest = parseRenderRequest(request) + request = renderRequest.request + + let returnValue: RscPayload['returnValue'] | undefined + let formState: ReactFormState | undefined + let temporaryReferences: unknown | undefined + let actionStatus: number | undefined + if (renderRequest.isAction === true) { + if (renderRequest.actionId) { + const contentType = request.headers.get('content-type') + const body = contentType?.startsWith('multipart/form-data') + ? await request.formData() + : await request.text() + temporaryReferences = createTemporaryReferenceSet() + const args = await decodeReply(body, { temporaryReferences }) + const action = await loadServerAction(renderRequest.actionId) + try { + const data = await action.apply(null, args) + returnValue = { ok: true, data } + } catch (e) { + returnValue = { ok: false, data: e } + actionStatus = 500 + } + } else { + const formData = await request.formData() + const decodedAction = await decodeAction(formData) + try { + const result = await decodedAction() + formState = await decodeFormState(result, formData) + } catch (e) { + return new Response('Internal Server Error: server action failed', { + status: 500, + }) + } + } + } + + const rscPayload: RscPayload = { + root: , + formState, + returnValue, + } + const rscOptions = { temporaryReferences } + const rscStream = renderToReadableStream(rscPayload, rscOptions) + + if (renderRequest.isRsc) { + return new Response(rscStream, { + status: actionStatus, + headers: { + 'content-type': 'text/x-component;charset=utf-8', + }, + }) + } + + const ssrEntryModule = await import.meta.viteRsc.loadModule< + typeof import('./entry.ssr.tsx') + >('ssr', 'index') + const ssrResult = await ssrEntryModule.renderHTML(rscStream, { + formState, + debugNojs: renderRequest.url.searchParams.has('__nojs'), + }) + + return new Response(ssrResult.stream, { + status: ssrResult.status, + headers: { + 'Content-type': 'text/html', + }, + }) +} + +if (import.meta.hot) { + import.meta.hot.accept() +} diff --git a/packages/plugin-rsc/examples/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..6eb6b3650 --- /dev/null +++ b/packages/plugin-rsc/examples/use-cache-callable/src/framework/entry.ssr.tsx @@ -0,0 +1,63 @@ +import { createFromReadableStream } from '@vitejs/plugin-rsc/ssr' +import React from 'react' +import type { ReactFormState } from 'react-dom/client' +import { renderToReadableStream } from 'react-dom/server.edge' +import { injectRSCPayload } from 'rsc-html-stream/server' +import type { RscPayload } from './entry.rsc' + +export async function renderHTML( + rscStream: ReadableStream, + options: { + formState?: ReactFormState + nonce?: string + debugNojs?: boolean + }, +): Promise<{ stream: ReadableStream; status?: number }> { + const [rscStream1, rscStream2] = rscStream.tee() + + let payload: Promise | undefined + function SsrRoot() { + payload ??= createFromReadableStream(rscStream1) + return React.use(payload).root + } + + const bootstrapScriptContent = + await import.meta.viteRsc.loadBootstrapScriptContent('index') + let htmlStream: ReadableStream + let status: number | undefined + try { + htmlStream = await renderToReadableStream(, { + bootstrapScriptContent: options?.debugNojs + ? undefined + : bootstrapScriptContent, + nonce: options?.nonce, + formState: options?.formState, + }) + } catch (e) { + status = 500 + htmlStream = await renderToReadableStream( + + + + + , + { + bootstrapScriptContent: + `self.__NO_HYDRATE=1;` + + (options?.debugNojs ? '' : bootstrapScriptContent), + nonce: options?.nonce, + }, + ) + } + + let responseStream: ReadableStream = htmlStream + if (!options?.debugNojs) { + responseStream = responseStream.pipeThrough( + injectRSCPayload(rscStream2, { + nonce: options?.nonce, + }), + ) + } + + return { stream: responseStream, status } +} diff --git a/packages/plugin-rsc/examples/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..1c7e047c1 --- /dev/null +++ b/packages/plugin-rsc/examples/use-cache-callable/src/framework/error-boundary.tsx @@ -0,0 +1,76 @@ +'use client' + +import React from 'react' + +export function GlobalErrorBoundary(props: { children?: React.ReactNode }) { + return ( + + {props.children} + + ) +} + +class ErrorBoundary extends React.Component<{ + children?: React.ReactNode + errorComponent: React.FC<{ + error: Error + reset: () => void + }> +}> { + state: { error?: Error } = {} + + static getDerivedStateFromError(error: Error) { + return { error } + } + + reset = () => { + this.setState({ error: null }) + } + + render() { + const error = this.state.error + if (error) { + return + } + return this.props.children + } +} + +function DefaultGlobalErrorPage(props: { error: Error; reset: () => void }) { + return ( + + + Unexpected Error + + +

Caught an unexpected error

+
+          Error:{' '}
+          {import.meta.env.DEV && 'message' in props.error
+            ? props.error.message
+            : '(Unknown)'}
+        
+ + + + ) +} diff --git a/packages/plugin-rsc/examples/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..4cf961973 --- /dev/null +++ b/packages/plugin-rsc/examples/use-cache-callable/src/framework/request.tsx @@ -0,0 +1,53 @@ +const URL_POSTFIX = '_.rsc' +const HEADER_ACTION_ID = 'x-rsc-action' + +type RenderRequest = { + isRsc: boolean + isAction: boolean + actionId?: string + request: Request + url: URL +} + +export function createRscRenderRequest( + urlString: string, + action?: { id: string; body: BodyInit }, +): Request { + const url = new URL(urlString) + url.pathname += URL_POSTFIX + const headers = new Headers() + if (action) { + headers.set(HEADER_ACTION_ID, action.id) + } + return new Request(url.toString(), { + method: action ? 'POST' : 'GET', + headers, + body: action?.body, + }) +} + +export function parseRenderRequest(request: Request): RenderRequest { + const url = new URL(request.url) + const isAction = request.method === 'POST' + if (url.pathname.endsWith(URL_POSTFIX)) { + url.pathname = url.pathname.slice(0, -URL_POSTFIX.length) + const actionId = request.headers.get(HEADER_ACTION_ID) || undefined + if (request.method === 'POST' && !actionId) { + throw new Error('Missing action id header for RSC action request') + } + return { + isRsc: true, + isAction, + actionId, + request: new Request(url, request), + url, + } + } 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..b12bcf5a9 --- /dev/null +++ b/packages/plugin-rsc/examples/use-cache-callable/src/framework/use-cache-runtime.tsx @@ -0,0 +1,121 @@ +import { + createClientTemporaryReferenceSet, + createFromReadableStream, + encodeReply, +} from '@vitejs/plugin-rsc/rsc/client' +import { + createTemporaryReferenceSet, + decodeReply, + renderToReadableStream, +} from '@vitejs/plugin-rsc/rsc/server' + +// 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() +let 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) +} + +export function resetCache() { + cachedFnCacheEntries = new WeakMap() +} + +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 + } + // `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']), + '\0', + await value.arrayBuffer(), + '\0', + ) + } + } + const buffer = await crypto.subtle.digest( + 'SHA-256', + 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 new file mode 100644 index 000000000..126ecc1d3 --- /dev/null +++ b/packages/plugin-rsc/examples/use-cache-callable/src/root.tsx @@ -0,0 +1,73 @@ +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' + +const routes = [ + { + path: '/inline-directive', + title: 'Inline directive', + description: + 'This Server Component defines an inline cached function, captures a value, and passes the function to the client form.', + 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: FileDirectiveFromClientServer, + }, +] + +export function Root({ url }: { url: URL }) { + const route = routes.find((item) => item.path === url.pathname) + const Example = route?.Component + + return ( + + + + RSC callable use cache + + +

RSC callable use cache

+

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

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

{route.title}

+

{route.description}

+ + + ) : ( +

Select an example.

+ )} +
+ + + ) +} 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..0241d75af --- /dev/null +++ b/packages/plugin-rsc/examples/use-cache-callable/vite.config.ts @@ -0,0 +1,18 @@ +import react from '@vitejs/plugin-react' +import rsc from '@vitejs/plugin-rsc' +import { defineConfig } from 'vite' +import { callableCachePlugin } from './callable-cache-plugin.ts' + +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', + }, + }), + ], +}) 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 e282db8a9..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,9 +94,24 @@ async function replyToCacheKey(reply: string | FormData) { if (typeof reply === 'string') { return reply } + // `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']), + '\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/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..edd6348eb --- /dev/null +++ b/packages/plugin-rsc/src/transforms/fixtures/hoist-runtime/basic.js.snap.js @@ -0,0 +1,36 @@ +// names: ["$$hoist_0_noCapture","$$hoist_1_capture","$$hoist_2_exported"] + +'custom directive' +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; + +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 3dde6bc91..752699d78 100644 --- a/packages/plugin-rsc/src/transforms/hoist.test.ts +++ b/packages/plugin-rsc/src/transforms/hoist.test.ts @@ -46,6 +46,37 @@ describe('fixtures', () => { } }) +describe('hoistRuntime fixtures', () => { + const fixtures = import.meta.glob( + ['./fixtures/hoist-runtime/**/*.js', '!**/*.snap.*'], + { query: 'raw' }, + ) + + async function transformFixture(input: string) { + 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, + }) + const transformed = result.output.toString() + await parseAstAsync(transformed) + 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 + await expect(await transformFixture(input)).toMatchFileSnapshot( + file + '.snap.js', + ) + }) + } +}) + describe(transformHoistInlineDirective, () => { async function testTransform( input: string, diff --git a/packages/plugin-rsc/src/transforms/hoist.ts b/packages/plugin-rsc/src/transforms/hoist.ts index f6c36fd4e..c5a6d39ce 100644 --- a/packages/plugin-rsc/src/transforms/hoist.ts +++ b/packages/plugin-rsc/src/transforms/hoist.ts @@ -62,6 +62,24 @@ import { buildScopeTree, type ScopeTree } from './scope' * * In this second sketch, `__ENCODE__(...)` and `__DECODE__(...)` likewise * represent the expressions returned by those code-generation callbacks. + * + * With `hoistRuntime`, the runtime result becomes the module-level binding and + * the moved function becomes its private implementation: + * + * ```js + * function Component() { + * const x = 1 + * const action = $$hoist_0_action.bind(null, x) + * } + * export const $$hoist_0_action = __RUNTIME__($$hoist_0_action$$impl) + * async function $$hoist_0_action$$impl(x, y) { + * "use server" + * return x + y + * } + * ``` + * + * `noExport` independently keeps `$$hoist_0_action` module-local when an + * integration needs module-scope runtime initialization without an export. */ export function transformHoistInlineDirective( input: string, @@ -82,6 +100,12 @@ export function transformHoistInlineDirective( decode?: (value: string) => string /** Keep generated hoisted declarations module-local instead of exporting them. */ 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 @@ -102,6 +126,7 @@ export function transformHoistInlineDirective( // closure captures from module bindings and globals, which remain in scope. const scopeTree = buildScopeTree(ast) const names: string[] = [] + const runtimeHoists: string[] = [] walk(ast, { enter(node, parent) { @@ -163,27 +188,41 @@ export function transformHoistInlineDirective( const newName = `$$hoist_${names.length}` + (originalName ? `_${originalName}` : '') names.push(newName) + // Hoisted runtimes need two module bindings: a private function for the + // original body and a canonical binding for the runtime result. The + // default path keeps the original single generated function binding. + 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 the original function with the runtime expression for its - // hoisted declaration. Bind closure captures to the prepended capture - // parameters (or the single encoded parameter). - let newCode = `/* #__PURE__ */ ${runtime(newName, newName, { - directiveMatch: match, - })}` + // Replace the original function with either the hoisted runtime result + // or the runtime expression for its hoisted declaration. Bind closure + // captures to the prepended parameters (or one encoded parameter). + let newCode = options.hoistRuntime ? newName : runtimeCode if (bindVars.length > 0) { const bindArgs = options.encode ? options.encode('[' + bindVars.map((b) => b.expr).join(', ') + ']') @@ -204,15 +243,35 @@ export function transformHoistInlineDirective( }, }) - // Expose the generated hoisted declaration names. These are the new - // exports by default (unless noExport is set), so callers can also track them - // as runtime references. + if (runtimeHoists.length > 0) { + // Define hoisted runtime wrappers after leading directives. + output.prependLeft(getRuntimeHoistPosition(ast), runtimeHoists.join('')) + } + + // Expose the canonical generated names. They identify the moved functions by + // default or the runtime result bindings under hoistRuntime. Both are exports + // unless noExport is set, so callers can also track them as runtime references. return { output, names, } } +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 = + statement.type === 'ExpressionStatement' && + statement.expression.type === 'Literal' && + typeof statement.expression.value === 'string' + if (!isDirective) { + return statement.start + } + } + return 0 +} + const exactRegex = (s: string): RegExp => new RegExp('^' + s.replace(/[-[\]{}()*+?.,\\^$|#\s]/g, '\\$&') + '$') diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6f470a5eb..ce5289ec6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1005,6 +1005,34 @@ importers: 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) + 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.5 + version: 8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0) + playground: devDependencies: kill-port: