Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
5229ef9
feat(rsc): allow proxy export filter by node ast
hi-ogawa Aug 5, 2026
c13edeb
refactor(rsc): defer destructured export filtering
hi-ogawa Aug 5, 2026
fd256cf
docs(rsc): locate destructuring filter todo
hi-ogawa Aug 5, 2026
89c50d2
fix(rsc): reject proxy filter with keep
hi-ogawa Aug 5, 2026
a096d90
fix(rsc): preserve empty binding validation
hi-ogawa Aug 5, 2026
bd58863
fix(rsc): validate filtered empty bindings
hi-ogawa Aug 5, 2026
5453a31
docs(rsc): explain ordinary cache exports
hi-ogawa Aug 5, 2026
67b592a
test(rsc): clarify object and array exports
hi-ogawa Aug 5, 2026
19c4ce1
docs(rsc): clarify cache export exception
hi-ogawa Aug 5, 2026
20ed023
test(rsc): document destructured proxy follow-up
hi-ogawa Aug 5, 2026
6c7f1a1
test(rsc): characterize destructured proxy limitation
hi-ogawa Aug 5, 2026
7ed81c4
docs(rsc): link destructured export reference
hi-ogawa Aug 5, 2026
5c54ec9
docs(rsc): link destructured proxy fixture
hi-ogawa Aug 5, 2026
14899ac
docs(rsc): clarify destructured export scope
hi-ogawa Aug 5, 2026
ee3cb3c
docs(rsc): illustrate proxy export transform
hi-ogawa Aug 5, 2026
8f9466b
docs(rsc): document proxy export options
hi-ogawa Aug 5, 2026
296d989
refactor(rsc): name proxy export result
hi-ogawa Aug 5, 2026
792e8ed
Merge origin/main into fix/issue-1391
hi-ogawa Aug 5, 2026
752abef
refactor(rsc): group proxy fixture options
hi-ogawa Aug 5, 2026
091019a
refactor(rsc): remove filtered proxy exports explicitly
hi-ogawa Aug 5, 2026
59f2825
docs(rsc): clarify namespace proxy exports
hi-ogawa Aug 5, 2026
c18f8b2
refactor(rsc): extract proxy node removal
hi-ogawa Aug 5, 2026
3ace8e8
refactor(rsc): handle empty proxy selections
hi-ogawa Aug 5, 2026
eee7f87
refactor(rsc): simplify empty proxy selection
hi-ogawa Aug 5, 2026
00381b0
refactor(rsc): simplify keep proxy handling
hi-ogawa Aug 5, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/plugin-rsc/e2e/use-cache-callable.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@ function defineTests(f: Fixture) {
await page.getByRole('button', { name: 'Reset' }).click()
await expect(submissionCount).toHaveText('0')
await expect(executionCount).toHaveText('0')
await expect(ordinaryExports).toHaveText('cached metadata: cache')
await expect(ordinaryExports).toHaveText('object: array')
await expect(result).toHaveText('not called')

// The wrapped export is passed from a Server Component to a Client Component.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,9 @@ export function callableCachePlugin(): Plugin {
const result = transformDirectiveProxyExport(ast, {
code,
directive,
filter: (_name, meta) =>
meta.valueNode?.type !== 'ObjectExpression' &&
meta.valueNode?.type !== 'ArrayExpression',
rejectNonAsyncFunction: true,
runtime: (name) =>
`$$ReactClient.createServerReference(` +
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,11 @@

import { state } from './state'

// Next.js excludes statically known object and array exports from "use cache"
// server-reference handling. The transform filter mirrors that narrow case.
export const objectValue = { text: 'object' }
export const arrayValue = ['array']

export async function cachedFromClient(formData: FormData) {
const argument = String(formData.get('argument'))
state.executionCount++
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,10 @@

import { state } from './state'

// Ordinary values remain available from "use cache" modules without becoming
// callable server references.
export const metadata = { title: 'cached metadata' }
export const tags = ['cache']
// Next.js excludes statically known object and array exports from "use cache"
// server-reference handling. The transform filter mirrors that narrow case.
export const objectValue = { text: 'object' }
export const arrayValue = ['array']

export async function cachedFromServer(formData: FormData) {
const argument = String(formData.get('argument'))
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { cachedFromServer, metadata, tags } from './action'
import { arrayValue, cachedFromServer, objectValue } from './action'
import { FileDirectiveFromServerClient } from './client'
import { resetAction } from './reset'
import { state } from './state'
Expand All @@ -8,7 +8,7 @@ export function FileDirectiveFromServer() {
<FileDirectiveFromServerClient
action={cachedFromServer}
executionCount={state.executionCount}
ordinaryExports={`${metadata.title}: ${tags.join(', ')}`}
ordinaryExports={`${objectValue.text}: ${arrayValue.join(', ')}`}
resetAction={resetAction}
result={state.result}
/>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
'use cache'

export const cached = async () => {},
objectValue = {},
arrayValue = []
export const unknown = createCached()
export const primitive = 0
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
## proxy-export-filtered

```txt
(2:0) "export const cached = async () => {},\n" --> (2:0) "export const cached = /* #__PURE__ */ createServerReference(\"cached\");\n"
(5:0) "export const unknown = createCached()\n" --> (4:0) "export const unknown = /* #__PURE__ */ createServerReference(\"unknown\");\n"
(6:0) "export const primitive = 0\n" --> (6:0) "export const primitive = /* #__PURE__ */ createServerReference(\"primitive\");\n"
```
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
## Input

```js
'use cache'

export const cached = async () => {},
objectValue = {},
arrayValue = []
export const unknown = createCached()
export const primitive = 0
```

## proxy-export-filtered

**Status:** transformed

**References:** cached, unknown, primitive

[Source map visualization](https://evanw.github.io/source-map-visualization/#MjI2AAoKZXhwb3J0IGNvbnN0IGNhY2hlZCA9IC8qICNfX1BVUkVfXyAqLyBjcmVhdGVTZXJ2ZXJSZWZlcmVuY2UoImNhY2hlZCIpOwoKZXhwb3J0IGNvbnN0IHVua25vd24gPSAvKiAjX19QVVJFX18gKi8gY3JlYXRlU2VydmVyUmVmZXJlbmNlKCJ1bmtub3duIik7CgpleHBvcnQgY29uc3QgcHJpbWl0aXZlID0gLyogI19fUFVSRV9fICovIGNyZWF0ZVNlcnZlclJlZmVyZW5jZSgicHJpbWl0aXZlIik7CgoyNTYAeyJ2ZXJzaW9uIjozLCJzb3VyY2VzIjpbIiJdLCJzb3VyY2VzQ29udGVudCI6WyIndXNlIGNhY2hlJ1xuXG5leHBvcnQgY29uc3QgY2FjaGVkID0gYXN5bmMgKCkgPT4ge30sXG4gIG9iamVjdFZhbHVlID0ge30sXG4gIGFycmF5VmFsdWUgPSBbXVxuZXhwb3J0IGNvbnN0IHVua25vd24gPSBjcmVhdGVDYWNoZWQoKVxuZXhwb3J0IGNvbnN0IHByaW1pdGl2ZSA9IDBcbiJdLCJuYW1lcyI6W10sIm1hcHBpbmdzIjoiOztBQUVBOztBQUdBOztBQUNBOzsifQ==)

```js


export const cached = /* #__PURE__ */ createServerReference("cached");

export const unknown = /* #__PURE__ */ createServerReference("unknown");

export const primitive = /* #__PURE__ */ createServerReference("primitive");

```
5 changes: 5 additions & 0 deletions packages/plugin-rsc/src/transforms/module-export-scan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,11 @@ export function scanModuleExports(
// uniformly handle destructured exports such as
// export const { foo, bar } = ...
// even though associated `meta` doesn't make sense anymore
// TODO: Treat destructured bindings as unknown for both
// "use server" and "use cache" instead of using the container
// initializer as each binding's `valueNode`.
// See the destructured-binding proxy export regression test.
// https://github.com/vercel/next.js/blob/aae4179ac628e55483b62cd023a7e1827dcef122/crates/next-custom-transforms/src/transforms/server_actions.rs#L1787-L1815
exports: extractNames(declarator.id).map((name) => ({
localName: name,
exportName: name,
Expand Down
68 changes: 66 additions & 2 deletions packages/plugin-rsc/src/transforms/proxy-export.test.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,72 @@
import { parseAstAsync } from 'vite'
import { describe, expect, test } from 'vitest'
import { transformProxyExport } from './proxy-export'
import {
transformProxyExport,
type TransformProxyExportOptions,
} from './proxy-export'

async function testTransform(input: string) {
async function testTransform(
input: string,
options?: Partial<TransformProxyExportOptions>,
) {
const ast = await parseAstAsync(input)
const result = transformProxyExport(ast, {
code: input,
runtime: (name) => `$$proxy("<id>", ${JSON.stringify(name)})`,
...options,
})
return { ...result, output: result.output.toString() }
}

describe(transformProxyExport, () => {
test('filter runs before validation', async () => {
const input = `export const cached = async () => {}, objectValue = {}`
const ast = await parseAstAsync(input)
const options: TransformProxyExportOptions = {
code: input,
runtime: (name) => `$$proxy(${JSON.stringify(name)})`,
rejectNonAsyncFunction: true,
filter: (_name, meta) => meta.valueNode?.type !== 'ObjectExpression',
}

expect(() => transformProxyExport(ast, options)).not.toThrow()

const invalidInput = `${input}, primitive = 0`
const invalidAst = await parseAstAsync(invalidInput)
expect(() =>
transformProxyExport(invalidAst, { ...options, code: invalidInput }),
).toThrow('unsupported non async function')
})

test('filter classifies destructured bindings from their container', async () => {
const input = `export const { cached } = { cached: async () => {} }`
const result = await testTransform(input, {
rejectNonAsyncFunction: true,
filter: (_name, meta) => meta.valueNode?.type !== 'ObjectExpression',
})

// TODO: A destructured binding should have no `valueNode` because the
// container is not its value. The filter should therefore conservatively
// select `cached` without validating the object initializer, resulting in
// `exportNames: ['cached']`.
// https://github.com/vercel/next.js/tree/aae4179ac628e55483b62cd023a7e1827dcef122/crates/next-custom-transforms/tests/fixture/server-actions/client-graph/14
expect(result.exportNames).toEqual([])
})

test.each([
['{}', undefined],
['[]', undefined],
['{}', () => true],
['[]', () => true],
])('validates empty binding %s with filter %s', async (id, filter) => {
await expect(
testTransform(`export const ${id} = ${id}`, {
rejectNonAsyncFunction: true,
filter,
}),
).rejects.toThrow('unsupported non async function')
})

test('export string name throws', async () => {
const input = `
const x = 0;
Expand All @@ -27,4 +82,13 @@ export { x as "my thing" }
'unsupported ExportAllDeclaration',
)
})

test('filter with keep throws', async () => {
await expect(
testTransform(`export const action = () => {}`, {
keep: true,
filter: () => true,
}),
).rejects.toThrow('`filter` option is not supported with `keep`')
})
})
Loading
Loading