Skip to content
Merged
61 changes: 61 additions & 0 deletions packages/plugin-rsc/e2e/use-cache-callable.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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
Expand Down
20 changes: 12 additions & 8 deletions packages/plugin-rsc/examples/use-cache-callable/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)})`
Expand All @@ -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,
Expand All @@ -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(),
Expand All @@ -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)})`,
})
Expand All @@ -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(),
Expand All @@ -122,6 +130,27 @@ export function callableCachePlugin(): Plugin {
}
}

function hasFunctionDirective(
meta: Pick<ModuleExportMeta, 'valueNode'>,
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<ModuleExportMeta, 'valueNode'>,
): CacheWrapperOptions {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
'use server'

import { state } from './state'

export async function defaultAction() {
state.defaultExecutions++
}

export async function overrideAction() {
'use cache'
state.overrideExecutions++
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
'use client'

import { defaultAction, overrideAction } from './actions'
import { resetAction } from './reset'

export function UseCacheInUseServerFromClient(props: {
defaultExecutions: number
overrideExecutions: number
}) {
return (
<div data-testid="use-cache-in-use-server-from-client">
<button onClick={() => defaultAction()}>Call default action</button>
<button onClick={() => overrideAction()}>Call override action</button>
<p>
Default executions:{' '}
<output data-testid="default-executions">
{props.defaultExecutions}
</output>
<br />
Override executions:{' '}
<output data-testid="override-executions">
{props.overrideExecutions}
</output>
</p>
<button onClick={() => resetAction()}>Reset</button>
</div>
)
}
Original file line number Diff line number Diff line change
@@ -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
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { UseCacheInUseServerFromClient } from './client'
import { state } from './state'

export function UseCacheInUseServerFromClientServer() {
return (
<UseCacheInUseServerFromClient
defaultExecutions={state.defaultExecutions}
overrideExecutions={state.overrideExecutions}
/>
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export const state = {
defaultExecutions: 0,
overrideExecutions: 0,
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
'use server'

import { state } from './state'

export async function defaultAction() {
state.defaultExecutions++
}

export async function overrideAction() {
'use cache'
state.overrideExecutions++
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
'use client'

type Action = () => Promise<void>

export function UseCacheInUseServerFromServer(props: {
defaultAction: Action
defaultExecutions: number
overrideAction: Action
overrideExecutions: number
resetAction: () => Promise<void>
}) {
return (
<div data-testid="use-cache-in-use-server-from-server">
<button onClick={() => props.defaultAction()}>Call default action</button>
<button onClick={() => props.overrideAction()}>
Call override action
</button>
<p>
Default executions:{' '}
<output data-testid="default-executions">
{props.defaultExecutions}
</output>
<br />
Override executions:{' '}
<output data-testid="override-executions">
{props.overrideExecutions}
</output>
</p>
<button onClick={() => props.resetAction()}>Reset</button>
</div>
)
}
Original file line number Diff line number Diff line change
@@ -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
}
Original file line number Diff line number Diff line change
@@ -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 (
<UseCacheInUseServerFromServer
defaultAction={defaultAction}
defaultExecutions={state.defaultExecutions}
overrideAction={overrideAction}
overrideExecutions={state.overrideExecutions}
resetAction={resetAction}
/>
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export const state = {
defaultExecutions: 0,
overrideExecutions: 0,
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
'use cache'

import { state } from './state'

export async function defaultAction() {
state.defaultExecutions++
}

export async function overrideAction() {
'use server'
state.overrideExecutions++
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
'use client'

import { defaultAction, overrideAction } from './actions'
import { resetAction } from './reset'

export function UseServerInUseCacheFromClient(props: {
defaultExecutions: number
overrideExecutions: number
}) {
return (
<div data-testid="use-server-in-use-cache-from-client">
<button onClick={() => defaultAction()}>Call default action</button>
<button onClick={() => overrideAction()}>Call override action</button>
<p>
Default executions:{' '}
<output data-testid="default-executions">
{props.defaultExecutions}
</output>
<br />
Override executions:{' '}
<output data-testid="override-executions">
{props.overrideExecutions}
</output>
</p>
<button onClick={() => resetAction()}>Reset</button>
</div>
)
}
Original file line number Diff line number Diff line change
@@ -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
}
Loading
Loading