From 8732f56b786c8f9e0fb077ba6c3b9913ee318faf Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Date: Wed, 5 Aug 2026 09:26:34 +0900 Subject: [PATCH 01/22] feat(rsc): demonstrate encrypted cache captures Co-authored-by: OpenCode --- .../plugin-rsc/e2e/use-cache-callable.test.ts | 51 ++++++++++++++--- .../callable-cache-plugin.ts | 4 +- .../src/features/inline-directive/client.tsx | 14 +++++ .../src/features/inline-directive/reset.ts | 5 ++ .../src/features/inline-directive/server.tsx | 9 ++- .../src/features/inline-directive/state.ts | 1 + .../src/framework/use-cache-runtime.tsx | 57 ++++++++++++++++++- 7 files changed, 129 insertions(+), 12 deletions(-) diff --git a/packages/plugin-rsc/e2e/use-cache-callable.test.ts b/packages/plugin-rsc/e2e/use-cache-callable.test.ts index 5a2ae9be7..049105131 100644 --- a/packages/plugin-rsc/e2e/use-cache-callable.test.ts +++ b/packages/plugin-rsc/e2e/use-cache-callable.test.ts @@ -15,6 +15,10 @@ test.describe('build', () => { function defineTests(f: Fixture) { test('inline directive', async ({ page }) => { using _errors = expectNoPageError(page) + const rscResponse = await page.request.get(f.url('/inline-directive_.rsc')) + expect(rscResponse.ok()).toBe(true) + expect(await rscResponse.text()).not.toContain('capture-secret') + await page.goto(f.url()) await waitForHydration(page) await page.getByRole('link', { name: 'Inline directive' }).click() @@ -22,11 +26,13 @@ function defineTests(f: Fixture) { const example = page.getByTestId('inline-directive') const submissionCount = example.getByTestId('submission-count') + const capture = page.getByTestId('capture') 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(capture).toHaveText('first') await expect(executionCount).toHaveText('0') await expect(result).toHaveText('not called') @@ -35,20 +41,36 @@ function defineTests(f: Fixture) { await submit(page, example) await expect(submissionCount).toHaveText('1') await expect(executionCount).toHaveText('1') - await expect(result).toHaveText('captured + alpha') + await expect(result).toHaveText('first + alpha') // alpha (cache hit) await submit(page, example) await expect(submissionCount).toHaveText('2') await expect(executionCount).toHaveText('1') - await expect(result).toHaveText('captured + alpha') + await expect(result).toHaveText('first + 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') + await expect(result).toHaveText('first + beta') + + // The same invocation with a different decoded capture is a cache miss. + await argument.fill('alpha') + await selectCapture(page, 'Second capture') + await expect(capture).toHaveText('second') + await submit(page, example) + await expect(submissionCount).toHaveText('4') + await expect(executionCount).toHaveText('3') + await expect(result).toHaveText('second + alpha') + + // Re-encrypting the first logical capture still addresses its existing entry. + await selectCapture(page, 'First capture') + await expect(capture).toHaveText('first') + await submit(page, example) + await expect(submissionCount).toHaveText('5') + await expect(executionCount).toHaveText('3') }) testNoJs('inline directive progressive enhancement', async ({ page }) => { @@ -72,21 +94,21 @@ function defineTests(f: Fixture) { await call.click() await expect(submissionCount).toHaveText('0') await expect(executionCount).toHaveText('1') - await expect(result).toHaveText('captured + alpha') + await expect(result).toHaveText('first + 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') + await expect(result).toHaveText('first + 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') + await expect(result).toHaveText('first + beta') }) test('file directive from server', async ({ page }) => { @@ -251,7 +273,7 @@ async function submit(page: Page, form: Locator) { // 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([ + const [response] = await Promise.all([ page.waitForResponse( (response) => response.request().method() === 'POST' && @@ -259,4 +281,19 @@ async function submit(page: Page, form: Locator) { ), form.getByRole('button', { name: 'Call cached function' }).click(), ]) + expect(response.ok()).toBe(true) + expect(await response.text()).not.toContain('capture-secret') +} + +async function selectCapture(page: Page, name: string) { + const [response] = await Promise.all([ + page.waitForResponse( + (response) => + response.request().method() === 'POST' && + response.url().includes('_.rsc'), + ), + page.getByRole('button', { name }).click(), + ]) + expect(response.ok()).toBe(true) + expect(await response.text()).not.toContain('capture-secret') } 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 a9f4728f7..4f8e011b6 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 @@ -51,6 +51,8 @@ export function callableCachePlugin(): Plugin { rejectNonAsyncFunction: true, hoistRuntime: true, runtime, + encode: (value) => `$$encodeCacheCaptures(${value})`, + decode: (value) => `await $$decodeCacheCaptures(${value})`, }) if (!result.output.hasChanged()) { manager.serverReferences.deleteClaim(pluginName, id) @@ -62,7 +64,7 @@ export function callableCachePlugin(): Plugin { exportNames: 'names' in result ? result.names : result.exportNames, }) result.output.prepend( - `import $$cacheWrapper from "/src/framework/use-cache-runtime";\n` + + `import $$cacheWrapper, { encodeCacheCaptures as $$encodeCacheCaptures, decodeCacheCaptures as $$decodeCacheCaptures } from "/src/framework/use-cache-runtime";\n` + `import * as $$ReactServer from "@vitejs/plugin-rsc/react/rsc/server";\n`, ) return { diff --git a/packages/plugin-rsc/examples/use-cache-callable/src/features/inline-directive/client.tsx b/packages/plugin-rsc/examples/use-cache-callable/src/features/inline-directive/client.tsx index 88b819e5a..7949ba441 100644 --- a/packages/plugin-rsc/examples/use-cache-callable/src/features/inline-directive/client.tsx +++ b/packages/plugin-rsc/examples/use-cache-callable/src/features/inline-directive/client.tsx @@ -4,14 +4,28 @@ import { useState } from 'react' export function InlineDirectiveClient(props: { action: (formData: FormData) => Promise + capture: string executionCount: number resetAction: () => Promise result: string + selectCaptureAction: (formData: FormData) => Promise }) { const [submissions, setSubmissions] = useState(0) return ( <> +
+ {' '} + +

+ Selected capture:{' '} + {props.capture} +

+
) } 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 index 3b013aeb4..5d444850e 100644 --- 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 @@ -1,4 +1,5 @@ export const state = { + capture: 'first', executionCount: 0, result: 'not called', } diff --git a/packages/plugin-rsc/examples/use-cache-callable/src/framework/use-cache-runtime.tsx b/packages/plugin-rsc/examples/use-cache-callable/src/framework/use-cache-runtime.tsx index b12bcf5a9..01356d762 100644 --- a/packages/plugin-rsc/examples/use-cache-callable/src/framework/use-cache-runtime.tsx +++ b/packages/plugin-rsc/examples/use-cache-callable/src/framework/use-cache-runtime.tsx @@ -8,12 +8,17 @@ import { decodeReply, renderToReadableStream, } from '@vitejs/plugin-rsc/rsc/server' +import { + decryptActionBoundArgs, + encryptActionBoundArgs, +} from '@vitejs/plugin-rsc/utils/encryption-runtime' // based on // https://github.com/vercel/next.js/pull/70435 // https://github.com/vercel/next.js/blob/09a2167b0a970757606b7f91ff2d470f77f13f8c/packages/next/src/server/use-cache/use-cache-wrapper.ts const cachedFnMap = new WeakMap() +const cacheCaptureType = 'use-cache-captures' let cachedFnCacheEntries = new WeakMap< Function, Record> @@ -41,7 +46,21 @@ export default function cacheWrapper(fn: (...args: any[]) => Promise) { const encodedArguments = await encodeReply(args, { temporaryReferences: clientTemporaryReferences, }) - const serializedCacheKey = await replyToCacheKey(encodedArguments) + const firstArgument = await args[0] + const cacheArguments = isCacheCaptureEnvelope(firstArgument) + ? [ + cacheCaptureType, + ...(await decodeCacheCaptures(firstArgument)), + ...args.slice(1), + ] + : args + const encodedCacheArguments = + cacheArguments === args + ? encodedArguments + : await encodeReply(cacheArguments, { + temporaryReferences: createClientTemporaryReferenceSet(), + }) + const serializedCacheKey = await replyToCacheKey(encodedCacheArguments) // cache `fn` result as stream // (cache value is promise so that it dedupes concurrent async calls) @@ -77,6 +96,42 @@ export default function cacheWrapper(fn: (...args: any[]) => Promise) { return cachedFn } +type CacheCaptureEnvelope = { + type: typeof cacheCaptureType + encrypted: string +} + +export async function encodeCacheCaptures( + captures: unknown[], +): Promise { + return { + type: cacheCaptureType, + encrypted: await encryptActionBoundArgs(captures), + } +} + +export async function decodeCacheCaptures( + envelope: CacheCaptureEnvelope | Promise, +): Promise { + const { encrypted } = await envelope + const captures = await decryptActionBoundArgs(Promise.resolve(encrypted)) + if (!Array.isArray(captures)) { + throw new Error('Invalid cache capture payload') + } + return captures +} + +function isCacheCaptureEnvelope(value: unknown): value is CacheCaptureEnvelope { + return ( + typeof value === 'object' && + value !== null && + 'type' in value && + value.type === cacheCaptureType && + 'encrypted' in value && + typeof value.encrypted === 'string' + ) +} + export function revalidateCache(cachedFn: Function) { cachedFnCacheEntries.delete(cachedFn) } From b42a63cdafccc696a16adf37d0ec0a31e13d52e0 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Date: Wed, 5 Aug 2026 12:47:19 +0900 Subject: [PATCH 02/22] refactor(rsc): split protected cache captures example Co-authored-by: OpenCode --- .../plugin-rsc/e2e/use-cache-callable.test.ts | 92 +++++++++++++------ .../src/features/inline-directive/client.tsx | 14 --- .../src/features/inline-directive/reset.ts | 5 - .../src/features/inline-directive/server.tsx | 9 +- .../src/features/inline-directive/state.ts | 1 - .../features/protected-captures/client.tsx | 61 ++++++++++++ .../src/features/protected-captures/reset.ts | 15 +++ .../features/protected-captures/server.tsx | 26 ++++++ .../src/features/protected-captures/state.ts | 5 + .../examples/use-cache-callable/src/root.tsx | 8 ++ 10 files changed, 184 insertions(+), 52 deletions(-) create mode 100644 packages/plugin-rsc/examples/use-cache-callable/src/features/protected-captures/client.tsx create mode 100644 packages/plugin-rsc/examples/use-cache-callable/src/features/protected-captures/reset.ts create mode 100644 packages/plugin-rsc/examples/use-cache-callable/src/features/protected-captures/server.tsx create mode 100644 packages/plugin-rsc/examples/use-cache-callable/src/features/protected-captures/state.ts diff --git a/packages/plugin-rsc/e2e/use-cache-callable.test.ts b/packages/plugin-rsc/e2e/use-cache-callable.test.ts index 049105131..40abab9fa 100644 --- a/packages/plugin-rsc/e2e/use-cache-callable.test.ts +++ b/packages/plugin-rsc/e2e/use-cache-callable.test.ts @@ -1,4 +1,10 @@ -import { expect, test, type Locator, type Page } from '@playwright/test' +import { + expect, + test, + type Locator, + type Page, + type Response, +} from '@playwright/test' import { type Fixture, useFixture } from './fixture' import { expectNoPageError, testNoJs, waitForHydration } from './helper' @@ -15,10 +21,6 @@ test.describe('build', () => { function defineTests(f: Fixture) { test('inline directive', async ({ page }) => { using _errors = expectNoPageError(page) - const rscResponse = await page.request.get(f.url('/inline-directive_.rsc')) - expect(rscResponse.ok()).toBe(true) - expect(await rscResponse.text()).not.toContain('capture-secret') - await page.goto(f.url()) await waitForHydration(page) await page.getByRole('link', { name: 'Inline directive' }).click() @@ -26,13 +28,11 @@ function defineTests(f: Fixture) { const example = page.getByTestId('inline-directive') const submissionCount = example.getByTestId('submission-count') - const capture = page.getByTestId('capture') 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(capture).toHaveText('first') await expect(executionCount).toHaveText('0') await expect(result).toHaveText('not called') @@ -41,36 +41,68 @@ function defineTests(f: Fixture) { await submit(page, example) await expect(submissionCount).toHaveText('1') await expect(executionCount).toHaveText('1') - await expect(result).toHaveText('first + alpha') + 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('first + alpha') + 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('first + beta') + await expect(result).toHaveText('captured + beta') + }) + + test('protected captures', async ({ page }) => { + using _errors = expectNoPageError(page) + const rscResponse = await page.request.get( + f.url('/protected-captures_.rsc'), + ) + await expectProtectedResponse(rscResponse) + + await page.goto(f.url()) + await waitForHydration(page) + await page.getByRole('link', { name: 'Protected captures' }).click() + await expect(page).toHaveURL(f.url('/protected-captures')) + + const example = page.getByTestId('protected-captures') + const submissionCount = example.getByTestId('submission-count') + const capture = page.getByTestId('capture') + const executionCount = example.getByTestId('execution-count') + const result = example.getByTestId('result') + await page.getByRole('button', { name: 'Reset' }).click() + await expect(submissionCount).toHaveText('0') + await expect(capture).toHaveText('first') + await expect(executionCount).toHaveText('0') + await expect(result).toHaveText('not called') + + await expectProtectedResponse(await submit(page, example)) + await expect(submissionCount).toHaveText('1') + await expect(executionCount).toHaveText('1') + await expect(result).toHaveText('first + alpha') + + // A fresh ciphertext for the same logical capture still hits. + await expectProtectedResponse(await submit(page, example)) + await expect(submissionCount).toHaveText('2') + await expect(executionCount).toHaveText('1') // The same invocation with a different decoded capture is a cache miss. - await argument.fill('alpha') - await selectCapture(page, 'Second capture') + await expectProtectedResponse(await selectCapture(page, 'Second capture')) await expect(capture).toHaveText('second') - await submit(page, example) - await expect(submissionCount).toHaveText('4') - await expect(executionCount).toHaveText('3') + await expectProtectedResponse(await submit(page, example)) + await expect(submissionCount).toHaveText('3') + await expect(executionCount).toHaveText('2') await expect(result).toHaveText('second + alpha') - // Re-encrypting the first logical capture still addresses its existing entry. - await selectCapture(page, 'First capture') + await expectProtectedResponse(await selectCapture(page, 'First capture')) await expect(capture).toHaveText('first') - await submit(page, example) - await expect(submissionCount).toHaveText('5') - await expect(executionCount).toHaveText('3') + await expectProtectedResponse(await submit(page, example)) + await expect(submissionCount).toHaveText('4') + await expect(executionCount).toHaveText('2') }) testNoJs('inline directive progressive enhancement', async ({ page }) => { @@ -94,21 +126,21 @@ function defineTests(f: Fixture) { await call.click() await expect(submissionCount).toHaveText('0') await expect(executionCount).toHaveText('1') - await expect(result).toHaveText('first + alpha') + 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('first + alpha') + 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('first + beta') + await expect(result).toHaveText('captured + beta') }) test('file directive from server', async ({ page }) => { @@ -268,7 +300,7 @@ function defineTests(f: Fixture) { ) } -async function submit(page: Page, form: Locator) { +async function submit(page: Page, form: Locator): Promise { // `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 @@ -282,10 +314,10 @@ async function submit(page: Page, form: Locator) { form.getByRole('button', { name: 'Call cached function' }).click(), ]) expect(response.ok()).toBe(true) - expect(await response.text()).not.toContain('capture-secret') + return response } -async function selectCapture(page: Page, name: string) { +async function selectCapture(page: Page, name: string): Promise { const [response] = await Promise.all([ page.waitForResponse( (response) => @@ -294,6 +326,14 @@ async function selectCapture(page: Page, name: string) { ), page.getByRole('button', { name }).click(), ]) + expect(response.ok()).toBe(true) + return response +} + +async function expectProtectedResponse(response: { + ok(): boolean + text(): Promise +}) { expect(response.ok()).toBe(true) expect(await response.text()).not.toContain('capture-secret') } diff --git a/packages/plugin-rsc/examples/use-cache-callable/src/features/inline-directive/client.tsx b/packages/plugin-rsc/examples/use-cache-callable/src/features/inline-directive/client.tsx index 7949ba441..88b819e5a 100644 --- a/packages/plugin-rsc/examples/use-cache-callable/src/features/inline-directive/client.tsx +++ b/packages/plugin-rsc/examples/use-cache-callable/src/features/inline-directive/client.tsx @@ -4,28 +4,14 @@ import { useState } from 'react' export function InlineDirectiveClient(props: { action: (formData: FormData) => Promise - capture: string executionCount: number resetAction: () => Promise result: string - selectCaptureAction: (formData: FormData) => Promise }) { const [submissions, setSubmissions] = useState(0) return ( <> - - {' '} - -

- Selected capture:{' '} - {props.capture} -

-
) } 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 index 5d444850e..3b013aeb4 100644 --- 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 @@ -1,5 +1,4 @@ export const state = { - capture: 'first', executionCount: 0, result: 'not called', } diff --git a/packages/plugin-rsc/examples/use-cache-callable/src/features/protected-captures/client.tsx b/packages/plugin-rsc/examples/use-cache-callable/src/features/protected-captures/client.tsx new file mode 100644 index 000000000..e8ce4f140 --- /dev/null +++ b/packages/plugin-rsc/examples/use-cache-callable/src/features/protected-captures/client.tsx @@ -0,0 +1,61 @@ +'use client' + +import { useState } from 'react' + +export function ProtectedCapturesClient(props: { + action: (formData: FormData) => Promise + capture: string + executionCount: number + resetAction: () => Promise + result: string + selectCaptureAction: (formData: FormData) => Promise +}) { + const [submissions, setSubmissions] = useState(0) + + return ( + <> + + {' '} + +

+ Selected capture:{' '} + {props.capture} +

+ +
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/protected-captures/reset.ts b/packages/plugin-rsc/examples/use-cache-callable/src/features/protected-captures/reset.ts new file mode 100644 index 000000000..3051e7032 --- /dev/null +++ b/packages/plugin-rsc/examples/use-cache-callable/src/features/protected-captures/reset.ts @@ -0,0 +1,15 @@ +'use server' + +import { resetCache } from '../../framework/use-cache-runtime' +import { state } from './state' + +export async function resetAction() { + resetCache() + state.capture = 'first' + state.executionCount = 0 + state.result = 'not called' +} + +export async function selectCaptureAction(formData: FormData) { + state.capture = String(formData.get('capture')) +} diff --git a/packages/plugin-rsc/examples/use-cache-callable/src/features/protected-captures/server.tsx b/packages/plugin-rsc/examples/use-cache-callable/src/features/protected-captures/server.tsx new file mode 100644 index 000000000..b1a97f2ab --- /dev/null +++ b/packages/plugin-rsc/examples/use-cache-callable/src/features/protected-captures/server.tsx @@ -0,0 +1,26 @@ +import { ProtectedCapturesClient } from './client' +import { resetAction, selectCaptureAction } from './reset' +import { state } from './state' + +export function ProtectedCaptures() { + const captured = + state.capture === 'first' ? 'capture-secret-one' : 'capture-secret-two' + + async function cachedAction(formData: FormData) { + 'use cache' + const argument = String(formData.get('argument')) + state.executionCount++ + state.result = `${captured.endsWith('one') ? 'first' : 'second'} + ${argument}` + } + + return ( + + ) +} diff --git a/packages/plugin-rsc/examples/use-cache-callable/src/features/protected-captures/state.ts b/packages/plugin-rsc/examples/use-cache-callable/src/features/protected-captures/state.ts new file mode 100644 index 000000000..5d444850e --- /dev/null +++ b/packages/plugin-rsc/examples/use-cache-callable/src/features/protected-captures/state.ts @@ -0,0 +1,5 @@ +export const state = { + capture: 'first', + executionCount: 0, + result: 'not called', +} 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 126ecc1d3..8c57a0e74 100644 --- a/packages/plugin-rsc/examples/use-cache-callable/src/root.tsx +++ b/packages/plugin-rsc/examples/use-cache-callable/src/root.tsx @@ -1,6 +1,7 @@ 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' +import { ProtectedCaptures } from './features/protected-captures/server' const routes = [ { @@ -10,6 +11,13 @@ const routes = [ 'This Server Component defines an inline cached function, captures a value, and passes the function to the client form.', Component: InlineDirective, }, + { + path: '/protected-captures', + title: 'Protected captures', + description: + 'This inline cached function transports encrypted captures while using their decoded values for cache identity.', + Component: ProtectedCaptures, + }, { path: '/file-directive-from-server', title: 'File directive from server', From cfcc4d20d35be63425280a67e1e7c1d55f54ab84 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Date: Wed, 5 Aug 2026 14:49:17 +0900 Subject: [PATCH 03/22] chore(rsc): order protected captures route last Co-authored-by: OpenCode --- .../examples/use-cache-callable/src/root.tsx | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) 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 dfa31b2f0..753f3ca7b 100644 --- a/packages/plugin-rsc/examples/use-cache-callable/src/root.tsx +++ b/packages/plugin-rsc/examples/use-cache-callable/src/root.tsx @@ -13,13 +13,6 @@ const routes = [ 'This Server Component defines an inline cached function, captures a value, and passes the function to the client form.', Component: InlineDirective, }, - { - path: '/protected-captures', - title: 'Protected captures', - description: - 'This inline cached function transports encrypted captures while using their decoded values for cache identity.', - Component: ProtectedCaptures, - }, { path: '/file-directive-from-server', title: 'File directive from server', @@ -48,6 +41,13 @@ const routes = [ 'A zero-parameter inline cached function currently includes FormData supplied by React.', Component: InlineDirectiveExtraArgumentsServer, }, + { + path: '/protected-captures', + title: 'Protected captures', + description: + 'This inline cached function transports encrypted captures while using their decoded values for cache identity.', + Component: ProtectedCaptures, + }, ] export function Root({ url }: { url: URL }) { From 38d2d8174e2bf4c3b7624d2331fe2711599abd5c Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Date: Wed, 5 Aug 2026 15:02:14 +0900 Subject: [PATCH 04/22] refactor(rsc): render capture selector on server Co-authored-by: OpenCode --- .../features/protected-captures/client.tsx | 14 ---------- .../features/protected-captures/server.tsx | 28 +++++++++++++------ 2 files changed, 20 insertions(+), 22 deletions(-) diff --git a/packages/plugin-rsc/examples/use-cache-callable/src/features/protected-captures/client.tsx b/packages/plugin-rsc/examples/use-cache-callable/src/features/protected-captures/client.tsx index e8ce4f140..bab95fecd 100644 --- a/packages/plugin-rsc/examples/use-cache-callable/src/features/protected-captures/client.tsx +++ b/packages/plugin-rsc/examples/use-cache-callable/src/features/protected-captures/client.tsx @@ -4,28 +4,14 @@ import { useState } from 'react' export function ProtectedCapturesClient(props: { action: (formData: FormData) => Promise - capture: string executionCount: number resetAction: () => Promise result: string - selectCaptureAction: (formData: FormData) => Promise }) { const [submissions, setSubmissions] = useState(0) return ( <> -
- {' '} - -

- Selected capture:{' '} - {props.capture} -

-
+ <> + + {' '} + +

+ Selected capture:{' '} + {state.capture} +

+
+ + ) } From f572f1a9c1e7fcb08bbb2fc346567577910886be Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Date: Wed, 5 Aug 2026 15:17:25 +0900 Subject: [PATCH 05/22] test(rsc): verify protected captures across reload Co-authored-by: OpenCode --- .../plugin-rsc/e2e/use-cache-callable.test.ts | 61 ++++++------------- .../features/protected-captures/client.tsx | 4 +- .../features/protected-captures/server.tsx | 3 +- 3 files changed, 22 insertions(+), 46 deletions(-) diff --git a/packages/plugin-rsc/e2e/use-cache-callable.test.ts b/packages/plugin-rsc/e2e/use-cache-callable.test.ts index b7f31b225..2023a7575 100644 --- a/packages/plugin-rsc/e2e/use-cache-callable.test.ts +++ b/packages/plugin-rsc/e2e/use-cache-callable.test.ts @@ -1,10 +1,4 @@ -import { - expect, - test, - type Locator, - type Page, - type Response, -} from '@playwright/test' +import { expect, test, type Locator, type Page } from '@playwright/test' import { type Fixture, useFixture } from './fixture' import { expectNoPageError, testNoJs, waitForHydration } from './helper' @@ -60,12 +54,14 @@ function defineTests(f: Fixture) { }) test('protected captures', async ({ page }) => { - using _errors = expectNoPageError(page) + // verify captured value is encoded and thus doesn't appear in raw response const rscResponse = await page.request.get( f.url('/protected-captures_.rsc'), ) - await expectProtectedResponse(rscResponse) + expect(rscResponse.ok()).toBe(true) + expect(await rscResponse.text()).not.toContain('capture-secret') + using _errors = expectNoPageError(page) await page.goto(f.url()) await waitForHydration(page) await page.getByRole('link', { name: 'Protected captures' }).click() @@ -82,28 +78,31 @@ function defineTests(f: Fixture) { await expect(executionCount).toHaveText('0') await expect(result).toHaveText('not called') - await expectProtectedResponse(await submit(page, example)) + // submit with "first" capture and "alpha" argument + await submit(page, example) await expect(submissionCount).toHaveText('1') await expect(executionCount).toHaveText('1') await expect(result).toHaveText('first + alpha') - // A fresh ciphertext for the same logical capture still hits. - await expectProtectedResponse(await submit(page, example)) - await expect(submissionCount).toHaveText('2') + // A fresh render and ciphertext for the same logical capture still hits. + await page.reload() + await waitForHydration(page) + await submit(page, example) + await expect(submissionCount).toHaveText('1') await expect(executionCount).toHaveText('1') // The same invocation with a different decoded capture is a cache miss. - await expectProtectedResponse(await selectCapture(page, 'Second capture')) + await page.getByRole('button', { name: 'Second capture' }).click() await expect(capture).toHaveText('second') - await expectProtectedResponse(await submit(page, example)) - await expect(submissionCount).toHaveText('3') + await submit(page, example) + await expect(submissionCount).toHaveText('2') await expect(executionCount).toHaveText('2') await expect(result).toHaveText('second + alpha') - await expectProtectedResponse(await selectCapture(page, 'First capture')) + await page.getByRole('button', { name: 'First capture' }).click() await expect(capture).toHaveText('first') - await expectProtectedResponse(await submit(page, example)) - await expect(submissionCount).toHaveText('4') + await submit(page, example) + await expect(submissionCount).toHaveText('3') await expect(executionCount).toHaveText('2') }) @@ -362,7 +361,7 @@ function defineTests(f: Fixture) { }) } -async function submit(page: Page, form: Locator): Promise { +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 @@ -376,26 +375,4 @@ async function submit(page: Page, form: Locator): Promise { form.getByRole('button', { name: 'Call cached function' }).click(), ]) expect(response.ok()).toBe(true) - return response -} - -async function selectCapture(page: Page, name: string): Promise { - const [response] = await Promise.all([ - page.waitForResponse( - (response) => - response.request().method() === 'POST' && - response.url().includes('_.rsc'), - ), - page.getByRole('button', { name }).click(), - ]) - expect(response.ok()).toBe(true) - return response -} - -async function expectProtectedResponse(response: { - ok(): boolean - text(): Promise -}) { - expect(response.ok()).toBe(true) - expect(await response.text()).not.toContain('capture-secret') } diff --git a/packages/plugin-rsc/examples/use-cache-callable/src/features/protected-captures/client.tsx b/packages/plugin-rsc/examples/use-cache-callable/src/features/protected-captures/client.tsx index bab95fecd..7b9b2c24c 100644 --- a/packages/plugin-rsc/examples/use-cache-callable/src/features/protected-captures/client.tsx +++ b/packages/plugin-rsc/examples/use-cache-callable/src/features/protected-captures/client.tsx @@ -3,7 +3,7 @@ import { useState } from 'react' export function ProtectedCapturesClient(props: { - action: (formData: FormData) => Promise + action: (argument: string) => Promise executionCount: number resetAction: () => Promise result: string @@ -13,7 +13,7 @@ export function ProtectedCapturesClient(props: { return ( <>
props.action(String(formData.get('argument')))} data-testid="protected-captures" onSubmit={() => setSubmissions((value) => value + 1)} > diff --git a/packages/plugin-rsc/examples/use-cache-callable/src/features/protected-captures/server.tsx b/packages/plugin-rsc/examples/use-cache-callable/src/features/protected-captures/server.tsx index 3ac54e91b..11c1dc90c 100644 --- a/packages/plugin-rsc/examples/use-cache-callable/src/features/protected-captures/server.tsx +++ b/packages/plugin-rsc/examples/use-cache-callable/src/features/protected-captures/server.tsx @@ -6,9 +6,8 @@ export function ProtectedCaptures() { const captured = state.capture === 'first' ? 'capture-secret-one' : 'capture-secret-two' - async function cachedAction(formData: FormData) { + async function cachedAction(argument: string) { 'use cache' - const argument = String(formData.get('argument')) state.executionCount++ state.result = `${captured.endsWith('one') ? 'first' : 'second'} + ${argument}` } From c152ca90913944b72294b4c63b60a743fd35181f Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Date: Wed, 5 Aug 2026 15:17:56 +0900 Subject: [PATCH 06/22] nit --- packages/plugin-rsc/e2e/use-cache-callable.test.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/plugin-rsc/e2e/use-cache-callable.test.ts b/packages/plugin-rsc/e2e/use-cache-callable.test.ts index 2023a7575..c0d645e1d 100644 --- a/packages/plugin-rsc/e2e/use-cache-callable.test.ts +++ b/packages/plugin-rsc/e2e/use-cache-callable.test.ts @@ -90,6 +90,7 @@ function defineTests(f: Fixture) { await submit(page, example) await expect(submissionCount).toHaveText('1') await expect(executionCount).toHaveText('1') + await expect(result).toHaveText('first + alpha') // The same invocation with a different decoded capture is a cache miss. await page.getByRole('button', { name: 'Second capture' }).click() @@ -104,6 +105,7 @@ function defineTests(f: Fixture) { await submit(page, example) await expect(submissionCount).toHaveText('3') await expect(executionCount).toHaveText('2') + await expect(result).toHaveText('first + alpha') }) testNoJs('inline directive progressive enhancement', async ({ page }) => { From 7612a5910523449bf8c5f26c15f2cfb567fd6a9f Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Date: Wed, 5 Aug 2026 15:33:40 +0900 Subject: [PATCH 07/22] docs(rsc): explain protected capture argument adapter Co-authored-by: OpenCode --- .../src/features/protected-captures/client.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/plugin-rsc/examples/use-cache-callable/src/features/protected-captures/client.tsx b/packages/plugin-rsc/examples/use-cache-callable/src/features/protected-captures/client.tsx index 7b9b2c24c..8d2e1f7b9 100644 --- a/packages/plugin-rsc/examples/use-cache-callable/src/features/protected-captures/client.tsx +++ b/packages/plugin-rsc/examples/use-cache-callable/src/features/protected-captures/client.tsx @@ -13,7 +13,11 @@ export function ProtectedCapturesClient(props: { return ( <> props.action(String(formData.get('argument')))} + action={(formData) => { + // SSR forms also contain React transport fields such as `$ACTION_REF_0`. + // Pass only user input so fresh encrypted metadata cannot alter the cache key. + return props.action(String(formData.get('argument'))) + }} data-testid="protected-captures" onSubmit={() => setSubmissions((value) => value + 1)} > From 77d4c32cbbf07d6f24e90476f431c636579a899a Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Date: Wed, 5 Aug 2026 15:34:50 +0900 Subject: [PATCH 08/22] nit --- .../plugin-rsc/e2e/use-cache-callable.test.ts | 110 +++++++++--------- 1 file changed, 55 insertions(+), 55 deletions(-) diff --git a/packages/plugin-rsc/e2e/use-cache-callable.test.ts b/packages/plugin-rsc/e2e/use-cache-callable.test.ts index c0d645e1d..c885e86c1 100644 --- a/packages/plugin-rsc/e2e/use-cache-callable.test.ts +++ b/packages/plugin-rsc/e2e/use-cache-callable.test.ts @@ -53,61 +53,6 @@ function defineTests(f: Fixture) { await expect(result).toHaveText('captured + beta') }) - test('protected captures', async ({ page }) => { - // verify captured value is encoded and thus doesn't appear in raw response - const rscResponse = await page.request.get( - f.url('/protected-captures_.rsc'), - ) - expect(rscResponse.ok()).toBe(true) - expect(await rscResponse.text()).not.toContain('capture-secret') - - using _errors = expectNoPageError(page) - await page.goto(f.url()) - await waitForHydration(page) - await page.getByRole('link', { name: 'Protected captures' }).click() - await expect(page).toHaveURL(f.url('/protected-captures')) - - const example = page.getByTestId('protected-captures') - const submissionCount = example.getByTestId('submission-count') - const capture = page.getByTestId('capture') - const executionCount = example.getByTestId('execution-count') - const result = example.getByTestId('result') - await page.getByRole('button', { name: 'Reset' }).click() - await expect(submissionCount).toHaveText('0') - await expect(capture).toHaveText('first') - await expect(executionCount).toHaveText('0') - await expect(result).toHaveText('not called') - - // submit with "first" capture and "alpha" argument - await submit(page, example) - await expect(submissionCount).toHaveText('1') - await expect(executionCount).toHaveText('1') - await expect(result).toHaveText('first + alpha') - - // A fresh render and ciphertext for the same logical capture still hits. - await page.reload() - await waitForHydration(page) - await submit(page, example) - await expect(submissionCount).toHaveText('1') - await expect(executionCount).toHaveText('1') - await expect(result).toHaveText('first + alpha') - - // The same invocation with a different decoded capture is a cache miss. - await page.getByRole('button', { name: 'Second capture' }).click() - await expect(capture).toHaveText('second') - await submit(page, example) - await expect(submissionCount).toHaveText('2') - await expect(executionCount).toHaveText('2') - await expect(result).toHaveText('second + alpha') - - await page.getByRole('button', { name: 'First capture' }).click() - await expect(capture).toHaveText('first') - await submit(page, example) - await expect(submissionCount).toHaveText('3') - await expect(executionCount).toHaveText('2') - await expect(result).toHaveText('first + alpha') - }) - testNoJs('inline directive progressive enhancement', async ({ page }) => { await page.goto(f.url('/inline-directive')) @@ -361,6 +306,61 @@ function defineTests(f: Fixture) { await expect(executionCount).toHaveText('2') await expect(result).toHaveText('arguments: 1') }) + + test('protected captures', async ({ page }) => { + // verify captured value is encoded and thus doesn't appear in raw response + const rscResponse = await page.request.get( + f.url('/protected-captures_.rsc'), + ) + expect(rscResponse.ok()).toBe(true) + expect(await rscResponse.text()).not.toContain('capture-secret') + + using _errors = expectNoPageError(page) + await page.goto(f.url()) + await waitForHydration(page) + await page.getByRole('link', { name: 'Protected captures' }).click() + await expect(page).toHaveURL(f.url('/protected-captures')) + + const example = page.getByTestId('protected-captures') + const submissionCount = example.getByTestId('submission-count') + const capture = page.getByTestId('capture') + const executionCount = example.getByTestId('execution-count') + const result = example.getByTestId('result') + await page.getByRole('button', { name: 'Reset' }).click() + await expect(submissionCount).toHaveText('0') + await expect(capture).toHaveText('first') + await expect(executionCount).toHaveText('0') + await expect(result).toHaveText('not called') + + // submit with "first" capture and "alpha" argument + await submit(page, example) + await expect(submissionCount).toHaveText('1') + await expect(executionCount).toHaveText('1') + await expect(result).toHaveText('first + alpha') + + // A fresh render and ciphertext for the same logical capture still hits. + await page.reload() + await waitForHydration(page) + await submit(page, example) + await expect(submissionCount).toHaveText('1') + await expect(executionCount).toHaveText('1') + await expect(result).toHaveText('first + alpha') + + // The same invocation with a different decoded capture is a cache miss. + await page.getByRole('button', { name: 'Second capture' }).click() + await expect(capture).toHaveText('second') + await submit(page, example) + await expect(submissionCount).toHaveText('2') + await expect(executionCount).toHaveText('2') + await expect(result).toHaveText('second + alpha') + + await page.getByRole('button', { name: 'First capture' }).click() + await expect(capture).toHaveText('first') + await submit(page, example) + await expect(submissionCount).toHaveText('3') + await expect(executionCount).toHaveText('2') + await expect(result).toHaveText('first + alpha') + }) } async function submit(page: Page, form: Locator) { From 9e66ba27313c38bd12a5792e6d3847620008e897 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Date: Wed, 5 Aug 2026 17:07:01 +0900 Subject: [PATCH 09/22] fix(rsc): stabilize encrypted form cache keys Exclude React form transport fields from cache identity while preserving the original FormData for execution. Co-authored-by: OpenCode --- .../plugin-rsc/e2e/use-cache-callable.test.ts | 3 ++- .../src/framework/use-cache-runtime.tsx | 26 +++++++++++++++++-- 2 files changed, 26 insertions(+), 3 deletions(-) diff --git a/packages/plugin-rsc/e2e/use-cache-callable.test.ts b/packages/plugin-rsc/e2e/use-cache-callable.test.ts index e0793f977..2c9508158 100644 --- a/packages/plugin-rsc/e2e/use-cache-callable.test.ts +++ b/packages/plugin-rsc/e2e/use-cache-callable.test.ts @@ -415,7 +415,8 @@ function defineTests(f: Fixture) { await submit(page, example) await expect(submissionCount).toHaveText('3') await expect(executionCount).toHaveText('2') - await expect(result).toHaveText('first + alpha') + // A cache hit skips the implementation, so its previous display side effect remains. + await expect(result).toHaveText('second + alpha') }) } diff --git a/packages/plugin-rsc/examples/use-cache-callable/src/framework/use-cache-runtime.tsx b/packages/plugin-rsc/examples/use-cache-callable/src/framework/use-cache-runtime.tsx index a7da37a17..1fdaf3957 100644 --- a/packages/plugin-rsc/examples/use-cache-callable/src/framework/use-cache-runtime.tsx +++ b/packages/plugin-rsc/examples/use-cache-callable/src/framework/use-cache-runtime.tsx @@ -66,9 +66,9 @@ export default function cacheWrapper( ? [ cacheCaptureType, ...(await decodeCacheCaptures(firstArgument)), - ...admittedArgs.slice(1), + ...toCacheArguments(admittedArgs.slice(1)), ] - : admittedArgs + : toCacheArguments(admittedArgs) const encodedCacheArguments = cacheArguments === admittedArgs ? encodedArguments @@ -147,6 +147,28 @@ function isCacheCaptureEnvelope(value: unknown): value is CacheCaptureEnvelope { ) } +function toCacheArgument(value: unknown): unknown { + if (!(value instanceof FormData)) { + return value + } + const result = new FormData() + for (const [name, entry] of value) { + // Hydrated forms retain React's server-reference transport fields. Bound + // captures can give those fields fresh ciphertext without changing user input. + if (!name.startsWith('$ACTION_')) { + result.append(name, entry) + } + } + return result +} + +function toCacheArguments(values: any[]): any[] { + const result = values.map(toCacheArgument) + return result.every((value, index) => value === values[index]) + ? values + : result +} + export function revalidateCache(cachedFn: Function) { cachedFnCacheEntries.delete(cachedFn) } From 56bdff7d6698772d08aea2366c2dff25a656e117 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Date: Wed, 5 Aug 2026 17:20:13 +0900 Subject: [PATCH 10/22] docs(rsc): explain form cache key divergence Co-authored-by: OpenCode --- packages/plugin-rsc/e2e/use-cache-callable.test.ts | 4 +++- .../use-cache-callable/src/framework/use-cache-runtime.tsx | 7 +++++-- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/packages/plugin-rsc/e2e/use-cache-callable.test.ts b/packages/plugin-rsc/e2e/use-cache-callable.test.ts index 2c9508158..13eaaf0dd 100644 --- a/packages/plugin-rsc/e2e/use-cache-callable.test.ts +++ b/packages/plugin-rsc/e2e/use-cache-callable.test.ts @@ -65,7 +65,9 @@ function defineTests(f: Fixture) { await page.getByRole('button', { name: 'Reset' }).click() await expect(executionCount).toHaveText('0') - // Reloading and hydrating a fresh SSR form preserves the cache hit for the same argument. + // Unlike current Next.js behavior, this framework strips React's `$ACTION_*` + // fields from cache identity, so a fresh SSR form preserves the cache hit. + // https://github.com/hi-ogawa/reproductions/tree/main/next-use-cache-form-reload // alpha (cache miss) await submit(page, example) await expect(submissionCount).toHaveText('1') diff --git a/packages/plugin-rsc/examples/use-cache-callable/src/framework/use-cache-runtime.tsx b/packages/plugin-rsc/examples/use-cache-callable/src/framework/use-cache-runtime.tsx index 1fdaf3957..fe54b6cf7 100644 --- a/packages/plugin-rsc/examples/use-cache-callable/src/framework/use-cache-runtime.tsx +++ b/packages/plugin-rsc/examples/use-cache-callable/src/framework/use-cache-runtime.tsx @@ -153,8 +153,11 @@ function toCacheArgument(value: unknown): unknown { } const result = new FormData() for (const [name, entry] of value) { - // Hydrated forms retain React's server-reference transport fields. Bound - // captures can give those fields fresh ciphertext without changing user input. + // Hydrated forms retain React's server-reference transport fields. Unlike + // current Next.js behavior, exclude them from this cache-key-only copy because + // bound captures can give them fresh ciphertext without changing user input. + // The implementation still receives the original FormData via encodedArguments. + // https://github.com/hi-ogawa/reproductions/tree/main/next-use-cache-form-reload if (!name.startsWith('$ACTION_')) { result.append(name, entry) } From da14545c6bf6abe5a00c37a3f2a11130e9b40cd8 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Date: Wed, 5 Aug 2026 17:28:26 +0900 Subject: [PATCH 11/22] test(rsc): document encrypted form reload miss Keep FormData cache identity unchanged and point to custom form adaptation as the framework-level improvement path. Co-authored-by: OpenCode --- .../plugin-rsc/e2e/use-cache-callable.test.ts | 13 +++++---- .../src/framework/use-cache-runtime.tsx | 29 ++----------------- 2 files changed, 10 insertions(+), 32 deletions(-) diff --git a/packages/plugin-rsc/e2e/use-cache-callable.test.ts b/packages/plugin-rsc/e2e/use-cache-callable.test.ts index 13eaaf0dd..25c729a0d 100644 --- a/packages/plugin-rsc/e2e/use-cache-callable.test.ts +++ b/packages/plugin-rsc/e2e/use-cache-callable.test.ts @@ -53,7 +53,9 @@ function defineTests(f: Fixture) { await expect(result).toHaveText('captured + beta') }) - test('inline directive cache hit after hydrated reload', async ({ page }) => { + test('inline directive cache miss after hydrated reload', async ({ + page, + }) => { using _errors = expectNoPageError(page) await page.goto(f.url('/inline-directive')) await waitForHydration(page) @@ -65,8 +67,9 @@ function defineTests(f: Fixture) { await page.getByRole('button', { name: 'Reset' }).click() await expect(executionCount).toHaveText('0') - // Unlike current Next.js behavior, this framework strips React's `$ACTION_*` - // fields from cache identity, so a fresh SSR form preserves the cache hit. + // A fresh SSR form has new encrypted `$ACTION_*` fields, so the FormData cache + // argument changes. A framework can avoid this with custom form handling that + // passes only application fields, as the protected-captures example does. // https://github.com/hi-ogawa/reproductions/tree/main/next-use-cache-form-reload // alpha (cache miss) await submit(page, example) @@ -74,12 +77,12 @@ function defineTests(f: Fixture) { await expect(executionCount).toHaveText('1') await expect(result).toHaveText('captured + alpha') - // alpha after reload (cache hit) + // alpha after reload (cache miss) await page.reload() await waitForHydration(page) await submit(page, example) await expect(submissionCount).toHaveText('1') - await expect(executionCount).toHaveText('1') + await expect(executionCount).toHaveText('2') await expect(result).toHaveText('captured + alpha') }) diff --git a/packages/plugin-rsc/examples/use-cache-callable/src/framework/use-cache-runtime.tsx b/packages/plugin-rsc/examples/use-cache-callable/src/framework/use-cache-runtime.tsx index fe54b6cf7..a7da37a17 100644 --- a/packages/plugin-rsc/examples/use-cache-callable/src/framework/use-cache-runtime.tsx +++ b/packages/plugin-rsc/examples/use-cache-callable/src/framework/use-cache-runtime.tsx @@ -66,9 +66,9 @@ export default function cacheWrapper( ? [ cacheCaptureType, ...(await decodeCacheCaptures(firstArgument)), - ...toCacheArguments(admittedArgs.slice(1)), + ...admittedArgs.slice(1), ] - : toCacheArguments(admittedArgs) + : admittedArgs const encodedCacheArguments = cacheArguments === admittedArgs ? encodedArguments @@ -147,31 +147,6 @@ function isCacheCaptureEnvelope(value: unknown): value is CacheCaptureEnvelope { ) } -function toCacheArgument(value: unknown): unknown { - if (!(value instanceof FormData)) { - return value - } - const result = new FormData() - for (const [name, entry] of value) { - // Hydrated forms retain React's server-reference transport fields. Unlike - // current Next.js behavior, exclude them from this cache-key-only copy because - // bound captures can give them fresh ciphertext without changing user input. - // The implementation still receives the original FormData via encodedArguments. - // https://github.com/hi-ogawa/reproductions/tree/main/next-use-cache-form-reload - if (!name.startsWith('$ACTION_')) { - result.append(name, entry) - } - } - return result -} - -function toCacheArguments(values: any[]): any[] { - const result = values.map(toCacheArgument) - return result.every((value, index) => value === values[index]) - ? values - : result -} - export function revalidateCache(cachedFn: Function) { cachedFnCacheEntries.delete(cachedFn) } From 2ed49ab6eeee80e22eb361df034a1ea7b66f1a98 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Date: Wed, 5 Aug 2026 17:32:16 +0900 Subject: [PATCH 12/22] refactor(rsc): keep cache capture envelope synchronous Store the asynchronous encryption result in the envelope so the cache wrapper can inspect its sentinel without awaiting the argument. Co-authored-by: OpenCode --- .../src/framework/use-cache-runtime.tsx | 27 ++++++++++++------- 1 file changed, 17 insertions(+), 10 deletions(-) diff --git a/packages/plugin-rsc/examples/use-cache-callable/src/framework/use-cache-runtime.tsx b/packages/plugin-rsc/examples/use-cache-callable/src/framework/use-cache-runtime.tsx index a7da37a17..34498b5b2 100644 --- a/packages/plugin-rsc/examples/use-cache-callable/src/framework/use-cache-runtime.tsx +++ b/packages/plugin-rsc/examples/use-cache-callable/src/framework/use-cache-runtime.tsx @@ -61,7 +61,7 @@ export default function cacheWrapper( const encodedArguments = await encodeReply(admittedArgs, { temporaryReferences: clientTemporaryReferences, }) - const firstArgument = await admittedArgs[0] + const firstArgument = admittedArgs[0] const cacheArguments = isCacheCaptureEnvelope(firstArgument) ? [ cacheCaptureType, @@ -113,22 +113,22 @@ export default function cacheWrapper( type CacheCaptureEnvelope = { type: typeof cacheCaptureType - encrypted: string + encrypted: string | PromiseLike } -export async function encodeCacheCaptures( - captures: unknown[], -): Promise { +export function encodeCacheCaptures(captures: unknown[]): CacheCaptureEnvelope { + // Keep the sentinel envelope synchronous so the cache wrapper can identify it + // without awaiting the argument; only the encrypted payload needs to be async. return { type: cacheCaptureType, - encrypted: await encryptActionBoundArgs(captures), + encrypted: encryptActionBoundArgs(captures), } } export async function decodeCacheCaptures( - envelope: CacheCaptureEnvelope | Promise, + envelope: CacheCaptureEnvelope, ): Promise { - const { encrypted } = await envelope + const { encrypted } = envelope const captures = await decryptActionBoundArgs(Promise.resolve(encrypted)) if (!Array.isArray(captures)) { throw new Error('Invalid cache capture payload') @@ -137,13 +137,20 @@ export async function decodeCacheCaptures( } function isCacheCaptureEnvelope(value: unknown): value is CacheCaptureEnvelope { + const encrypted = + typeof value === 'object' && value !== null && 'encrypted' in value + ? value.encrypted + : undefined return ( typeof value === 'object' && value !== null && 'type' in value && value.type === cacheCaptureType && - 'encrypted' in value && - typeof value.encrypted === 'string' + (typeof encrypted === 'string' || + (typeof encrypted === 'object' && + encrypted !== null && + 'then' in encrypted && + typeof encrypted.then === 'function')) ) } From cd72fec6ffc69eb5be603b725ffb7167e19003fd Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Date: Wed, 5 Aug 2026 17:51:22 +0900 Subject: [PATCH 13/22] refactor(rsc): clarify protected cache key encoding Co-authored-by: OpenCode --- .../src/framework/use-cache-runtime.tsx | 26 +++++++++---------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/packages/plugin-rsc/examples/use-cache-callable/src/framework/use-cache-runtime.tsx b/packages/plugin-rsc/examples/use-cache-callable/src/framework/use-cache-runtime.tsx index 34498b5b2..8ed66cfc2 100644 --- a/packages/plugin-rsc/examples/use-cache-callable/src/framework/use-cache-runtime.tsx +++ b/packages/plugin-rsc/examples/use-cache-callable/src/framework/use-cache-runtime.tsx @@ -61,20 +61,20 @@ export default function cacheWrapper( const encodedArguments = await encodeReply(admittedArgs, { temporaryReferences: clientTemporaryReferences, }) + let encodedCacheArguments = encodedArguments const firstArgument = admittedArgs[0] - const cacheArguments = isCacheCaptureEnvelope(firstArgument) - ? [ - cacheCaptureType, - ...(await decodeCacheCaptures(firstArgument)), - ...admittedArgs.slice(1), - ] - : admittedArgs - const encodedCacheArguments = - cacheArguments === admittedArgs - ? encodedArguments - : await encodeReply(cacheArguments, { - temporaryReferences: createClientTemporaryReferenceSet(), - }) + if (isCacheCaptureEnvelope(firstArgument)) { + const cacheArguments = [ + cacheCaptureType, + ...(await decodeCacheCaptures(firstArgument)), + ...admittedArgs.slice(1), + ] + // Re-encode decoded captures so cache identity reflects their logical values + // rather than the randomized ciphertext used by the transport arguments. + encodedCacheArguments = await encodeReply(cacheArguments, { + temporaryReferences: createClientTemporaryReferenceSet(), + }) + } const serializedCacheKey = await replyToCacheKey(encodedCacheArguments) // cache `fn` result as stream From add3c5c9de236110aa3ce0f64410f75fb7e30112 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Date: Wed, 5 Aug 2026 17:52:23 +0900 Subject: [PATCH 14/22] nit --- .../use-cache-callable/src/framework/use-cache-runtime.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/plugin-rsc/examples/use-cache-callable/src/framework/use-cache-runtime.tsx b/packages/plugin-rsc/examples/use-cache-callable/src/framework/use-cache-runtime.tsx index 8ed66cfc2..150faba2a 100644 --- a/packages/plugin-rsc/examples/use-cache-callable/src/framework/use-cache-runtime.tsx +++ b/packages/plugin-rsc/examples/use-cache-callable/src/framework/use-cache-runtime.tsx @@ -62,6 +62,8 @@ export default function cacheWrapper( temporaryReferences: clientTemporaryReferences, }) let encodedCacheArguments = encodedArguments + // Re-encode decrypted captures so cache identity reflects their logical values + // rather than the randomized ciphertext used by the transport arguments. const firstArgument = admittedArgs[0] if (isCacheCaptureEnvelope(firstArgument)) { const cacheArguments = [ @@ -69,8 +71,6 @@ export default function cacheWrapper( ...(await decodeCacheCaptures(firstArgument)), ...admittedArgs.slice(1), ] - // Re-encode decoded captures so cache identity reflects their logical values - // rather than the randomized ciphertext used by the transport arguments. encodedCacheArguments = await encodeReply(cacheArguments, { temporaryReferences: createClientTemporaryReferenceSet(), }) From 9b799ad6e6d49dfd9eaac486ef384cc1af9c1b7c Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Date: Wed, 5 Aug 2026 17:53:03 +0900 Subject: [PATCH 15/22] refactor(rsc): name cache capture encryption explicitly Co-authored-by: OpenCode --- .../examples/use-cache-callable/callable-cache-plugin.ts | 6 +++--- .../src/framework/use-cache-runtime.tsx | 8 +++++--- 2 files changed, 8 insertions(+), 6 deletions(-) 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 9ebe09dcf..ec59ff4c5 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 @@ -58,8 +58,8 @@ export function callableCachePlugin(): Plugin { rejectNonAsyncFunction: true, hoistRuntime: true, runtime: (value, name) => runtime(value, name, {}), - encode: (value) => `$$encodeCacheCaptures(${value})`, - decode: (value) => `await $$decodeCacheCaptures(${value})`, + encode: (value) => `$$encryptCacheCaptures(${value})`, + decode: (value) => `await $$decryptCacheCaptures(${value})`, }) if (!result.output.hasChanged()) { manager.serverReferences.deleteClaim(pluginName, id) @@ -71,7 +71,7 @@ export function callableCachePlugin(): Plugin { exportNames: 'names' in result ? result.names : result.exportNames, }) result.output.prepend( - `import $$cacheWrapper, { encodeCacheCaptures as $$encodeCacheCaptures, decodeCacheCaptures as $$decodeCacheCaptures } from "/src/framework/use-cache-runtime";\n` + + `import $$cacheWrapper, { encryptCacheCaptures as $$encryptCacheCaptures, decryptCacheCaptures as $$decryptCacheCaptures } from "/src/framework/use-cache-runtime";\n` + `import * as $$ReactServer from "@vitejs/plugin-rsc/react/rsc/server";\n`, ) return { diff --git a/packages/plugin-rsc/examples/use-cache-callable/src/framework/use-cache-runtime.tsx b/packages/plugin-rsc/examples/use-cache-callable/src/framework/use-cache-runtime.tsx index 150faba2a..7f120b6d2 100644 --- a/packages/plugin-rsc/examples/use-cache-callable/src/framework/use-cache-runtime.tsx +++ b/packages/plugin-rsc/examples/use-cache-callable/src/framework/use-cache-runtime.tsx @@ -68,7 +68,7 @@ export default function cacheWrapper( if (isCacheCaptureEnvelope(firstArgument)) { const cacheArguments = [ cacheCaptureType, - ...(await decodeCacheCaptures(firstArgument)), + ...(await decryptCacheCaptures(firstArgument)), ...admittedArgs.slice(1), ] encodedCacheArguments = await encodeReply(cacheArguments, { @@ -116,7 +116,9 @@ type CacheCaptureEnvelope = { encrypted: string | PromiseLike } -export function encodeCacheCaptures(captures: unknown[]): CacheCaptureEnvelope { +export function encryptCacheCaptures( + captures: unknown[], +): CacheCaptureEnvelope { // Keep the sentinel envelope synchronous so the cache wrapper can identify it // without awaiting the argument; only the encrypted payload needs to be async. return { @@ -125,7 +127,7 @@ export function encodeCacheCaptures(captures: unknown[]): CacheCaptureEnvelope { } } -export async function decodeCacheCaptures( +export async function decryptCacheCaptures( envelope: CacheCaptureEnvelope, ): Promise { const { encrypted } = envelope From 329abcbacbb010534552aca01b17d1bdfd4bb562 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Date: Wed, 5 Aug 2026 17:55:55 +0900 Subject: [PATCH 16/22] nit --- .../src/framework/use-cache-runtime.tsx | 88 ++++++++++--------- 1 file changed, 45 insertions(+), 43 deletions(-) diff --git a/packages/plugin-rsc/examples/use-cache-callable/src/framework/use-cache-runtime.tsx b/packages/plugin-rsc/examples/use-cache-callable/src/framework/use-cache-runtime.tsx index 7f120b6d2..94804408f 100644 --- a/packages/plugin-rsc/examples/use-cache-callable/src/framework/use-cache-runtime.tsx +++ b/packages/plugin-rsc/examples/use-cache-callable/src/framework/use-cache-runtime.tsx @@ -111,6 +111,51 @@ export default function cacheWrapper( 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))) +} + +// use fixed sentinel value to detect the existence of cache captures via runtime logic +// without transform-informed metadata type CacheCaptureEnvelope = { type: typeof cacheCaptureType encrypted: string | PromiseLike @@ -155,46 +200,3 @@ function isCacheCaptureEnvelope(value: unknown): value is CacheCaptureEnvelope { typeof encrypted.then === 'function')) ) } - -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))) -} From 5c0734e71cdf246a3cc91491da7472ece9666cba Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Date: Wed, 5 Aug 2026 18:00:49 +0900 Subject: [PATCH 17/22] refactor(rsc): remove cache capture key marker Co-authored-by: OpenCode --- .../use-cache-callable/src/framework/use-cache-runtime.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/plugin-rsc/examples/use-cache-callable/src/framework/use-cache-runtime.tsx b/packages/plugin-rsc/examples/use-cache-callable/src/framework/use-cache-runtime.tsx index 94804408f..273495aed 100644 --- a/packages/plugin-rsc/examples/use-cache-callable/src/framework/use-cache-runtime.tsx +++ b/packages/plugin-rsc/examples/use-cache-callable/src/framework/use-cache-runtime.tsx @@ -67,7 +67,6 @@ export default function cacheWrapper( const firstArgument = admittedArgs[0] if (isCacheCaptureEnvelope(firstArgument)) { const cacheArguments = [ - cacheCaptureType, ...(await decryptCacheCaptures(firstArgument)), ...admittedArgs.slice(1), ] From f45d3293458dd838b2e4bf18b3e200f606d21601 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Date: Wed, 5 Aug 2026 18:01:49 +0900 Subject: [PATCH 18/22] nit --- .../src/framework/use-cache-runtime.tsx | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/packages/plugin-rsc/examples/use-cache-callable/src/framework/use-cache-runtime.tsx b/packages/plugin-rsc/examples/use-cache-callable/src/framework/use-cache-runtime.tsx index 273495aed..80c97f527 100644 --- a/packages/plugin-rsc/examples/use-cache-callable/src/framework/use-cache-runtime.tsx +++ b/packages/plugin-rsc/examples/use-cache-callable/src/framework/use-cache-runtime.tsx @@ -22,7 +22,6 @@ export type CacheWrapperOptions = { } const cachedFnMap = new WeakMap() -const cacheCaptureType = 'use-cache-captures' let cachedFnCacheEntries = new WeakMap< Function, Record> @@ -155,8 +154,10 @@ async function replyToCacheKey(reply: string | FormData) { // use fixed sentinel value to detect the existence of cache captures via runtime logic // without transform-informed metadata +const CACHE_CAPTURE_TYPE = 'use-cache-captures' + type CacheCaptureEnvelope = { - type: typeof cacheCaptureType + type: typeof CACHE_CAPTURE_TYPE encrypted: string | PromiseLike } @@ -166,7 +167,7 @@ export function encryptCacheCaptures( // Keep the sentinel envelope synchronous so the cache wrapper can identify it // without awaiting the argument; only the encrypted payload needs to be async. return { - type: cacheCaptureType, + type: CACHE_CAPTURE_TYPE, encrypted: encryptActionBoundArgs(captures), } } @@ -191,7 +192,7 @@ function isCacheCaptureEnvelope(value: unknown): value is CacheCaptureEnvelope { typeof value === 'object' && value !== null && 'type' in value && - value.type === cacheCaptureType && + value.type === CACHE_CAPTURE_TYPE && (typeof encrypted === 'string' || (typeof encrypted === 'object' && encrypted !== null && From 09400ad3c5314fe28047c9a267acedd89e1a13db Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Date: Wed, 5 Aug 2026 18:05:55 +0900 Subject: [PATCH 19/22] docs(rsc): note duplicate capture decryption Co-authored-by: OpenCode --- .../use-cache-callable/src/framework/use-cache-runtime.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/plugin-rsc/examples/use-cache-callable/src/framework/use-cache-runtime.tsx b/packages/plugin-rsc/examples/use-cache-callable/src/framework/use-cache-runtime.tsx index 80c97f527..2ed146de9 100644 --- a/packages/plugin-rsc/examples/use-cache-callable/src/framework/use-cache-runtime.tsx +++ b/packages/plugin-rsc/examples/use-cache-callable/src/framework/use-cache-runtime.tsx @@ -65,6 +65,8 @@ export default function cacheWrapper( // rather than the randomized ciphertext used by the transport arguments. const firstArgument = admittedArgs[0] if (isCacheCaptureEnvelope(firstArgument)) { + // TODO: On a cache miss, the hoister-generated implementation decrypts the + // original envelope again. A tighter adapter could reuse these captures. const cacheArguments = [ ...(await decryptCacheCaptures(firstArgument)), ...admittedArgs.slice(1), From 2f70fe9f063f67f7a0c691b97cc09266595f563a Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Date: Wed, 5 Aug 2026 18:09:44 +0900 Subject: [PATCH 20/22] refactor(rsc): decrypt cache captures once Adapt encrypted transport arguments to decoded implementation arguments in the cache runtime, allowing the generated implementation to destructure captures directly. Co-authored-by: OpenCode --- .../callable-cache-plugin.ts | 6 +++-- .../src/framework/use-cache-runtime.tsx | 26 ++++++++++--------- 2 files changed, 18 insertions(+), 14 deletions(-) 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 ec59ff4c5..f535c3b42 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 @@ -59,7 +59,9 @@ export function callableCachePlugin(): Plugin { hoistRuntime: true, runtime: (value, name) => runtime(value, name, {}), encode: (value) => `$$encryptCacheCaptures(${value})`, - decode: (value) => `await $$decryptCacheCaptures(${value})`, + // The cache runtime replaces the envelope with decoded captures + // before invoking this private implementation. + decode: (value) => value, }) if (!result.output.hasChanged()) { manager.serverReferences.deleteClaim(pluginName, id) @@ -71,7 +73,7 @@ export function callableCachePlugin(): Plugin { exportNames: 'names' in result ? result.names : result.exportNames, }) result.output.prepend( - `import $$cacheWrapper, { encryptCacheCaptures as $$encryptCacheCaptures, decryptCacheCaptures as $$decryptCacheCaptures } from "/src/framework/use-cache-runtime";\n` + + `import $$cacheWrapper, { encryptCacheCaptures as $$encryptCacheCaptures } from "/src/framework/use-cache-runtime";\n` + `import * as $$ReactServer from "@vitejs/plugin-rsc/react/rsc/server";\n`, ) return { diff --git a/packages/plugin-rsc/examples/use-cache-callable/src/framework/use-cache-runtime.tsx b/packages/plugin-rsc/examples/use-cache-callable/src/framework/use-cache-runtime.tsx index 2ed146de9..f8302df78 100644 --- a/packages/plugin-rsc/examples/use-cache-callable/src/framework/use-cache-runtime.tsx +++ b/packages/plugin-rsc/examples/use-cache-callable/src/framework/use-cache-runtime.tsx @@ -57,20 +57,22 @@ export default function cacheWrapper( // "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(admittedArgs, { + let executionArguments = admittedArgs + let cacheArguments = admittedArgs + const firstArgument = admittedArgs[0] + if (isCacheCaptureEnvelope(firstArgument)) { + const captures = await decryptCacheCaptures(firstArgument) + const invocationArguments = admittedArgs.slice(1) + // The private implementation receives the decoded capture array, while the + // cache key retains the same flattened logical argument shape as direct captures. + executionArguments = [captures, ...invocationArguments] + cacheArguments = [...captures, ...invocationArguments] + } + const encodedArguments = await encodeReply(executionArguments, { temporaryReferences: clientTemporaryReferences, }) let encodedCacheArguments = encodedArguments - // Re-encode decrypted captures so cache identity reflects their logical values - // rather than the randomized ciphertext used by the transport arguments. - const firstArgument = admittedArgs[0] - if (isCacheCaptureEnvelope(firstArgument)) { - // TODO: On a cache miss, the hoister-generated implementation decrypts the - // original envelope again. A tighter adapter could reuse these captures. - const cacheArguments = [ - ...(await decryptCacheCaptures(firstArgument)), - ...admittedArgs.slice(1), - ] + if (cacheArguments !== executionArguments) { encodedCacheArguments = await encodeReply(cacheArguments, { temporaryReferences: createClientTemporaryReferenceSet(), }) @@ -174,7 +176,7 @@ export function encryptCacheCaptures( } } -export async function decryptCacheCaptures( +async function decryptCacheCaptures( envelope: CacheCaptureEnvelope, ): Promise { const { encrypted } = envelope From b295216b811fbaf174902e06d8a5835b333ba686 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Date: Thu, 6 Aug 2026 11:40:48 +0900 Subject: [PATCH 21/22] refactor(rsc): reuse decoded cache arguments Use the decoded execution argument serialization directly for cache identity instead of flattening and encoding captures separately. Co-authored-by: OpenCode --- .../src/framework/use-cache-runtime.tsx | 12 +----------- 1 file changed, 1 insertion(+), 11 deletions(-) diff --git a/packages/plugin-rsc/examples/use-cache-callable/src/framework/use-cache-runtime.tsx b/packages/plugin-rsc/examples/use-cache-callable/src/framework/use-cache-runtime.tsx index 44a748faf..896df6516 100644 --- a/packages/plugin-rsc/examples/use-cache-callable/src/framework/use-cache-runtime.tsx +++ b/packages/plugin-rsc/examples/use-cache-callable/src/framework/use-cache-runtime.tsx @@ -65,25 +65,15 @@ export default function cacheWrapper( // cf. https://nextjs.org/docs/app/api-reference/directives/use-cache#non-serializable-arguments const clientTemporaryReferences = createClientTemporaryReferenceSet() let executionArguments = admittedArgs - let cacheArguments = admittedArgs if (captureEnvelope) { const captures = await decryptCacheCaptures(captureEnvelope) const invocationArguments = admittedArgs.slice(1) - // The private implementation receives the decoded capture array, while the - // cache key retains the same flattened logical argument shape as direct captures. executionArguments = [captures, ...invocationArguments] - cacheArguments = [...captures, ...invocationArguments] } const encodedArguments = await encodeReply(executionArguments, { temporaryReferences: clientTemporaryReferences, }) - let encodedCacheArguments = encodedArguments - if (cacheArguments !== executionArguments) { - encodedCacheArguments = await encodeReply(cacheArguments, { - temporaryReferences: createClientTemporaryReferenceSet(), - }) - } - const serializedCacheKey = await replyToCacheKey(encodedCacheArguments) + const serializedCacheKey = await replyToCacheKey(encodedArguments) // cache `fn` result as stream // (cache value is promise so that it dedupes concurrent async calls) From 78f8b6a485a59fe7225f49312a0dfb05ebde121d Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Date: Thu, 6 Aug 2026 11:42:46 +0900 Subject: [PATCH 22/22] docs(rsc): clarify cache capture decode boundary Co-authored-by: OpenCode --- .../use-cache-callable/src/framework/use-cache-runtime.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/plugin-rsc/examples/use-cache-callable/src/framework/use-cache-runtime.tsx b/packages/plugin-rsc/examples/use-cache-callable/src/framework/use-cache-runtime.tsx index 896df6516..70a929062 100644 --- a/packages/plugin-rsc/examples/use-cache-callable/src/framework/use-cache-runtime.tsx +++ b/packages/plugin-rsc/examples/use-cache-callable/src/framework/use-cache-runtime.tsx @@ -66,6 +66,8 @@ export default function cacheWrapper( const clientTemporaryReferences = createClientTemporaryReferenceSet() let executionArguments = admittedArgs if (captureEnvelope) { + // Decrypt in the framework runtime so cache identity and execution share + // these values; the transformed implementation only destructures the array. const captures = await decryptCacheCaptures(captureEnvelope) const invocationArguments = admittedArgs.slice(1) executionArguments = [captures, ...invocationArguments]