diff --git a/packages/plugin-rsc/e2e/use-cache-callable.test.ts b/packages/plugin-rsc/e2e/use-cache-callable.test.ts index 5a2ae9be7..2344927e0 100644 --- a/packages/plugin-rsc/e2e/use-cache-callable.test.ts +++ b/packages/plugin-rsc/e2e/use-cache-callable.test.ts @@ -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. 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..8a1f0b624 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 @@ -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(` + diff --git a/packages/plugin-rsc/examples/use-cache-callable/src/features/file-directive-from-client/action.ts b/packages/plugin-rsc/examples/use-cache-callable/src/features/file-directive-from-client/action.ts index 90dc25108..04aaa4b72 100644 --- a/packages/plugin-rsc/examples/use-cache-callable/src/features/file-directive-from-client/action.ts +++ b/packages/plugin-rsc/examples/use-cache-callable/src/features/file-directive-from-client/action.ts @@ -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++ diff --git a/packages/plugin-rsc/examples/use-cache-callable/src/features/file-directive-from-server/action.ts b/packages/plugin-rsc/examples/use-cache-callable/src/features/file-directive-from-server/action.ts index 2de9df9ca..0573bd770 100644 --- a/packages/plugin-rsc/examples/use-cache-callable/src/features/file-directive-from-server/action.ts +++ b/packages/plugin-rsc/examples/use-cache-callable/src/features/file-directive-from-server/action.ts @@ -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')) diff --git a/packages/plugin-rsc/examples/use-cache-callable/src/features/file-directive-from-server/server.tsx b/packages/plugin-rsc/examples/use-cache-callable/src/features/file-directive-from-server/server.tsx index 3f09befef..394509bef 100644 --- a/packages/plugin-rsc/examples/use-cache-callable/src/features/file-directive-from-server/server.tsx +++ b/packages/plugin-rsc/examples/use-cache-callable/src/features/file-directive-from-server/server.tsx @@ -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' @@ -8,7 +8,7 @@ export function FileDirectiveFromServer() { diff --git a/packages/plugin-rsc/src/transforms/fixtures/source-map/proxy-export/filter-value-node.js b/packages/plugin-rsc/src/transforms/fixtures/source-map/proxy-export/filter-value-node.js new file mode 100644 index 000000000..01f36df69 --- /dev/null +++ b/packages/plugin-rsc/src/transforms/fixtures/source-map/proxy-export/filter-value-node.js @@ -0,0 +1,7 @@ +'use cache' + +export const cached = async () => {}, + objectValue = {}, + arrayValue = [] +export const unknown = createCached() +export const primitive = 0 diff --git a/packages/plugin-rsc/src/transforms/fixtures/source-map/proxy-export/filter-value-node.js.map.snap.md b/packages/plugin-rsc/src/transforms/fixtures/source-map/proxy-export/filter-value-node.js.map.snap.md new file mode 100644 index 000000000..81729a733 --- /dev/null +++ b/packages/plugin-rsc/src/transforms/fixtures/source-map/proxy-export/filter-value-node.js.map.snap.md @@ -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" +``` diff --git a/packages/plugin-rsc/src/transforms/fixtures/source-map/proxy-export/filter-value-node.js.snap.md b/packages/plugin-rsc/src/transforms/fixtures/source-map/proxy-export/filter-value-node.js.snap.md new file mode 100644 index 000000000..84f060c5d --- /dev/null +++ b/packages/plugin-rsc/src/transforms/fixtures/source-map/proxy-export/filter-value-node.js.snap.md @@ -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"); + +``` diff --git a/packages/plugin-rsc/src/transforms/module-export-scan.ts b/packages/plugin-rsc/src/transforms/module-export-scan.ts index 92b3b3bac..fa2084fb3 100644 --- a/packages/plugin-rsc/src/transforms/module-export-scan.ts +++ b/packages/plugin-rsc/src/transforms/module-export-scan.ts @@ -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, diff --git a/packages/plugin-rsc/src/transforms/proxy-export.test.ts b/packages/plugin-rsc/src/transforms/proxy-export.test.ts index cca6086c6..8702ee797 100644 --- a/packages/plugin-rsc/src/transforms/proxy-export.test.ts +++ b/packages/plugin-rsc/src/transforms/proxy-export.test.ts @@ -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, +) { const ast = await parseAstAsync(input) const result = transformProxyExport(ast, { code: input, runtime: (name) => `$$proxy("", ${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; @@ -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`') + }) }) diff --git a/packages/plugin-rsc/src/transforms/proxy-export.ts b/packages/plugin-rsc/src/transforms/proxy-export.ts index aeebb583a..b7daf4834 100644 --- a/packages/plugin-rsc/src/transforms/proxy-export.ts +++ b/packages/plugin-rsc/src/transforms/proxy-export.ts @@ -1,52 +1,118 @@ import type { Node, Program } from 'estree' import MagicString from 'magic-string' import type { ESTree } from 'vite' -import { extractNames, hasDirective, validateNonAsyncFunction } from './utils' +import { scanModuleExports, type ModuleExportMeta } from './module-export-scan' +import { hasDirective, validateNonAsyncFunction } from './utils' + +/** Selects which statically discovered exports become proxies. */ +export type TransformProxyExportFilter = ( + name: string, + meta: ModuleExportMeta, +) => boolean export type TransformProxyExportOptions = { - /** Required for source map and `keep` options */ + /** + * Original module source used to preserve source mappings and initializer + * text. Required by `keep`. + */ code?: string + /** + * Returns the proxy expression for an export name. In `keep` mode, `value` + * contains the original initializer for a single identifier declaration. + */ runtime: (name: string, meta?: { value: string }) => string + /** + * Removes a bare `export *` instead of rejecting it. With `keep`, the + * declaration is retained. + * @default false + */ ignoreExportAllDeclaration?: boolean + /** Rejects statically known values that are not async functions. */ rejectNonAsyncFunction?: boolean /** - * escape hatch for Waku's `allowServer` + * Selects exports before validation and proxy generation. Filtered exports + * are omitted from both the generated module and `exportNames`. + * + * Cannot be combined with `keep`. + * @default () => true + */ + filter?: TransformProxyExportFilter + /** + * Retains non-export implementation code and passes a single identifier + * declaration's initializer to `runtime`. This is an escape hatch for Waku's + * `allowServer` transform. + * + * Requires `code` and cannot be combined with `filter`. * @default false */ keep?: boolean } +export type TransformProxyExportResult = { + exportNames: string[] + output: MagicString +} + export function transformDirectiveProxyExport( ast: ESTree.Program, options: { directive: string } & TransformProxyExportOptions, -): - | { - exportNames: string[] - output: MagicString - } - | undefined { +): TransformProxyExportResult | undefined { if (!hasDirective(ast.body, options.directive)) { return } return transformProxyExport(ast, options) } +/** + * Replaces selected exports with proxies created by `runtime` and removes the + * original module implementation. + * + * Conceptually, with a filter that excludes `objectValue`: + * + * ```js + * import { dependency } from './dep' + * export async function action() {} + * export const objectValue = {} + * export { dependency as renamed } + * export default async () => {} + * ``` + * + * becomes: + * + * ```js + * export const action = __PROXY__('action') + * export const renamed = __PROXY__('renamed') + * export default __PROXY__('default') + * ``` + * + * Unlike `transformWrapExport`, this transform does not evaluate the original + * exports. Unknown values are represented only by their export names, so the + * caller must filter solely from the available static `ModuleExportMeta`. + * `keep` is a specialized mode that retains non-export implementation code and + * passes a single variable initializer to `runtime`. + */ export function transformProxyExport( viteAst: ESTree.Program, options: TransformProxyExportOptions, -): { - exportNames: string[] - output: MagicString -} { +): TransformProxyExportResult { const ast = viteAst as unknown as Program if (options.keep && typeof options.code !== 'string') { throw new Error('`keep` option requires `code`') } + if (options.keep && options.filter) { + throw new Error('`filter` option is not supported with `keep`') + } const output = new MagicString(options.code ?? ' '.repeat(ast.end)) const exportNames: string[] = [] + const filter = options.filter ?? (() => true) + function removeNode(node: Node) { + output.remove(node.start, node.end) + } + + /** Replaces one complete export statement with the selected proxy exports. */ function createExport(node: Node, names: string[]) { exportNames.push(...names) const newCode = names @@ -59,97 +125,111 @@ export function transformProxyExport( output.update(node.start, node.end, newCode) } - for (const node of ast.body) { - if (node.type === 'ExportNamedDeclaration') { - if (node.declaration) { + const exportNodes = new Set() + for (const group of scanModuleExports(viteAst)) { + const node = group.node as Node + exportNodes.add(node) + + if (group.type === 'declaration') { + // export function action() {} + // -> export const action = __PROXY__('action') + const entry = group.export + if (filter(entry.exportName, entry.meta)) { + validateNonAsyncFunction(options, group.declaration) + createExport(node, [entry.exportName]) + } else { + removeNode(node) + } + } else if (group.type === 'variable-declaration') { + // export const selected = init(), skipped = {} + // -> export const selected = __PROXY__('selected') + const selectedNames: string[] = [] + for (const declarator of group.declarators) { + const names = declarator.exports + .filter((entry) => filter(entry.exportName, entry.meta)) + .map((entry) => entry.exportName) if ( - node.declaration.type === 'FunctionDeclaration' || - node.declaration.type === 'ClassDeclaration' + declarator.node.init && + (!options.filter || + declarator.exports.length === 0 || + names.length > 0) ) { - /** - * export function foo() {} - */ - validateNonAsyncFunction(options, node.declaration) - createExport(node, [node.declaration.id.name]) - } else if (node.declaration.type === 'VariableDeclaration') { - /** - * export const foo = 1, bar = 2 - */ - for (const decl of node.declaration.declarations) { - if (decl.init) validateNonAsyncFunction(options, decl.init) - } - if (options.keep && options.code) { - if (node.declaration.declarations.length === 1) { - const decl = node.declaration.declarations[0]! - if (decl.id.type === 'Identifier' && decl.init) { - const name = decl.id.name - const value = options.code.slice(decl.init.start, decl.init.end) - const newCode = `export const ${name} = /* #__PURE__ */ ${options.runtime( - name, - { value }, - )};` - output.update(node.start, node.end, newCode) - exportNames.push(name) - continue - } - } - } - const names = node.declaration.declarations.flatMap((decl) => - extractNames(decl.id), - ) - createExport(node, names) - } else { - node.declaration satisfies never + validateNonAsyncFunction(options, declarator.node.init) } - } else { - /** - * export { foo, bar as car } from './foo' - * export { foo, bar as car } - */ - const names: string[] = [] - for (const spec of node.specifiers) { - if (spec.exported.type !== 'Identifier') { + selectedNames.push(...names) + } + if (options.keep && group.declaration.declarations.length === 1) { + // Waku's `keep` mode retains the initializer as the proxy value: + // export const value = init() + // -> export const value = __PROXY__(init(), 'value') + const decl = group.declaration.declarations[0]! + if (decl.id.type === 'Identifier' && decl.init) { + const name = decl.id.name + const value = options.code!.slice(decl.init.start, decl.init.end) + const newCode = `export const ${name} = /* #__PURE__ */ ${options.runtime( + name, + { value }, + )};` + output.update(node.start, node.end, newCode) + exportNames.push(name) + continue + } + } + createExport(node, selectedNames) + } else if (group.type === 'specifiers') { + // export { local as renamed } from './dep' + // -> export const renamed = __PROXY__('renamed') + const names = group.exports + .filter((entry) => { + if (entry.node.exported.type !== 'Identifier') { throw Object.assign( new Error('unsupported string literal export name'), - { pos: spec.exported.start }, + { pos: entry.node.exported.start }, ) } - names.push(spec.exported.name) + return filter(entry.exportName, entry.meta) + }) + .map((entry) => entry.exportName) + createExport(node, names) + } else if (group.type === 'export-all') { + // A namespace re-export has one known name. A bare export-all cannot be + // represented without resolving the dependency's export names. + // TODO: Reject `export * as "name"` as an unsupported string literal + // export name instead of handling it like a bare `export *`. + if (group.node.exported?.type === 'Identifier') { + // export * as dep from './dep' + // -> export const dep = __PROXY__('dep') + const name = group.node.exported.name + if (filter(name, {})) { + createExport(node, [name]) + } else { + removeNode(node) } - createExport(node, names) - } - continue - } - - /** - * export * as ns from './foo' - * export * from './foo' - */ - if (node.type === 'ExportAllDeclaration') { - if (node.exported?.type === 'Identifier') { - createExport(node, [node.exported.name]) - continue - } - if (!options.ignoreExportAllDeclaration) { + } else if (!options.ignoreExportAllDeclaration) { throw new Error('unsupported ExportAllDeclaration') + } else if (!options.keep) { + removeNode(node) + } + } else if (group.type === 'default') { + // export default async () => {} + // -> export default __PROXY__('default') + if (filter('default', group.meta)) { + validateNonAsyncFunction(options, group.node.declaration) + createExport(node, ['default']) + } else { + removeNode(node) } } + } - /** - * export default function foo() {} - * export default class Foo {} - * export default () => {} - */ - if (node.type === 'ExportDefaultDeclaration') { - validateNonAsyncFunction(options, node.declaration) - createExport(node, ['default']) - continue + if (!options.keep) { + // Imports, directives, and implementation statements must not execute in + // the graph that consumes these proxies. + for (const node of ast.body) { + if (!exportNodes.has(node)) { + removeNode(node) + } } - - if (options.keep) continue - - // remove all other nodes - output.remove(node.start, node.end) } return { exportNames, output } diff --git a/packages/plugin-rsc/src/transforms/source-map.test.ts b/packages/plugin-rsc/src/transforms/source-map.test.ts index 71664fa11..32aa8a028 100644 --- a/packages/plugin-rsc/src/transforms/source-map.test.ts +++ b/packages/plugin-rsc/src/transforms/source-map.test.ts @@ -4,7 +4,10 @@ import { describe, expect, test } from 'vitest' import { transformCjsToEsm } from './cjs' import { transformHoistInlineDirective } from './hoist' import { transformModuleExportEffect } from './module-export-effect' -import { transformProxyExport } from './proxy-export' +import { + transformProxyExport, + type TransformProxyExportOptions, +} from './proxy-export' import { formatDecodedSourceMapMarkdown, formatSourceMapMarkdownFixture, @@ -89,16 +92,28 @@ describe('source map fixtures', () => { string, { name: string - keep?: boolean - ignoreExportAllDeclaration?: boolean + options?: Partial }[] > = { './fixtures/source-map/proxy-export/export-all-ignore.js': [ - { name: 'proxy-export', ignoreExportAllDeclaration: true }, + { + name: 'proxy-export', + options: { ignoreExportAllDeclaration: true }, + }, ], './fixtures/source-map/proxy-export/keep.js': [ { name: 'proxy-export' }, - { name: 'proxy-export-keep', keep: true }, + { name: 'proxy-export-keep', options: { keep: true } }, + ], + './fixtures/source-map/proxy-export/filter-value-node.js': [ + { + name: 'proxy-export-filtered', + options: { + filter: (_name, meta) => + meta.valueNode?.type !== 'ObjectExpression' && + meta.valueNode?.type !== 'ArrayExpression', + }, + }, ], } for (const [file, load] of Object.entries(proxyExportFixtures)) { @@ -108,7 +123,7 @@ describe('source map fixtures', () => { const variants = proxyExportFixtureVariants[file] ?? [ { name: 'proxy-export' }, ] - const outputs = variants.map(({ name, ...options }) => { + const outputs = variants.map(({ name, options }) => { const result = transformProxyExport(ast, { code: input, ...options, diff --git a/packages/plugin-rsc/src/transforms/wrap-export.test.ts b/packages/plugin-rsc/src/transforms/wrap-export.test.ts index 58d01d77c..0d745600c 100644 --- a/packages/plugin-rsc/src/transforms/wrap-export.test.ts +++ b/packages/plugin-rsc/src/transforms/wrap-export.test.ts @@ -369,8 +369,8 @@ export default Page; test('filter value node', async () => { const input = `\ export const action = async () => {} -export const metadata = {} -export const tags = [] +export const objectValue = {} +export const arrayValue = [] ` const ast = await parseAstAsync(input) const result = transformWrapExport(input, ast, { @@ -384,8 +384,8 @@ export const tags = [] expect(result.exportNames).toEqual(['action']) expect(result.output.toString()).toMatchInlineSnapshot(` "let action = async () => {} - export const metadata = {} - export const tags = [] + export const objectValue = {} + export const arrayValue = [] action = /* #__PURE__ */ $$wrap(action, "action"); export { action }; "