镜像站点 · 本页由第三方 GitHub 只读镜像提供,非 GitHub 官方站点,不接受任何登录或凭据输入。前往 github.com
Skip to content
Merged
64 changes: 63 additions & 1 deletion packages/plugin-rsc/e2e/use-cache-callable.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,9 @@ function defineTests(f: Fixture) {
using _errors = expectNoPageError(page)
await page.goto(f.url())
await waitForHydration(page)
await page.getByRole('link', { name: 'Inline directive' }).click()
await page
.getByRole('link', { name: 'Inline directive', exact: true })
.click()
await expect(page).toHaveURL(f.url('/inline-directive'))

const example = page.getByTestId('inline-directive')
Expand Down Expand Up @@ -244,6 +246,66 @@ function defineTests(f: Fixture) {
await expect(result).toHaveText('client import + beta')
},
)

test('file directive extra arguments', async ({ page }) => {
using _errors = expectNoPageError(page)
await page.goto(f.url('/file-directive-extra-arguments'))
await waitForHydration(page)

const example = page.getByTestId('file-directive-extra-arguments')
const submissionCount = example.getByTestId('submission-count')
const executionCount = example.getByTestId('execution-count')
const result = example.getByTestId('result')
const argument = example.getByRole('textbox', { name: 'Ignored argument' })
await page.getByRole('button', { name: 'Reset' }).click()
await expect(submissionCount).toHaveText('0')
await expect(executionCount).toHaveText('0')
await expect(result).toHaveText('not called')

// React supplies FormData to the zero-parameter function, but it does not
// participate in the cache key or invocation.
await submit(page, example)
await expect(submissionCount).toHaveText('1')
await expect(executionCount).toHaveText('1')
await expect(result).toHaveText('arguments: 0')

await argument.fill('beta')
await submit(page, example)
await expect(submissionCount).toHaveText('2')
await expect(executionCount).toHaveText('1')
await expect(result).toHaveText('arguments: 0')
})

test('inline directive extra arguments', async ({ page }) => {
using _errors = expectNoPageError(page)
await page.goto(f.url('/inline-directive-extra-arguments'))
await waitForHydration(page)

const example = page.getByTestId('inline-directive-extra-arguments')
const submissionCount = example.getByTestId('submission-count')
const executionCount = example.getByTestId('execution-count')
const result = example.getByTestId('result')
const argument = example.getByRole('textbox', {
name: 'Undeclared argument',
})
await page.getByRole('button', { name: 'Reset' }).click()
await expect(submissionCount).toHaveText('0')
await expect(executionCount).toHaveText('0')
await expect(result).toHaveText('not called')

// Inline transform metadata does not yet expose the source parameters, so
// React's FormData still participates in the cache key and invocation.
await submit(page, example)
await expect(submissionCount).toHaveText('1')
await expect(executionCount).toHaveText('1')
await expect(result).toHaveText('arguments: 1')

await argument.fill('beta')
await submit(page, example)
await expect(submissionCount).toHaveText('2')
await expect(executionCount).toHaveText('2')
await expect(result).toHaveText('arguments: 1')
})
}

