diff --git a/packages/plugin-rsc/e2e/use-cache-callable.test.ts b/packages/plugin-rsc/e2e/use-cache-callable.test.ts index 74f0457ce..936bf609a 100644 --- a/packages/plugin-rsc/e2e/use-cache-callable.test.ts +++ b/packages/plugin-rsc/e2e/use-cache-callable.test.ts @@ -368,6 +368,55 @@ function defineTests(f: Fixture) { await expect(result).toHaveText('arguments: 0') }) + for (const testCase of [ + { + route: 'use-cache-in-use-server-from-client', + defaultExecutions: 2, + overrideExecutions: 1, + }, + { + route: 'use-cache-in-use-server-from-server', + defaultExecutions: 2, + overrideExecutions: 1, + }, + { + route: 'use-server-in-use-cache-from-client', + defaultExecutions: 1, + overrideExecutions: 2, + }, + { + route: 'use-server-in-use-cache-from-server', + defaultExecutions: 1, + overrideExecutions: 2, + }, + ]) { + test(testCase.route.replaceAll('-', ' '), async ({ page }) => { + using _errors = expectNoPageError(page) + await page.goto(f.url('/' + testCase.route)) + await waitForHydration(page) + + const example = page.getByTestId(testCase.route) + const defaultExecutions = example.getByTestId('default-executions') + const overrideExecutions = example.getByTestId('override-executions') + await example.getByRole('button', { name: 'Reset' }).click() + await expect(defaultExecutions).toHaveText('0') + await expect(overrideExecutions).toHaveText('0') + + await callAction(page, example, 'Call default action') + await callAction(page, example, 'Call default action') + await expect(defaultExecutions).toHaveText( + String(testCase.defaultExecutions), + ) + + // An inline directive overrides the behavior inherited from the file. + await callAction(page, example, 'Call override action') + await callAction(page, example, 'Call override action') + await expect(overrideExecutions).toHaveText( + String(testCase.overrideExecutions), + ) + }) + } + test('protected captures', async ({ page }) => { // verify captured value is encoded and thus doesn't appear in raw response const rscResponse = await page.request.get( @@ -425,6 +474,18 @@ function defineTests(f: Fixture) { }) } +async function callAction(page: Page, example: Locator, name: string) { + const [response] = await Promise.all([ + page.waitForResponse( + (response) => + response.request().method() === 'POST' && + response.url().includes('_.rsc'), + ), + example.getByRole('button', { name }).click(), + ]) + expect(response.ok()).toBe(true) +} + 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 diff --git a/packages/plugin-rsc/examples/use-cache-callable/README.md b/packages/plugin-rsc/examples/use-cache-callable/README.md index f61a05947..6800257d8 100644 --- a/packages/plugin-rsc/examples/use-cache-callable/README.md +++ b/packages/plugin-rsc/examples/use-cache-callable/README.md @@ -46,14 +46,18 @@ Arguments are serialized with React's `encodeReply()` so values supported by the ## Examples -| Route | Demonstrates | -| ----------------------------------- | ----------------------------------------------------------------------------------------------------- | -| `/inline-directive` | An inline cached function captures a Server Component value and is passed to a Client Component form. | -| `/file-directive-from-server` | A module-level cached export is imported on the server and passed to a Client Component. | -| `/file-directive-from-client` | A Client Component imports a cached export through its generated proxy. | -| `/file-directive-extra-arguments` | A zero-parameter module export excludes React-supplied caller arguments. | -| `/inline-directive-extra-arguments` | A zero-parameter inline function uses transform metadata to exclude React-supplied caller arguments. | -| `/protected-captures` | Inline captures cross the client boundary encrypted while decoded values define cache identity. | +| Route | Demonstrates | +| -------------------------------------- | ----------------------------------------------------------------------------------------------------- | +| `/inline-directive` | An inline cached function captures a Server Component value and is passed to a Client Component form. | +| `/file-directive-from-server` | A module-level cached export is imported on the server and passed to a Client Component. | +| `/file-directive-from-client` | A Client Component imports a cached export through its generated proxy. | +| `/file-directive-extra-arguments` | A zero-parameter module export excludes React-supplied caller arguments. | +| `/inline-directive-extra-arguments` | A zero-parameter inline function uses transform metadata to exclude React-supplied caller arguments. | +| `/use-cache-in-use-server-from-client` | A Client Component imports an inline cached export from a `"use server"` module. | +| `/use-cache-in-use-server-from-server` | A Server Component passes an inline cached export from a `"use server"` module. | +| `/use-server-in-use-cache-from-client` | A Client Component imports an uncached inline server export from a `"use cache"` module. | +| `/use-server-in-use-cache-from-server` | A Server Component passes an uncached inline server export from a `"use cache"` module. | +| `/protected-captures` | Inline captures cross the client boundary encrypted while decoded values define cache identity. | Each route displays submission and execution counts. Every form submission calls the server reference, while the function body runs only on a cache miss. 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 index 5825c3a5a..53fbd762f 100644 --- a/packages/plugin-rsc/examples/use-cache-callable/callable-cache-plugin.ts +++ b/packages/plugin-rsc/examples/use-cache-callable/callable-cache-plugin.ts @@ -36,7 +36,7 @@ export function callableCachePlugin(): Plugin { name: string, options: CacheWrapperOptions, ) => - `$$ReactServer.registerServerReference(` + + `$$CacheReactServer.registerServerReference(` + `$$cacheWrapper(${value}, ${JSON.stringify(options)}),` + `${JSON.stringify(reference.referenceKey)},` + `${JSON.stringify(name)})` @@ -49,6 +49,9 @@ export function callableCachePlugin(): Plugin { // and viewport exports. // https://github.com/vercel/next.js/blob/aae4179ac628e55483b62cd023a7e1827dcef122/crates/next-custom-transforms/src/transforms/server_actions.rs#L1914-L1919 filter: (_name, meta) => + // Inline "use server" overrides the file-level cache role and + // is left for the built-in transform. + !hasFunctionDirective(meta, 'use server') && meta.valueNode?.type !== 'ObjectExpression' && meta.valueNode?.type !== 'ArrayExpression', rejectNonAsyncFunction: true, @@ -73,9 +76,14 @@ export function callableCachePlugin(): Plugin { ...reference, exportNames: 'names' in result ? result.names : result.exportNames, }) - result.output.prepend( + // Preserve leading directives so a later transform can still recognize + // a file-level role such as "use server". + const importPosition = + ast.body.find((node) => !('directive' in node))?.start ?? code.length + result.output.prependLeft( + importPosition, `import $$cacheWrapper, { encryptCacheCaptures as $$encryptCacheCaptures } from "/src/framework/use-cache-runtime";\n` + - `import * as $$ReactServer from "@vitejs/plugin-rsc/react/rsc/server";\n`, + `import * as $$CacheReactServer from "@vitejs/plugin-rsc/react/rsc/server";\n`, ) return { code: result.output.toString(), @@ -91,12 +99,12 @@ export function callableCachePlugin(): Plugin { meta.valueNode?.type !== 'ArrayExpression', rejectNonAsyncFunction: true, runtime: (name) => - `$$ReactClient.createServerReference(` + + `$$CacheReactClient.createServerReference(` + `${JSON.stringify(reference.referenceKey + '#' + name)},` + - `$$ReactClient.callServer,` + + `$$CacheReactClient.callServer,` + `undefined,` + (this.environment.mode === 'dev' - ? `$$ReactClient.findSourceMapURL,` + ? `$$CacheReactClient.findSourceMapURL,` : `undefined,`) + `${JSON.stringify(name)})`, }) @@ -112,7 +120,7 @@ export function callableCachePlugin(): Plugin { const runtimeEnvironment = environmentName === 'client' ? 'browser' : 'ssr' result.output.prepend( - `import * as $$ReactClient from "@vitejs/plugin-rsc/react/${runtimeEnvironment}";\n`, + `import * as $$CacheReactClient from "@vitejs/plugin-rsc/react/${runtimeEnvironment}";\n`, ) return { code: result.output.toString(), @@ -122,6 +130,27 @@ export function callableCachePlugin(): Plugin { } } +function hasFunctionDirective( + meta: Pick, + directive: string, +): boolean { + const node = meta.valueNode + if ( + (node?.type !== 'FunctionDeclaration' && + node?.type !== 'FunctionExpression' && + node?.type !== 'ArrowFunctionExpression') || + node.body.type !== 'BlockStatement' + ) { + return false + } + return node.body.body.some( + (statement) => + statement.type === 'ExpressionStatement' && + 'directive' in statement && + statement.directive === directive, + ) +} + function getCacheWrapperOptions( meta: Pick, ): CacheWrapperOptions { diff --git a/packages/plugin-rsc/examples/use-cache-callable/src/features/use-cache-in-use-server-from-client/actions.ts b/packages/plugin-rsc/examples/use-cache-callable/src/features/use-cache-in-use-server-from-client/actions.ts new file mode 100644 index 000000000..ad3847b4e --- /dev/null +++ b/packages/plugin-rsc/examples/use-cache-callable/src/features/use-cache-in-use-server-from-client/actions.ts @@ -0,0 +1,12 @@ +'use server' + +import { state } from './state' + +export async function defaultAction() { + state.defaultExecutions++ +} + +export async function overrideAction() { + 'use cache' + state.overrideExecutions++ +} diff --git a/packages/plugin-rsc/examples/use-cache-callable/src/features/use-cache-in-use-server-from-client/client.tsx b/packages/plugin-rsc/examples/use-cache-callable/src/features/use-cache-in-use-server-from-client/client.tsx new file mode 100644 index 000000000..687c2a8c5 --- /dev/null +++ b/packages/plugin-rsc/examples/use-cache-callable/src/features/use-cache-in-use-server-from-client/client.tsx @@ -0,0 +1,28 @@ +'use client' + +import { defaultAction, overrideAction } from './actions' +import { resetAction } from './reset' + +export function UseCacheInUseServerFromClient(props: { + defaultExecutions: number + overrideExecutions: number +}) { + return ( +
+ + +

+ Default executions:{' '} + + {props.defaultExecutions} + +
+ Override executions:{' '} + + {props.overrideExecutions} + +

+ +
+ ) +} diff --git a/packages/plugin-rsc/examples/use-cache-callable/src/features/use-cache-in-use-server-from-client/reset.ts b/packages/plugin-rsc/examples/use-cache-callable/src/features/use-cache-in-use-server-from-client/reset.ts new file mode 100644 index 000000000..16911e798 --- /dev/null +++ b/packages/plugin-rsc/examples/use-cache-callable/src/features/use-cache-in-use-server-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.defaultExecutions = 0 + state.overrideExecutions = 0 +} diff --git a/packages/plugin-rsc/examples/use-cache-callable/src/features/use-cache-in-use-server-from-client/server.tsx b/packages/plugin-rsc/examples/use-cache-callable/src/features/use-cache-in-use-server-from-client/server.tsx new file mode 100644 index 000000000..6ca011847 --- /dev/null +++ b/packages/plugin-rsc/examples/use-cache-callable/src/features/use-cache-in-use-server-from-client/server.tsx @@ -0,0 +1,11 @@ +import { UseCacheInUseServerFromClient } from './client' +import { state } from './state' + +export function UseCacheInUseServerFromClientServer() { + return ( + + ) +} diff --git a/packages/plugin-rsc/examples/use-cache-callable/src/features/use-cache-in-use-server-from-client/state.ts b/packages/plugin-rsc/examples/use-cache-callable/src/features/use-cache-in-use-server-from-client/state.ts new file mode 100644 index 000000000..8cbac1600 --- /dev/null +++ b/packages/plugin-rsc/examples/use-cache-callable/src/features/use-cache-in-use-server-from-client/state.ts @@ -0,0 +1,4 @@ +export const state = { + defaultExecutions: 0, + overrideExecutions: 0, +} diff --git a/packages/plugin-rsc/examples/use-cache-callable/src/features/use-cache-in-use-server-from-server/actions.ts b/packages/plugin-rsc/examples/use-cache-callable/src/features/use-cache-in-use-server-from-server/actions.ts new file mode 100644 index 000000000..ad3847b4e --- /dev/null +++ b/packages/plugin-rsc/examples/use-cache-callable/src/features/use-cache-in-use-server-from-server/actions.ts @@ -0,0 +1,12 @@ +'use server' + +import { state } from './state' + +export async function defaultAction() { + state.defaultExecutions++ +} + +export async function overrideAction() { + 'use cache' + state.overrideExecutions++ +} diff --git a/packages/plugin-rsc/examples/use-cache-callable/src/features/use-cache-in-use-server-from-server/client.tsx b/packages/plugin-rsc/examples/use-cache-callable/src/features/use-cache-in-use-server-from-server/client.tsx new file mode 100644 index 000000000..b52dc6d16 --- /dev/null +++ b/packages/plugin-rsc/examples/use-cache-callable/src/features/use-cache-in-use-server-from-server/client.tsx @@ -0,0 +1,32 @@ +'use client' + +type Action = () => Promise + +export function UseCacheInUseServerFromServer(props: { + defaultAction: Action + defaultExecutions: number + overrideAction: Action + overrideExecutions: number + resetAction: () => Promise +}) { + return ( +
+ + +

+ Default executions:{' '} + + {props.defaultExecutions} + +
+ Override executions:{' '} + + {props.overrideExecutions} + +

+ +
+ ) +} diff --git a/packages/plugin-rsc/examples/use-cache-callable/src/features/use-cache-in-use-server-from-server/reset.ts b/packages/plugin-rsc/examples/use-cache-callable/src/features/use-cache-in-use-server-from-server/reset.ts new file mode 100644 index 000000000..16911e798 --- /dev/null +++ b/packages/plugin-rsc/examples/use-cache-callable/src/features/use-cache-in-use-server-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.defaultExecutions = 0 + state.overrideExecutions = 0 +} diff --git a/packages/plugin-rsc/examples/use-cache-callable/src/features/use-cache-in-use-server-from-server/server.tsx b/packages/plugin-rsc/examples/use-cache-callable/src/features/use-cache-in-use-server-from-server/server.tsx new file mode 100644 index 000000000..d72130552 --- /dev/null +++ b/packages/plugin-rsc/examples/use-cache-callable/src/features/use-cache-in-use-server-from-server/server.tsx @@ -0,0 +1,16 @@ +import { defaultAction, overrideAction } from './actions' +import { UseCacheInUseServerFromServer } from './client' +import { resetAction } from './reset' +import { state } from './state' + +export function UseCacheInUseServerFromServerServer() { + return ( + + ) +} diff --git a/packages/plugin-rsc/examples/use-cache-callable/src/features/use-cache-in-use-server-from-server/state.ts b/packages/plugin-rsc/examples/use-cache-callable/src/features/use-cache-in-use-server-from-server/state.ts new file mode 100644 index 000000000..8cbac1600 --- /dev/null +++ b/packages/plugin-rsc/examples/use-cache-callable/src/features/use-cache-in-use-server-from-server/state.ts @@ -0,0 +1,4 @@ +export const state = { + defaultExecutions: 0, + overrideExecutions: 0, +} diff --git a/packages/plugin-rsc/examples/use-cache-callable/src/features/use-server-in-use-cache-from-client/actions.ts b/packages/plugin-rsc/examples/use-cache-callable/src/features/use-server-in-use-cache-from-client/actions.ts new file mode 100644 index 000000000..86e21f1b8 --- /dev/null +++ b/packages/plugin-rsc/examples/use-cache-callable/src/features/use-server-in-use-cache-from-client/actions.ts @@ -0,0 +1,12 @@ +'use cache' + +import { state } from './state' + +export async function defaultAction() { + state.defaultExecutions++ +} + +export async function overrideAction() { + 'use server' + state.overrideExecutions++ +} diff --git a/packages/plugin-rsc/examples/use-cache-callable/src/features/use-server-in-use-cache-from-client/client.tsx b/packages/plugin-rsc/examples/use-cache-callable/src/features/use-server-in-use-cache-from-client/client.tsx new file mode 100644 index 000000000..2030bd860 --- /dev/null +++ b/packages/plugin-rsc/examples/use-cache-callable/src/features/use-server-in-use-cache-from-client/client.tsx @@ -0,0 +1,28 @@ +'use client' + +import { defaultAction, overrideAction } from './actions' +import { resetAction } from './reset' + +export function UseServerInUseCacheFromClient(props: { + defaultExecutions: number + overrideExecutions: number +}) { + return ( +
+ + +

+ Default executions:{' '} + + {props.defaultExecutions} + +
+ Override executions:{' '} + + {props.overrideExecutions} + +

+ +
+ ) +} diff --git a/packages/plugin-rsc/examples/use-cache-callable/src/features/use-server-in-use-cache-from-client/reset.ts b/packages/plugin-rsc/examples/use-cache-callable/src/features/use-server-in-use-cache-from-client/reset.ts new file mode 100644 index 000000000..16911e798 --- /dev/null +++ b/packages/plugin-rsc/examples/use-cache-callable/src/features/use-server-in-use-cache-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.defaultExecutions = 0 + state.overrideExecutions = 0 +} diff --git a/packages/plugin-rsc/examples/use-cache-callable/src/features/use-server-in-use-cache-from-client/server.tsx b/packages/plugin-rsc/examples/use-cache-callable/src/features/use-server-in-use-cache-from-client/server.tsx new file mode 100644 index 000000000..c3dced99d --- /dev/null +++ b/packages/plugin-rsc/examples/use-cache-callable/src/features/use-server-in-use-cache-from-client/server.tsx @@ -0,0 +1,11 @@ +import { UseServerInUseCacheFromClient } from './client' +import { state } from './state' + +export function UseServerInUseCacheFromClientServer() { + return ( + + ) +} diff --git a/packages/plugin-rsc/examples/use-cache-callable/src/features/use-server-in-use-cache-from-client/state.ts b/packages/plugin-rsc/examples/use-cache-callable/src/features/use-server-in-use-cache-from-client/state.ts new file mode 100644 index 000000000..8cbac1600 --- /dev/null +++ b/packages/plugin-rsc/examples/use-cache-callable/src/features/use-server-in-use-cache-from-client/state.ts @@ -0,0 +1,4 @@ +export const state = { + defaultExecutions: 0, + overrideExecutions: 0, +} diff --git a/packages/plugin-rsc/examples/use-cache-callable/src/features/use-server-in-use-cache-from-server/actions.ts b/packages/plugin-rsc/examples/use-cache-callable/src/features/use-server-in-use-cache-from-server/actions.ts new file mode 100644 index 000000000..86e21f1b8 --- /dev/null +++ b/packages/plugin-rsc/examples/use-cache-callable/src/features/use-server-in-use-cache-from-server/actions.ts @@ -0,0 +1,12 @@ +'use cache' + +import { state } from './state' + +export async function defaultAction() { + state.defaultExecutions++ +} + +export async function overrideAction() { + 'use server' + state.overrideExecutions++ +} diff --git a/packages/plugin-rsc/examples/use-cache-callable/src/features/use-server-in-use-cache-from-server/client.tsx b/packages/plugin-rsc/examples/use-cache-callable/src/features/use-server-in-use-cache-from-server/client.tsx new file mode 100644 index 000000000..82f9248fb --- /dev/null +++ b/packages/plugin-rsc/examples/use-cache-callable/src/features/use-server-in-use-cache-from-server/client.tsx @@ -0,0 +1,32 @@ +'use client' + +type Action = () => Promise + +export function UseServerInUseCacheFromServer(props: { + defaultAction: Action + defaultExecutions: number + overrideAction: Action + overrideExecutions: number + resetAction: () => Promise +}) { + return ( +
+ + +

+ Default executions:{' '} + + {props.defaultExecutions} + +
+ Override executions:{' '} + + {props.overrideExecutions} + +

+ +
+ ) +} diff --git a/packages/plugin-rsc/examples/use-cache-callable/src/features/use-server-in-use-cache-from-server/reset.ts b/packages/plugin-rsc/examples/use-cache-callable/src/features/use-server-in-use-cache-from-server/reset.ts new file mode 100644 index 000000000..16911e798 --- /dev/null +++ b/packages/plugin-rsc/examples/use-cache-callable/src/features/use-server-in-use-cache-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.defaultExecutions = 0 + state.overrideExecutions = 0 +} diff --git a/packages/plugin-rsc/examples/use-cache-callable/src/features/use-server-in-use-cache-from-server/server.tsx b/packages/plugin-rsc/examples/use-cache-callable/src/features/use-server-in-use-cache-from-server/server.tsx new file mode 100644 index 000000000..b5aa8011e --- /dev/null +++ b/packages/plugin-rsc/examples/use-cache-callable/src/features/use-server-in-use-cache-from-server/server.tsx @@ -0,0 +1,16 @@ +import { defaultAction, overrideAction } from './actions' +import { UseServerInUseCacheFromServer } from './client' +import { resetAction } from './reset' +import { state } from './state' + +export function UseServerInUseCacheFromServerServer() { + return ( + + ) +} diff --git a/packages/plugin-rsc/examples/use-cache-callable/src/features/use-server-in-use-cache-from-server/state.ts b/packages/plugin-rsc/examples/use-cache-callable/src/features/use-server-in-use-cache-from-server/state.ts new file mode 100644 index 000000000..8cbac1600 --- /dev/null +++ b/packages/plugin-rsc/examples/use-cache-callable/src/features/use-server-in-use-cache-from-server/state.ts @@ -0,0 +1,4 @@ +export const state = { + defaultExecutions: 0, + overrideExecutions: 0, +} 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 2e1aa3c76..a12af492c 100644 --- a/packages/plugin-rsc/examples/use-cache-callable/src/root.tsx +++ b/packages/plugin-rsc/examples/use-cache-callable/src/root.tsx @@ -4,6 +4,10 @@ import { FileDirectiveFromServer } from './features/file-directive-from-server/s import { InlineDirectiveExtraArgumentsServer } from './features/inline-directive-extra-arguments/server' import { InlineDirective } from './features/inline-directive/server' import { ProtectedCaptures } from './features/protected-captures/server' +import { UseCacheInUseServerFromClientServer } from './features/use-cache-in-use-server-from-client/server' +import { UseCacheInUseServerFromServerServer } from './features/use-cache-in-use-server-from-server/server' +import { UseServerInUseCacheFromClientServer } from './features/use-server-in-use-cache-from-client/server' +import { UseServerInUseCacheFromServerServer } from './features/use-server-in-use-cache-from-server/server' const routes = [ { @@ -41,6 +45,34 @@ const routes = [ 'A zero-parameter inline cached function ignores FormData supplied by React.', Component: InlineDirectiveExtraArgumentsServer, }, + { + path: '/use-cache-in-use-server-from-client', + title: 'Use cache in use server from client', + description: + 'A Client Component imports an inline cached export from a server function module.', + Component: UseCacheInUseServerFromClientServer, + }, + { + path: '/use-cache-in-use-server-from-server', + title: 'Use cache in use server from server', + description: + 'A Server Component passes an inline cached export from a server function module to a Client Component.', + Component: UseCacheInUseServerFromServerServer, + }, + { + path: '/use-server-in-use-cache-from-client', + title: 'Use server in use cache from client', + description: + 'A Client Component imports an uncached inline server export from a cached function module.', + Component: UseServerInUseCacheFromClientServer, + }, + { + path: '/use-server-in-use-cache-from-server', + title: 'Use server in use cache from server', + description: + 'A Server Component passes an uncached inline server export from a cached function module to a Client Component.', + Component: UseServerInUseCacheFromServerServer, + }, { path: '/protected-captures', title: 'Protected captures', diff --git a/packages/plugin-rsc/src/plugins/server-reference.ts b/packages/plugin-rsc/src/plugins/server-reference.ts index e428246b4..486ff7d26 100644 --- a/packages/plugin-rsc/src/plugins/server-reference.ts +++ b/packages/plugin-rsc/src/plugins/server-reference.ts @@ -110,13 +110,14 @@ function aggregateClaims( ) } for (const name of claim.exportNames) { + // An inline "use cache" transform followed by a file-level "use server" + // transform can intentionally claim the same generated export. The + // identity checks above ensure they contribute to one manifest entry, so + // only the export name needs to be retained. const existingOwner = exportOwners.get(name) - if (existingOwner && existingOwner !== claimOwner) { - throw new Error( - `[vite-rsc] server reference '${claim.referenceKey}#${name}' is claimed by both '${existingOwner}' and '${claimOwner}'`, - ) + if (!existingOwner) { + exportOwners.set(name, claimOwner) } - exportOwners.set(name, claimOwner) } } assert(aggregate) diff --git a/packages/plugin-rsc/src/transforms/fixtures/mixed-directives/use-cache-in-use-server.js b/packages/plugin-rsc/src/transforms/fixtures/mixed-directives/use-cache-in-use-server.js new file mode 100644 index 000000000..1a375419c --- /dev/null +++ b/packages/plugin-rsc/src/transforms/fixtures/mixed-directives/use-cache-in-use-server.js @@ -0,0 +1,7 @@ +'use server' + +export async function defaultAction() {} + +export async function overrideAction() { + 'use cache' +} diff --git a/packages/plugin-rsc/src/transforms/fixtures/mixed-directives/use-cache-in-use-server.js.snap.md b/packages/plugin-rsc/src/transforms/fixtures/mixed-directives/use-cache-in-use-server.js.snap.md new file mode 100644 index 000000000..a092a970b --- /dev/null +++ b/packages/plugin-rsc/src/transforms/fixtures/mixed-directives/use-cache-in-use-server.js.snap.md @@ -0,0 +1,78 @@ +## Input + +```js +'use server' + +export async function defaultAction() {} + +export async function overrideAction() { + 'use cache' +} +``` + +## framework cache RSC transform + +**Status:** transformed + +**References:** $$hoist_0_overrideAction + +```js +'use server' + +import { cache as $cache, register as $registerCache } from "cache-runtime"; +export const $$hoist_0_overrideAction = /* #__PURE__ */ $registerCache($cache($$hoist_0_overrideAction$$impl), "$$hoist_0_overrideAction"); +export async function defaultAction() {} + +export const overrideAction = $$hoist_0_overrideAction; + +;async function $$hoist_0_overrideAction$$impl() { + 'use cache' +}; +/* #__PURE__ */ Object.defineProperty($$hoist_0_overrideAction$$impl, "name", { value: "overrideAction" }); +``` + +## final RSC transform + +**Status:** transformed + +**References:** $$hoist_0_overrideAction, defaultAction, overrideAction + +```js +'use server' + +import { cache as $cache, register as $registerCache } from "cache-runtime"; +const $$hoist_0_overrideAction = /* #__PURE__ */ $registerCache($cache($$hoist_0_overrideAction$$impl), "$$hoist_0_overrideAction"); +async function defaultAction() {} + +const overrideAction = $$hoist_0_overrideAction; + +;async function $$hoist_0_overrideAction$$impl() { + 'use cache' +}; +/* #__PURE__ */ Object.defineProperty($$hoist_0_overrideAction$$impl, "name", { value: "overrideAction" }); + +$registerServer($$hoist_0_overrideAction, "$$hoist_0_overrideAction"); +export { $$hoist_0_overrideAction }; + +$registerServer(defaultAction, "defaultAction"); +export { defaultAction }; + +$registerServer(overrideAction, "overrideAction"); +export { overrideAction }; +``` + +## browser and SSR proxy transform + +**Status:** transformed + +**References:** defaultAction, overrideAction + +```js + + +export const defaultAction = /* #__PURE__ */ $serverProxy("defaultAction"); + + +export const overrideAction = /* #__PURE__ */ $serverProxy("overrideAction"); + +``` diff --git a/packages/plugin-rsc/src/transforms/fixtures/mixed-directives/use-server-in-use-cache.js b/packages/plugin-rsc/src/transforms/fixtures/mixed-directives/use-server-in-use-cache.js new file mode 100644 index 000000000..b4303db27 --- /dev/null +++ b/packages/plugin-rsc/src/transforms/fixtures/mixed-directives/use-server-in-use-cache.js @@ -0,0 +1,7 @@ +'use cache' + +export async function defaultAction() {} + +export async function overrideAction() { + 'use server' +} diff --git a/packages/plugin-rsc/src/transforms/fixtures/mixed-directives/use-server-in-use-cache.js.snap.md b/packages/plugin-rsc/src/transforms/fixtures/mixed-directives/use-server-in-use-cache.js.snap.md new file mode 100644 index 000000000..359544053 --- /dev/null +++ b/packages/plugin-rsc/src/transforms/fixtures/mixed-directives/use-server-in-use-cache.js.snap.md @@ -0,0 +1,68 @@ +## Input + +```js +'use cache' + +export async function defaultAction() {} + +export async function overrideAction() { + 'use server' +} +``` + +## framework cache RSC transform + +**Status:** transformed + +**References:** defaultAction + +```js +'use cache' + +import { cache as $cache, register as $registerCache } from "cache-runtime"; +async function defaultAction() {} + +export async function overrideAction() { + 'use server' +} +defaultAction = /* #__PURE__ */ $registerCache($cache(defaultAction), "defaultAction"); +export { defaultAction }; +``` + +## final RSC transform + +**Status:** transformed + +**References:** $$hoist_0_overrideAction + +```js +'use cache' + +import { cache as $cache, register as $registerCache } from "cache-runtime"; +async function defaultAction() {} + +export const overrideAction = /* #__PURE__ */ $registerServer($$hoist_0_overrideAction, "$$hoist_0_overrideAction"); +defaultAction = /* #__PURE__ */ $registerCache($cache(defaultAction), "defaultAction"); +export { defaultAction }; + +;export async function $$hoist_0_overrideAction() { + 'use server' +}; +/* #__PURE__ */ Object.defineProperty($$hoist_0_overrideAction, "name", { value: "overrideAction" }); +``` + +## browser and SSR proxy transform + +**Status:** transformed + +**References:** defaultAction, overrideAction + +```js + + +export const defaultAction = /* #__PURE__ */ $cacheProxy("defaultAction"); + + +export const overrideAction = /* #__PURE__ */ $cacheProxy("overrideAction"); + +``` diff --git a/packages/plugin-rsc/src/transforms/mixed-directives.test.ts b/packages/plugin-rsc/src/transforms/mixed-directives.test.ts new file mode 100644 index 000000000..ba9bdf103 --- /dev/null +++ b/packages/plugin-rsc/src/transforms/mixed-directives.test.ts @@ -0,0 +1,132 @@ +import path from 'node:path' +import type MagicString from 'magic-string' +import { parseAstAsync } from 'vite' +import { expect, test } from 'vitest' +import { transformHoistInlineDirective } from './hoist' +import type { ModuleExportMeta } from './module-export-scan' +import { transformDirectiveProxyExport } from './proxy-export' +import { transformServerActionServer } from './server-action' +import { formatTransformMarkdownFixture } from './test-utils' +import { hasDirective } from './utils' +import { transformWrapExport } from './wrap-export' + +const fixtures = import.meta.glob( + ['./fixtures/mixed-directives/*.js', '!**/*.snap.*'], + { query: 'raw' }, +) + +for (const [file, load] of Object.entries(fixtures)) { + test(path.basename(file), async () => { + const input = ((await load()) as any).default as string + const cacheResult = await transformUseCache(input) + const rscResult = await transformUseServer(cacheResult.output.toString()) + const proxyResult = await transformMixedDirectiveProxy(input) + + await parseAstAsync(rscResult.output.toString()) + await parseAstAsync(proxyResult.output.toString()) + await expect( + formatTransformMarkdownFixture(input, [ + { + name: 'framework cache RSC transform', + output: cacheResult.output, + references: cacheResult.references, + }, + { + name: 'final RSC transform', + output: rscResult.output, + references: rscResult.references, + }, + { + name: 'browser and SSR proxy transform', + output: proxyResult.output, + references: proxyResult.references, + }, + ]), + ).toMatchFileSnapshot(file + '.snap.md') + }) +} + +type TransformResult = { + output: MagicString + references: string[] +} + +async function transformUseCache(input: string): Promise { + const ast = await parseAstAsync(input) + const result = hasDirective(ast.body, 'use cache') + ? transformWrapExport(input, ast, { + runtime: cacheRuntime, + filter: (_name, meta) => !hasFunctionDirective(meta, 'use server'), + }) + : transformHoistInlineDirective(input, ast, { + directive: 'use cache', + hoistRuntime: true, + runtime: cacheRuntime, + }) + + const importPosition = + ast.body.find((node) => !('directive' in node))?.start ?? input.length + result.output.prependLeft( + importPosition, + `import { cache as $cache, register as $registerCache } from "cache-runtime";\n`, + ) + return { + output: result.output, + references: 'names' in result ? result.names : result.exportNames, + } +} + +async function transformUseServer(input: string): Promise { + const ast = await parseAstAsync(input) + const result = transformServerActionServer(input, ast, { + runtime: (value, name) => + `$registerServer(${value}, ${JSON.stringify(name)})`, + }) + return { output: result.output, references: result.referenceNames } +} + +async function transformMixedDirectiveProxy( + input: string, +): Promise { + const ast = await parseAstAsync(input) + const result = + transformDirectiveProxyExport(ast, { + code: input, + directive: 'use cache', + runtime: (name) => `$cacheProxy(${JSON.stringify(name)})`, + }) ?? + transformDirectiveProxyExport(ast, { + code: input, + directive: 'use server', + runtime: (name) => `$serverProxy(${JSON.stringify(name)})`, + }) + if (!result) { + throw new Error('expected a file directive') + } + return { output: result.output, references: result.exportNames } +} + +function cacheRuntime(value: string, name: string): string { + return `$registerCache($cache(${value}), ${JSON.stringify(name)})` +} + +function hasFunctionDirective( + meta: Pick, + directive: string, +): boolean { + const node = meta.valueNode + if ( + (node?.type !== 'FunctionDeclaration' && + node?.type !== 'FunctionExpression' && + node?.type !== 'ArrowFunctionExpression') || + node.body.type !== 'BlockStatement' + ) { + return false + } + return node.body.body.some( + (statement) => + statement.type === 'ExpressionStatement' && + 'directive' in statement && + statement.directive === directive, + ) +}