diff --git a/packages/plugin-rsc/e2e/use-cache-callable.test.ts b/packages/plugin-rsc/e2e/use-cache-callable.test.ts index 5a2ae9be7..7e3e55027 100644 --- a/packages/plugin-rsc/e2e/use-cache-callable.test.ts +++ b/packages/plugin-rsc/e2e/use-cache-callable.test.ts @@ -13,6 +13,23 @@ test.describe('build', () => { }) function defineTests(f: Fixture) { + test('argument admission and capture identity', async ({ page }) => { + using _errors = expectNoPageError(page) + await page.goto(f.url('/argument-admission')) + + await expect(page.getByTestId('fixed-extra-admission')).toHaveText( + 'excluded', + ) + await expect(page.getByTestId('fixed-declared-admission')).toHaveText( + 'included', + ) + await expect(page.getByTestId('fixed-implementation-arguments')).toHaveText( + 'preserved', + ) + await expect(page.getByTestId('rest-admission')).toHaveText('included') + await expect(page.getByTestId('capture-admission')).toHaveText('included') + }) + test('inline directive', async ({ page }) => { using _errors = expectNoPageError(page) await page.goto(f.url()) 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..dc6acff70 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 @@ -4,6 +4,7 @@ import { transformDirectiveProxyExport, transformHoistInlineDirective, transformWrapExport, + type FunctionParameters, } from '@vitejs/plugin-rsc/transforms' import { parseAstAsync, type Plugin } from 'vite' @@ -29,9 +30,19 @@ export function callableCachePlugin(): Plugin { const environmentName = this.environment.name if (environmentName === 'rsc') { - const runtime = (value: string, name: string) => + const runtime = ( + value: string, + name: string, + meta: { + parameters?: FunctionParameters + boundArgumentCount?: number + }, + ) => `$$ReactServer.registerServerReference(` + - `$$cacheWrapper(${value}),` + + `$$cacheWrapper(${value}, ${JSON.stringify({ + parameters: meta.parameters, + boundArgumentCount: meta.boundArgumentCount ?? 0, + })}),` + `${JSON.stringify(reference.referenceKey)},` + `${JSON.stringify(name)})` const result = hasDirective(ast.body, directive) @@ -51,6 +62,10 @@ export function callableCachePlugin(): Plugin { rejectNonAsyncFunction: true, hoistRuntime: true, runtime, + encode: (value) => + `$$Encryption.encryptActionBoundArgs(${value})`, + decode: (value) => + `await $$Encryption.decryptActionBoundArgs(${value})`, }) if (!result.output.hasChanged()) { manager.serverReferences.deleteClaim(pluginName, id) @@ -63,6 +78,7 @@ export function callableCachePlugin(): Plugin { }) result.output.prepend( `import $$cacheWrapper from "/src/framework/use-cache-runtime";\n` + + `import * as $$Encryption from "@vitejs/plugin-rsc/utils/encryption-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/argument-admission/server.tsx b/packages/plugin-rsc/examples/use-cache-callable/src/features/argument-admission/server.tsx new file mode 100644 index 000000000..e36e76e7a --- /dev/null +++ b/packages/plugin-rsc/examples/use-cache-callable/src/features/argument-admission/server.tsx @@ -0,0 +1,60 @@ +async function fixed(declared = 'default') { + 'use cache' + return `${declared}:${String(arguments[1])}:${crypto.randomUUID()}` +} + +async function rest(declared: string, ...remaining: string[]) { + 'use cache' + return `${declared}:${remaining.join(':')}:${crypto.randomUUID()}` +} + +function withCapture(captured: string) { + return async function cached(declared: string) { + 'use cache' + return `${captured}:${declared}:${crypto.randomUUID()}` + } +} + +export async function ArgumentAdmission() { + const callFixed = fixed as (...args: string[]) => Promise + const fixedFirst = await callFixed('same', 'first extra') + const fixedSecond = await callFixed('same', 'second extra') + const fixedDifferent = await callFixed('different', 'second extra') + + const restFirst = await rest('same', 'first extra') + const restSecond = await rest('same', 'second extra') + + const capturedAlpha = withCapture('alpha') + const capturedAlphaAgain = withCapture('alpha') + const capturedBeta = withCapture('beta') + const captureFirst = await capturedAlpha('same') + const captureSecond = await capturedAlphaAgain('same') + const captureDifferent = await capturedBeta('same') + + return ( +
+
Undeclared fixed-signature extras
+
+ {fixedFirst === fixedSecond ? 'excluded' : 'included'} +
+
Declared fixed-signature arguments
+
+ {fixedSecond !== fixedDifferent ? 'included' : 'excluded'} +
+
Complete implementation arguments
+
+ {fixedFirst.startsWith('same:first extra:') ? 'preserved' : 'truncated'} +
+
Rest arguments
+
+ {restFirst !== restSecond ? 'included' : 'excluded'} +
+
Decoded captures
+
+ {captureFirst === captureSecond && captureSecond !== captureDifferent + ? 'included' + : 'excluded'} +
+
+ ) +} 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..8b1c8de86 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 @@ -1,3 +1,4 @@ +import { decryptActionBoundArgs } from '@vitejs/plugin-rsc/rsc' import { createClientTemporaryReferenceSet, createFromReadableStream, @@ -19,7 +20,15 @@ let cachedFnCacheEntries = new WeakMap< Record> >() -export default function cacheWrapper(fn: (...args: any[]) => Promise) { +type CacheFunctionMetadata = { + parameters?: { count: number; hasRest: boolean } + boundArgumentCount: number +} + +export default function cacheWrapper( + fn: (...args: any[]) => Promise, + metadata: CacheFunctionMetadata, +) { if (cachedFnMap.has(fn)) { return cachedFnMap.get(fn)! } @@ -31,17 +40,36 @@ export default function cacheWrapper(fn: (...args: any[]) => Promise) { cachedFnCacheEntries.set(cachedFn, cacheEntries) } - // Serialize arguments to a cache key via `encodeReply` from `react-server-dom/client`. + const boundArguments = args.slice(0, metadata.boundArgumentCount) + const invocationArguments = args.slice(metadata.boundArgumentCount) + const decodedBoundArguments = + metadata.boundArgumentCount === 0 + ? [] + : ((await decryptActionBoundArgs(boundArguments[0])) as unknown[]) + const admittedInvocationArguments = + !metadata.parameters || metadata.parameters.hasRest + ? invocationArguments + : invocationArguments.slice(0, metadata.parameters.count) + + // Serialize admitted arguments to a cache key via `encodeReply` from `react-server-dom/client`. // NOTE: using `renderToReadableStream` here for arguments serialization would end up // serializing react elements (e.g. children props), which causes // those arguments to be included as a cache key and it doesn't achieve // "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 encodedCacheKey = await encodeReply( + [...decodedBoundArguments, ...admittedInvocationArguments], + { + temporaryReferences: clientTemporaryReferences, + }, + ) + const serializedCacheKey = await replyToCacheKey(encodedCacheKey) + + // Keep the complete generated argument list for implementation invocation. const encodedArguments = await encodeReply(args, { temporaryReferences: clientTemporaryReferences, }) - const serializedCacheKey = await replyToCacheKey(encodedArguments) // cache `fn` result as stream // (cache value is promise so that it dedupes concurrent async calls) 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..2ffa14a99 100644 --- a/packages/plugin-rsc/examples/use-cache-callable/src/root.tsx +++ b/packages/plugin-rsc/examples/use-cache-callable/src/root.tsx @@ -1,8 +1,16 @@ +import { ArgumentAdmission } from './features/argument-admission/server' 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' const routes = [ + { + path: '/argument-admission', + title: 'Argument admission', + description: + 'Source parameters and decoded closure captures determine cache identity.', + Component: ArgumentAdmission, + }, { path: '/inline-directive', title: 'Inline directive', diff --git a/packages/plugin-rsc/src/transforms/hoist.test.ts b/packages/plugin-rsc/src/transforms/hoist.test.ts index 265f84062..f1fa65948 100644 --- a/packages/plugin-rsc/src/transforms/hoist.test.ts +++ b/packages/plugin-rsc/src/transforms/hoist.test.ts @@ -493,11 +493,11 @@ export async function kv() { }), ).toMatchInlineSnapshot(` " - export const none = /* #__PURE__ */ $$register($$hoist_0_none, "", "$$hoist_0_none", {"directiveMatch":["use cache",null]}); + export const none = /* #__PURE__ */ $$register($$hoist_0_none, "", "$$hoist_0_none", {"directiveMatch":["use cache",null],"parameters":{"count":0,"hasRest":false},"boundArgumentCount":0}); - export const fs = /* #__PURE__ */ $$register($$hoist_1_fs, "", "$$hoist_1_fs", {"directiveMatch":["use cache: fs",": fs"]}); + export const fs = /* #__PURE__ */ $$register($$hoist_1_fs, "", "$$hoist_1_fs", {"directiveMatch":["use cache: fs",": fs"],"parameters":{"count":0,"hasRest":false},"boundArgumentCount":0}); - export const kv = /* #__PURE__ */ $$register($$hoist_2_kv, "", "$$hoist_2_kv", {"directiveMatch":["use cache: kv",": kv"]}); + export const kv = /* #__PURE__ */ $$register($$hoist_2_kv, "", "$$hoist_2_kv", {"directiveMatch":["use cache: kv",": kv"],"parameters":{"count":0,"hasRest":false},"boundArgumentCount":0}); ;async function $$hoist_0_none() { "use cache"; @@ -520,6 +520,69 @@ export async function kv() { `) }) + it('reports source parameters and generated bound argument count', async () => { + const input = ` +async function noCapture(first = 1, { second }) { + "use server"; +} + +function outer(firstCapture, secondCapture) { + async function fixed(first = 1, { second }) { + "use server"; + return [firstCapture, secondCapture, first, second]; + } + async function rest(first, ...remaining) { + "use server"; + return [firstCapture, first, remaining]; + } +} +` + const ast = await parseAstAsync(input) + + function collect(encoded: boolean) { + const metadata: unknown[] = [] + transformHoistInlineDirective(input, ast, { + directive: 'use server', + runtime: (value, _name, meta) => { + metadata.push(meta) + return value + }, + encode: encoded ? (value) => `encode(${value})` : undefined, + decode: encoded ? (value) => `decode(${value})` : undefined, + }) + return metadata.map(({ directiveMatch: _, ...meta }: any) => meta) + } + + expect(collect(false)).toEqual([ + { + parameters: { count: 2, hasRest: false }, + boundArgumentCount: 0, + }, + { + parameters: { count: 2, hasRest: false }, + boundArgumentCount: 2, + }, + { + parameters: { count: 2, hasRest: true }, + boundArgumentCount: 1, + }, + ]) + expect(collect(true)).toEqual([ + { + parameters: { count: 2, hasRest: false }, + boundArgumentCount: 0, + }, + { + parameters: { count: 2, hasRest: false }, + boundArgumentCount: 1, + }, + { + parameters: { count: 2, hasRest: true }, + boundArgumentCount: 1, + }, + ]) + }) + it('no ending new line', async () => { const input = `\ export async function test() { diff --git a/packages/plugin-rsc/src/transforms/hoist.ts b/packages/plugin-rsc/src/transforms/hoist.ts index 8f72cfad1..e4c8eec5b 100644 --- a/packages/plugin-rsc/src/transforms/hoist.ts +++ b/packages/plugin-rsc/src/transforms/hoist.ts @@ -9,8 +9,16 @@ import type { import { walk } from 'estree-walker' import MagicString from 'magic-string' import type { ESTree } from 'vite' +import type { FunctionParameters } from './module-export-scan' import { buildScopeTree, type ScopeTree } from './scope' +export type InlineDirectiveMeta = { + directiveMatch: RegExpMatchArray + parameters: FunctionParameters + /** Number of generated leading arguments bound before source arguments. */ + boundArgumentCount: number +} + /** * Turns an inline directive function into a module-level registered function. * Conceptually: @@ -90,11 +98,7 @@ export function transformHoistInlineDirective( rejectNonAsyncFunction, ...options }: { - runtime: ( - value: string, - name: string, - meta: { directiveMatch: RegExpMatchArray }, - ) => string + runtime: (value: string, name: string, meta: InlineDirectiveMeta) => string directive: string | RegExp rejectNonAsyncFunction?: boolean encode?: (value: string) => string @@ -166,6 +170,14 @@ export function transformHoistInlineDirective( // function. At the original call site, registration below binds the // corresponding values in the same order. const bindVars = getBindVars(node, scopeTree) + const parameters: FunctionParameters = { + count: node.params.length, + hasRest: node.params.some( + (parameter) => parameter.type === 'RestElement', + ), + } + const boundArgumentCount = + bindVars.length > 0 ? (options.encode ? 1 : bindVars.length) : 0 let newParams = [ ...bindVars.map((b) => b.root), ...node.params.map((n) => input.slice(n.start, n.end)), @@ -206,7 +218,7 @@ export function transformHoistInlineDirective( const runtimeCode = `/* #__PURE__ */ ${runtime( implementationName, newName, - { directiveMatch: match }, + { directiveMatch: match, parameters, boundArgumentCount }, )}` if (options.hoistRuntime) { runtimeHoists.push( diff --git a/packages/plugin-rsc/src/transforms/module-export-effect.test.ts b/packages/plugin-rsc/src/transforms/module-export-effect.test.ts index fa9fcd53c..f66f16659 100644 --- a/packages/plugin-rsc/src/transforms/module-export-effect.test.ts +++ b/packages/plugin-rsc/src/transforms/module-export-effect.test.ts @@ -122,6 +122,7 @@ export default async function Page() {} }) test('runtime export meta', async () => { + const parameters = { count: 0, hasRest: false } const examples: [input: string, expected: unknown[]][] = [ [ `export function Fn() {}`, @@ -129,6 +130,7 @@ export default async function Page() {} { declName: 'Fn', isFunction: true, + parameters, }, ], ], @@ -147,6 +149,7 @@ export default async function Page() {} { declName: 'Arrow', isFunction: true, + parameters, }, ], ], @@ -156,6 +159,7 @@ export default async function Page() {} { declName: 'FnExpression', isFunction: true, + parameters, }, ], ], @@ -204,6 +208,7 @@ export default async function Page() {} { declName: 'MultiFn', isFunction: true, + parameters, }, { declName: 'MultiValue', @@ -218,10 +223,11 @@ export default async function Page() {} { declName: 'Page', isFunction: true, + parameters, }, ], ], - [`export default function () {}`, [{ isFunction: true }]], + [`export default function () {}`, [{ isFunction: true, parameters }]], [ `export default class Page {}`, [ @@ -232,13 +238,13 @@ export default async function Page() {} ], ], [`export default class {}`, [{ isFunction: false }]], - [`export default () => {}`, [{ isFunction: true }]], + [`export default () => {}`, [{ isFunction: true, parameters }]], [`export default 1`, [{ isFunction: false }]], [ `const Page = () => {}; export default Page`, - [{ defaultExportIdentifierName: 'Page' }], + [{ defaultExportIdentifierName: 'Page', parameters }], ], - [`const id = async () => {}; export { id }`, [{}]], + [`const id = async () => {}; export { id }`, [{ parameters }]], [`export { id } from './dep'`, [{}]], ] diff --git a/packages/plugin-rsc/src/transforms/module-export-scan.test.ts b/packages/plugin-rsc/src/transforms/module-export-scan.test.ts index 054dbb193..77bda10ea 100644 --- a/packages/plugin-rsc/src/transforms/module-export-scan.test.ts +++ b/packages/plugin-rsc/src/transforms/module-export-scan.test.ts @@ -140,3 +140,163 @@ export { "remote name" as remote } from './dep' }, ]) }) + +test('reports statically known source parameters', async () => { + const ast = await parseAstAsync(` +export async function declared(value = 1, { nested }) {} +export const rest = async (value, ...remaining) => {} +async function local(value) {} +export { local as renamed } +export default local +export { remote } from './dep' +export const unknown = getValue() +`) + + const parameters = scanModuleExports(ast).flatMap((group) => { + if (group.type === 'declaration') { + return [[group.export.exportName, group.export.meta.parameters]] as const + } + if (group.type === 'variable-declaration') { + return group.declarators.flatMap((declarator) => + declarator.exports.map( + (entry) => [entry.exportName, entry.meta.parameters] as const, + ), + ) + } + if (group.type === 'specifiers') { + return group.exports.map( + (entry) => [entry.exportName, entry.meta.parameters] as const, + ) + } + if (group.type === 'default') { + return [['default', group.meta.parameters]] as const + } + return [] + }) + + expect(new Map(parameters)).toEqual( + new Map([ + ['declared', { count: 2, hasRest: false }], + ['rest', { count: 2, hasRest: true }], + ['renamed', { count: 1, hasRest: false }], + ['default', { count: 1, hasRest: false }], + ['remote', undefined], + ['unknown', undefined], + ]), + ) +}) + +test('omits parameters for reassigned function bindings', async () => { + const ast = await parseAstAsync(` +export let direct = async (value) => {} +direct = async (value, extra) => {} + +async function local(value) {} +local = async (value, extra) => {} +export { local as renamed } +export default local +`) + + const groups = scanModuleExports(ast) + const [direct, renamed, defaultExport] = groups + expect(direct?.type).toBe('variable-declaration') + expect(renamed?.type).toBe('specifiers') + expect(defaultExport?.type).toBe('default') + if ( + direct?.type !== 'variable-declaration' || + renamed?.type !== 'specifiers' || + defaultExport?.type !== 'default' + ) { + throw new Error('unexpected export groups') + } + expect(direct.declarators[0]?.exports[0]?.meta.parameters).toBeUndefined() + expect(renamed.exports[0]?.meta.parameters).toBeUndefined() + expect(defaultExport.meta.parameters).toBeUndefined() +}) + +test('does not assign initializer parameters to destructured exports', async () => { + const ast = await parseAstAsync( + `export const { fn } = async (first, second) => {}`, + ) + + const [group] = scanModuleExports(ast) + expect(group?.type).toBe('variable-declaration') + if (group?.type !== 'variable-declaration') { + throw new Error('unexpected export group') + } + expect(group.declarators[0]?.exports[0]?.meta.parameters).toBeUndefined() +}) + +test('omits parameters for module var rebinding', async () => { + const ast = await parseAstAsync(` +var repeated = async (value) => {} +var repeated = async (value, extra) => {} +export { repeated } + +var iterated = async (value) => {} +for (var iterated of source) {} +export { iterated } +`) + + const groups = scanModuleExports(ast).filter( + (group) => group.type === 'specifiers', + ) + expect(groups).toHaveLength(2) + expect(groups[0]?.exports[0]?.meta.parameters).toBeUndefined() + expect(groups[1]?.exports[0]?.meta.parameters).toBeUndefined() +}) + +test('omits parameters for assignments from default parameter scope', async () => { + const ast = await parseAstAsync(` +const reassigned = async (value) => {} +function trigger(value = (reassigned = async (value, extra) => {})) { + var reassigned +} +export { reassigned } + +const stable = async (value) => {} +function shadow(stable = (stable = other)) {} +export { stable } +`) + + const groups = scanModuleExports(ast).filter( + (group) => group.type === 'specifiers', + ) + expect(groups).toHaveLength(2) + expect(groups[0]?.exports[0]?.meta.parameters).toBeUndefined() + expect(groups[1]?.exports[0]?.meta.parameters).toEqual({ + count: 1, + hasRest: false, + }) +}) + +test('omits parameters when direct eval can reassign module bindings', async () => { + const directEvalAst = await parseAstAsync(` +const mutable = async (value) => {} +eval(source) +export { mutable } +`) + const shadowedEvalAst = await parseAstAsync(` +const stable = async (value) => {} +function run(eval) { + eval(source) +} +export { stable } +`) + + const [directEval] = scanModuleExports(directEvalAst) + const [shadowedEval] = scanModuleExports(shadowedEvalAst) + expect(directEval?.type).toBe('specifiers') + expect(shadowedEval?.type).toBe('specifiers') + if ( + directEval?.type !== 'specifiers' || + shadowedEval?.type !== 'specifiers' + ) { + throw new Error('unexpected export groups') + } + expect(directEval.exports[0]?.meta.parameters).toBeUndefined() + expect(shadowedEval.exports[0]?.meta.parameters).toEqual({ + count: 1, + hasRest: false, + }) +}) diff --git a/packages/plugin-rsc/src/transforms/module-export-scan.ts b/packages/plugin-rsc/src/transforms/module-export-scan.ts index 92b3b3bac..1298cd5c2 100644 --- a/packages/plugin-rsc/src/transforms/module-export-scan.ts +++ b/packages/plugin-rsc/src/transforms/module-export-scan.ts @@ -4,16 +4,26 @@ import type { ExportDefaultDeclaration, ExportNamedDeclaration, ExportSpecifier, + ArrowFunctionExpression, FunctionDeclaration, + FunctionExpression, + Identifier, ClassDeclaration, Node, Program, VariableDeclaration, VariableDeclarator, } from 'estree' +import { walk } from 'estree-walker' import type { ESTree } from 'vite' +import { buildScopeTree } from './scope' import { extractNames } from './utils' +export type FunctionParameters = { + count: number + hasRest: boolean +} + export type ModuleExportMeta = { /** * The source node that evaluates to the exported value when directly @@ -25,6 +35,8 @@ export type ModuleExportMeta = { * - `undefined` for export specifiers and re-exports. */ valueNode?: Node | ExportDefaultDeclaration['declaration'] + /** Source parameter shape when the exported function is statically known. */ + parameters?: FunctionParameters // TODO: followings are used only for internal `transformRscCssExport`. // should probably simplify to use `valueNode` directly and remove these. /** @@ -129,6 +141,11 @@ export function scanModuleExports( ): ModuleExportGroup[] { const ast = viteAst as unknown as Program const groups: ModuleExportGroup[] = [] + const reassignedNames = getReassignedModuleBindings(ast) + const localFunctionParameters = getLocalFunctionParameters( + ast, + reassignedNames, + ) for (const node of ast.body) { if (node.type === 'ExportNamedDeclaration') { @@ -157,6 +174,10 @@ export function scanModuleExports( declName: name, isFunction, valueNode: declarator.init ?? undefined, + ...(declarator.id.type !== 'Identifier' || + reassignedNames.has(name) + ? {} + : getFunctionParametersMeta(declarator.init)), }, })), } @@ -178,6 +199,9 @@ export function scanModuleExports( declName: name, isFunction: getIsFunction(node.declaration), valueNode: node.declaration, + ...(reassignedNames.has(name) + ? {} + : getFunctionParametersMeta(node.declaration)), }, }, }) @@ -201,7 +225,15 @@ export function scanModuleExports( specifier.exported.type === 'Identifier' ? specifier.exported.name : '__unsupported_string_export__', - meta: {}, + meta: groupParametersMeta( + node.source + ? undefined + : localFunctionParameters.get( + specifier.local.type === 'Identifier' + ? specifier.local.name + : '', + ), + ), } }), }) @@ -227,12 +259,18 @@ export function scanModuleExports( declName: node.declaration.id.name, isFunction: getIsFunction(node.declaration), valueNode: node.declaration, + ...(reassignedNames.has(node.declaration.id.name) + ? {} + : getFunctionParametersMeta(node.declaration)), } } else if (node.declaration.type === 'Identifier') { kind = 'identifier' meta = { defaultExportIdentifierName: node.declaration.name, valueNode: node.declaration, + ...groupParametersMeta( + localFunctionParameters.get(node.declaration.name), + ), } } else { // export default function () {} @@ -241,6 +279,7 @@ export function scanModuleExports( meta = { isFunction: getIsFunction(node.declaration), valueNode: node.declaration, + ...getFunctionParametersMeta(node.declaration), } } groups.push({ type: 'default', kind, node, localName, meta }) @@ -250,6 +289,205 @@ export function scanModuleExports( return groups } +function getLocalFunctionParameters( + ast: Program, + reassignedNames: Set, +): Map { + const result = new Map() + + for (const statement of ast.body) { + const declaration = + statement.type === 'ExportNamedDeclaration' || + statement.type === 'ExportDefaultDeclaration' + ? statement.declaration + : statement + if (declaration?.type === 'FunctionDeclaration' && declaration.id) { + if (!reassignedNames.has(declaration.id.name)) { + result.set(declaration.id.name, getFunctionParameters(declaration)!) + } + } else if (declaration?.type === 'VariableDeclaration') { + for (const declarator of declaration.declarations) { + const parameters = getFunctionParameters(declarator.init) + if ( + declarator.id.type === 'Identifier' && + parameters && + !reassignedNames.has(declarator.id.name) + ) { + result.set(declarator.id.name, parameters) + } + } + } + } + + return result +} + +function getReassignedModuleBindings(ast: Program): Set { + const scopeTree = buildScopeTree(ast) + const result = new Set() + const moduleVarNames = new Set() + const functionStack: ( + | FunctionDeclaration + | FunctionExpression + | ArrowFunctionExpression + )[] = [] + + function add(ids: Identifier[]) { + for (const id of ids) { + const declaredScope = scopeTree.referenceToDeclaredScope.get(id) + if (declaredScope === scopeTree.moduleScope) { + result.add(id.name) + continue + } + + // The shared scope tree currently resolves default parameter expressions + // against body `var` declarations. Conservatively treat that ambiguous + // binding as the same-named module binding unless a parameter shadows it. + const parameterOwner = functionStack.findLast((fn) => + fn.params.some( + (parameter) => parameter.start <= id.start && id.end <= parameter.end, + ), + ) + if ( + parameterOwner && + declaredScope === scopeTree.nodeScope.get(parameterOwner) && + scopeTree.moduleScope.declarations.has(id.name) && + !parameterOwner.params.some((parameter) => + extractNames(parameter).includes(id.name), + ) + ) { + result.add(id.name) + } + } + } + + walk(ast, { + enter(node) { + if ( + (node.type === 'ForInStatement' || node.type === 'ForOfStatement') && + node.left.type === 'VariableDeclaration' && + node.left.kind === 'var' && + functionStack.length === 0 + ) { + for (const declarator of node.left.declarations) { + for (const name of extractNames(declarator.id)) { + result.add(name) + } + } + } + if ( + node.type === 'VariableDeclaration' && + node.kind === 'var' && + functionStack.length === 0 + ) { + for (const declarator of node.declarations) { + for (const name of extractNames(declarator.id)) { + if (moduleVarNames.has(name)) { + result.add(name) + } + moduleVarNames.add(name) + } + } + } + if (node.type === 'AssignmentExpression') { + add(getAssignedIdentifiers(node.left)) + } else if ( + node.type === 'CallExpression' && + node.callee.type === 'Identifier' && + node.callee.name === 'eval' && + !scopeTree.referenceToDeclaredScope.has(node.callee) + ) { + for (const name of scopeTree.moduleScope.declarations) { + result.add(name) + } + } else if ( + node.type === 'UpdateExpression' && + node.argument.type === 'Identifier' + ) { + add([node.argument]) + } else if ( + (node.type === 'ForInStatement' || node.type === 'ForOfStatement') && + node.left.type !== 'VariableDeclaration' + ) { + add(getAssignedIdentifiers(node.left)) + } + if ( + node.type === 'FunctionDeclaration' || + node.type === 'FunctionExpression' || + node.type === 'ArrowFunctionExpression' + ) { + functionStack.push(node) + } + }, + leave(node) { + if ( + node.type === 'FunctionDeclaration' || + node.type === 'FunctionExpression' || + node.type === 'ArrowFunctionExpression' + ) { + functionStack.pop() + } + }, + }) + + return result +} + +function getAssignedIdentifiers( + pattern: import('estree').Pattern, +): Identifier[] { + if (pattern.type === 'Identifier') { + return [pattern] + } + if (pattern.type === 'ObjectPattern') { + return pattern.properties.flatMap((property) => + getAssignedIdentifiers( + property.type === 'RestElement' ? property : property.value, + ), + ) + } + if (pattern.type === 'ArrayPattern') { + return pattern.elements.flatMap((element) => + element ? getAssignedIdentifiers(element) : [], + ) + } + if (pattern.type === 'RestElement') { + return getAssignedIdentifiers(pattern.argument) + } + if (pattern.type === 'AssignmentPattern') { + return getAssignedIdentifiers(pattern.left) + } + return [] +} + +function getFunctionParametersMeta( + node: Node | ExportDefaultDeclaration['declaration'] | null | undefined, +): { parameters?: FunctionParameters } { + return groupParametersMeta(getFunctionParameters(node)) +} + +function groupParametersMeta(parameters: FunctionParameters | undefined): { + parameters?: FunctionParameters +} { + return parameters ? { parameters } : {} +} + +function getFunctionParameters( + node: Node | ExportDefaultDeclaration['declaration'] | null | undefined, +): FunctionParameters | undefined { + if ( + node?.type !== 'FunctionDeclaration' && + node?.type !== 'FunctionExpression' && + node?.type !== 'ArrowFunctionExpression' + ) { + return + } + return { + count: node.params.length, + hasRest: node.params.some((parameter) => parameter.type === 'RestElement'), + } +} + function getIsFunction( node: Node | ExportDefaultDeclaration['declaration'], ): boolean | undefined { diff --git a/packages/plugin-rsc/src/transforms/wrap-export.test.ts b/packages/plugin-rsc/src/transforms/wrap-export.test.ts index 58d01d77c..82342acc5 100644 --- a/packages/plugin-rsc/src/transforms/wrap-export.test.ts +++ b/packages/plugin-rsc/src/transforms/wrap-export.test.ts @@ -451,16 +451,20 @@ export default cached; }) test('runtime export meta', async () => { + const parameters = { count: 0, hasRest: false } const examples: [input: string, expected: unknown[]][] = [ - [`export function Fn() {}`, [{ isFunction: true, declName: 'Fn' }]], + [ + `export function Fn() {}`, + [{ isFunction: true, declName: 'Fn', parameters }], + ], [`export class Cls {}`, [{ isFunction: false, declName: 'Cls' }]], [ `export const Arrow = () => {}`, - [{ isFunction: true, declName: 'Arrow' }], + [{ isFunction: true, declName: 'Arrow', parameters }], ], [ `export const FnExpression = function () {}`, - [{ isFunction: true, declName: 'FnExpression' }], + [{ isFunction: true, declName: 'FnExpression', parameters }], ], [ `export const Literal = 1`, @@ -484,7 +488,7 @@ export default cached; [ `export const MultiFn = () => {}, MultiValue = 1, MultiUnknown = getValue()`, [ - { isFunction: true, declName: 'MultiFn' }, + { isFunction: true, declName: 'MultiFn', parameters }, { isFunction: false, declName: 'MultiValue' }, { declName: 'MultiUnknown' }, ], @@ -495,10 +499,11 @@ export default cached; { isFunction: true, declName: 'Page', + parameters, }, ], ], - [`export default function () {}`, [{ isFunction: true }]], + [`export default function () {}`, [{ isFunction: true, parameters }]], [ `export default class Page {}`, [ @@ -514,6 +519,7 @@ export default cached; [ { isFunction: true, + parameters, }, ], ], @@ -530,10 +536,11 @@ export default cached; [ { defaultExportIdentifierName: 'Page', + parameters, }, ], ], - [`const id = async () => {}; export { id }`, [{}]], + [`const id = async () => {}; export { id }`, [{ parameters }]], [`export { id } from './dep'`, [{}]], ]