async function submit(page: Page, form: Locator) {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import { getPluginApi, type RscPluginManager } from '@vitejs/plugin-rsc'
import {
hasDirective,
type ModuleExportMeta,
transformDirectiveProxyExport,
transformHoistInlineDirective,
transformWrapExport,
} from '@vitejs/plugin-rsc/transforms'
import { parseAstAsync, type Plugin } from 'vite'
import type { CacheWrapperOptions } from './src/framework/use-cache-runtime'

const directive = 'use cache'
const pluginName = 'example:use-cache-callable'
Expand All @@ -29,14 +31,19 @@ export function callableCachePlugin(): Plugin {
const environmentName = this.environment.name

if (environmentName === 'rsc') {
const runtime = (value: string, name: string) =>
const runtime = (
value: string,
name: string,
options: CacheWrapperOptions,
) =>
`$$ReactServer.registerServerReference(` +
`$$cacheWrapper(${value}),` +
`$$cacheWrapper(${value}, ${JSON.stringify(options)}),` +
`${JSON.stringify(reference.referenceKey)},` +
`${JSON.stringify(name)})`
const result = hasDirective(ast.body, directive)
? transformWrapExport(code, ast, {
runtime,
runtime: (value, name, meta) =>
runtime(value, name, getCacheWrapperOptions(meta)),
// Next.js calls rejecting primitive literals while permitting
// objects and arrays arbitrary, but keeps the latter for metadata
// and viewport exports.
Expand All @@ -50,7 +57,7 @@ export function callableCachePlugin(): Plugin {
directive,
rejectNonAsyncFunction: true,
hoistRuntime: true,
runtime,
runtime: (value, name) => runtime(value, name, {}),
})
if (!result.output.hasChanged()) {
manager.serverReferences.deleteClaim(pluginName, id)
Expand Down Expand Up @@ -109,3 +116,17 @@ export function callableCachePlugin(): Plugin {
},
}
}

function getCacheWrapperOptions(meta: ModuleExportMeta): CacheWrapperOptions {
const node = meta.valueNode
if (
node?.type !== 'FunctionDeclaration' &&
node?.type !== 'FunctionExpression' &&
node?.type !== 'ArrowFunctionExpression'
) {
return {}
}
return node.params.at(-1)?.type === 'RestElement'
? {}
: { argumentCount: node.params.length }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
'use cache'

import { state } from './state'

export async function cachedWithoutArguments() {
state.executionCount++
state.result = `arguments: ${arguments.length}`
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
'use client'

import { useState } from 'react'
import { cachedWithoutArguments } from './action'

export function FileDirectiveExtraArguments(props: {
executionCount: number
resetAction: () => Promise<void>
result: string
}) {
const [submissions, setSubmissions] = useState(0)

return (
<>
<form
action={cachedWithoutArguments}
data-testid="file-directive-extra-arguments"
onSubmit={() => setSubmissions((value) => value + 1)}
>
<p>
<label>
Ignored argument: <input name="argument" defaultValue="alpha" />
</label>
</p>
<p>
<button>Call cached function</button>
</p>
<p>
<span>
Submission count:{' '}
<output data-testid="submission-count">{submissions}</output>
<br />
Execution count:{' '}
<output data-testid="execution-count">
{props.executionCount}
</output>
<br />
Result: <output data-testid="result">{props.result}</output>
</span>
</p>
</form>
<form action={props.resetAction}>
<button>Reset</button>
</form>
</>
)
}
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.executionCount = 0
state.result = 'not called'
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { FileDirectiveExtraArguments } from './client'
import { resetAction } from './reset'
import { state } from './state'

export function FileDirectiveExtraArgumentsServer() {
return (
<FileDirectiveExtraArguments
executionCount={state.executionCount}
resetAction={resetAction}
result={state.result}
/>
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export const state = {
executionCount: 0,
result: 'not called',
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
'use client'

import { useState } from 'react'

export function InlineDirectiveExtraArguments(props: {
action: () => Promise<void>
executionCount: number
resetAction: () => Promise<void>
result: string
}) {
const [submissions, setSubmissions] = useState(0)

return (
<>
<form
action={props.action}
data-testid="inline-directive-extra-arguments"
onSubmit={() => setSubmissions((value) => value + 1)}
>
<p>
<label>
Undeclared argument: <input name="argument" defaultValue="alpha" />
</label>
</p>
<p>
<button>Call cached function</button>
</p>
<p>
<span>
Submission count:{' '}
<output data-testid="submission-count">{submissions}</output>
<br />
Execution count:{' '}
<output data-testid="execution-count">
{props.executionCount}
</output>
<br />
Result: <output data-testid="result">{props.result}</output>
</span>
</p>
</form>
<form action={props.resetAction}>
<button>Reset</button>
</form>
</>
)
}
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.executionCount = 0
state.result = 'not called'
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { InlineDirectiveExtraArguments } from './client'
import { resetAction } from './reset'
import { state } from './state'

export function InlineDirectiveExtraArgumentsServer() {
async function cachedWithoutArguments() {
'use cache'
state.executionCount++
state.result = `arguments: ${arguments.length}`
}

return (
<InlineDirectiveExtraArguments
action={cachedWithoutArguments}
executionCount={state.executionCount}
resetAction={resetAction}
result={state.result}
/>
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export const state = {
executionCount: 0,
result: 'not called',
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,18 +13,33 @@ import {
// 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

export type CacheWrapperOptions = {
argumentCount?: number
}

const cachedFnMap = new WeakMap<Function, unknown>()
let cachedFnCacheEntries = new WeakMap<
Function,
Record<string, Promise<StreamCacher>>
>()

export default function cacheWrapper(fn: (...args: any[]) => Promise<unknown>) {
export default function cacheWrapper(
fn: (...args: any[]) => Promise<unknown>,
options: CacheWrapperOptions,
) {
if (cachedFnMap.has(fn)) {
return cachedFnMap.get(fn)!
}

async function cachedFn(...args: any[]): Promise<unknown> {
// Callers can supply more arguments than a cached function declares. For example,
// `useActionState(fn)` passes state and form data even to `function fn() {}`.
// Strip those extras so they affect neither the cache key nor execution.
// https://github.com/vercel/next.js/pull/72506
const admittedArgs =
options.argumentCount === undefined
? args
: args.slice(0, options.argumentCount)
let cacheEntries = cachedFnCacheEntries.get(cachedFn)
if (!cacheEntries) {
cacheEntries = {}
Expand All @@ -38,7 +53,7 @@ export default function cacheWrapper(fn: (...args: any[]) => Promise<unknown>) {
// "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, {
const encodedArguments = await encodeReply(admittedArgs, {
temporaryReferences: clientTemporaryReferences,
})
const serializedCacheKey = await replyToCacheKey(encodedArguments)
Expand Down
16 changes: 16 additions & 0 deletions packages/plugin-rsc/examples/use-cache-callable/src/root.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { FileDirectiveExtraArgumentsServer } from './features/file-directive-extra-arguments/server'
import { FileDirectiveFromClientServer } from './features/file-directive-from-client/server'
import { FileDirectiveFromServer } from './features/file-directive-from-server/server'
import { InlineDirectiveExtraArgumentsServer } from './features/inline-directive-extra-arguments/server'
import { InlineDirective } from './features/inline-directive/server'

const routes = [
Expand All @@ -24,6 +26,20 @@ const routes = [
'A client component imports a cached module export through its generated proxy.',
Component: FileDirectiveFromClientServer,
},
{
path: '/file-directive-extra-arguments',
title: 'File directive extra arguments',
description:
'A zero-parameter cached module export ignores FormData supplied by React.',
Component: FileDirectiveExtraArgumentsServer,
},
{
path: '/inline-directive-extra-arguments',
title: 'Inline directive extra arguments',
description:
'A zero-parameter inline cached function currently includes FormData supplied by React.',
Component: InlineDirectiveExtraArgumentsServer,
},
]

export function Root({ url }: { url: URL }) {
Expand Down
Loading