Skip to content

Commit d426ce7

Browse files
committed
feat(rsc): expose server function parameter metadata
1 parent 223b164 commit d426ce7

5 files changed

Lines changed: 195 additions & 14 deletions

File tree

packages/plugin-rsc/src/plugins/server-function-directives.test.ts

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,21 @@ function cacheDirective(
8282
}
8383
}
8484

85+
function parameterWrap(
86+
contexts: Array<{
87+
name: string
88+
location: string
89+
parameters: { count: number; hasRest: boolean } | undefined
90+
}>,
91+
) {
92+
return cacheDirective({
93+
wrap: ({ value, name, location, parameters }) => {
94+
contexts.push({ name, location, parameters })
95+
return `cache(${value})`
96+
},
97+
})
98+
}
99+
85100
describe('vitePluginServerFunctionDirectives', () => {
86101
it('hoists, wraps, and registers inline functions in RSC', async () => {
87102
const { manager, run } = createHarness([cacheDirective()])
@@ -352,4 +367,101 @@ export async function action() {
352367
expect(server?.map).toMatchObject({ version: 3 })
353368
expect(client?.map).toMatchObject({ version: 3 })
354369
})
370+
371+
it('exposes declared parameter metadata for inline functions', async () => {
372+
const contexts: Parameters<typeof parameterWrap>[0] = []
373+
const { run } = createHarness([parameterWrap(contexts)])
374+
await run(`
375+
export async function getData(value, { offset }, ...rest) {
376+
"use cache";
377+
return [value, offset, rest];
378+
}
379+
`)
380+
expect(contexts).toEqual([
381+
expect.objectContaining({
382+
location: 'inline',
383+
parameters: { count: 3, hasRest: true },
384+
}),
385+
])
386+
387+
contexts.length = 0
388+
await run(`
389+
export async function action() {
390+
"use cache";
391+
return 1;
392+
}
393+
`)
394+
expect(contexts).toEqual([
395+
expect.objectContaining({
396+
parameters: { count: 0, hasRest: false },
397+
}),
398+
])
399+
})
400+
401+
it('exposes declared parameter metadata for module exports', async () => {
402+
const contexts: Parameters<typeof parameterWrap>[0] = []
403+
const { run } = createHarness([parameterWrap(contexts)])
404+
await run(`
405+
"use cache";
406+
export async function direct(value, offset) { return value + offset }
407+
const local = async (value) => value;
408+
export { local };
409+
export { external } from "./external";
410+
`)
411+
expect(contexts).toEqual(
412+
expect.arrayContaining([
413+
expect.objectContaining({
414+
name: 'direct',
415+
parameters: { count: 2, hasRest: false },
416+
}),
417+
expect.objectContaining({
418+
name: 'local',
419+
parameters: { count: 1, hasRest: false },
420+
}),
421+
expect.objectContaining({ name: 'external', parameters: undefined }),
422+
]),
423+
)
424+
})
425+
426+
it('exposes declared parameter metadata for default exports', async () => {
427+
const contexts: Parameters<typeof parameterWrap>[0] = []
428+
const { run } = createHarness([parameterWrap(contexts)])
429+
await run(`
430+
"use cache";
431+
export default async function Page({ params }, ...rest) {
432+
return [params, rest];
433+
}
434+
`)
435+
expect(contexts).toEqual([
436+
expect.objectContaining({
437+
name: 'default',
438+
parameters: { count: 2, hasRest: true },
439+
}),
440+
])
441+
442+
contexts.length = 0
443+
await run(`
444+
"use cache";
445+
const Page = async ({ params }) => params;
446+
export default Page;
447+
`)
448+
expect(contexts).toEqual([
449+
expect.objectContaining({
450+
name: 'default',
451+
parameters: { count: 1, hasRest: false },
452+
}),
453+
])
454+
455+
contexts.length = 0
456+
await run(`
457+
"use cache";
458+
export default createPage();
459+
`)
460+
expect(contexts).toEqual([
461+
expect.objectContaining({
462+
name: 'default',
463+
parameters: undefined,
464+
}),
465+
])
466+
})
355467
})

packages/plugin-rsc/src/plugins/server-function-directives.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
hasDirective,
88
transformDirectiveProxyExport,
99
transformServerActionServer,
10+
type FunctionParameters,
1011
type TransformWrapExportFilter,
1112
} from '../transforms'
1213
import { hashString } from './utils'
@@ -30,6 +31,8 @@ export type ServerFunctionDirectiveContext = {
3031
location: 'inline' | 'module'
3132
/** Whether an inline function closes over values from an outer scope. */
3233
hasBoundArgs: boolean
34+
/** Declared parameter shape when statically known. */
35+
parameters?: FunctionParameters
3336
/** Export metadata. Only present for module-level directives. */
3437
meta?: Parameters<TransformWrapExportFilter>[1]
3538
}
@@ -292,7 +295,7 @@ export function vitePluginServerFunctionDirectives(options: Options): Plugin {
292295
moduleDirective,
293296
moduleRuntime: (value, name, meta) => {
294297
if (!moduleMatch) return value
295-
return `$$ReactServer.registerServerReference(${definition.wrap({ value, name, id, directiveMatch: moduleMatch, location: 'module', hasBoundArgs: false, meta })}, ${JSON.stringify(normalizedId)}, ${JSON.stringify(name)})`
298+
return `$$ReactServer.registerServerReference(${definition.wrap({ value, name, id, directiveMatch: moduleMatch, location: 'module', hasBoundArgs: false, parameters: meta.parameters, meta })}, ${JSON.stringify(normalizedId)}, ${JSON.stringify(name)})`
296299
},
297300
inlineRuntime: (value, name, meta) => {
298301
definition.validate?.({
@@ -308,6 +311,7 @@ export function vitePluginServerFunctionDirectives(options: Options): Plugin {
308311
directiveMatch: meta.directiveMatch,
309312
location: 'inline',
310313
hasBoundArgs: meta.hasBoundArgs,
314+
parameters: meta.parameters,
311315
})
312316

313317
if (useServerBoundary) return wrapped

packages/plugin-rsc/src/transforms/hoist.test.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -466,11 +466,11 @@ export async function kv() {
466466
}),
467467
).toMatchInlineSnapshot(`
468468
"
469-
export const none = /* #__PURE__ */ $$register($$hoist_0_none, "<id>", "$$hoist_0_none", {"directiveMatch":["use cache",null],"hasBoundArgs":false});
469+
export const none = /* #__PURE__ */ $$register($$hoist_0_none, "<id>", "$$hoist_0_none", {"directiveMatch":["use cache",null],"hasBoundArgs":false,"parameters":{"count":0,"hasRest":false}});
470470
471-
export const fs = /* #__PURE__ */ $$register($$hoist_1_fs, "<id>", "$$hoist_1_fs", {"directiveMatch":["use cache: fs",": fs"],"hasBoundArgs":false});
471+
export const fs = /* #__PURE__ */ $$register($$hoist_1_fs, "<id>", "$$hoist_1_fs", {"directiveMatch":["use cache: fs",": fs"],"hasBoundArgs":false,"parameters":{"count":0,"hasRest":false}});
472472
473-
export const kv = /* #__PURE__ */ $$register($$hoist_2_kv, "<id>", "$$hoist_2_kv", {"directiveMatch":["use cache: kv",": kv"],"hasBoundArgs":false});
473+
export const kv = /* #__PURE__ */ $$register($$hoist_2_kv, "<id>", "$$hoist_2_kv", {"directiveMatch":["use cache: kv",": kv"],"hasBoundArgs":false,"parameters":{"count":0,"hasRest":false}});
474474
475475
;async function $$hoist_0_none() {
476476
"use cache";

packages/plugin-rsc/src/transforms/hoist.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { walk } from 'estree-walker'
1111
import MagicString from 'magic-string'
1212
import { hashString } from '../plugins/utils'
1313
import { buildScopeTree, type ScopeTree } from './scope'
14+
import type { FunctionParameters } from './wrap-export'
1415

1516
export function transformHoistInlineDirective(
1617
input: string,
@@ -23,7 +24,11 @@ export function transformHoistInlineDirective(
2324
runtime: (
2425
value: string,
2526
name: string,
26-
meta: { directiveMatch: RegExpMatchArray; hasBoundArgs: boolean },
27+
meta: {
28+
directiveMatch: RegExpMatchArray
29+
hasBoundArgs: boolean
30+
parameters: FunctionParameters
31+
},
2732
) => string
2833
directive: string | RegExp
2934
rejectNonAsyncFunction?: boolean
@@ -177,6 +182,12 @@ export function transformHoistInlineDirective(
177182
let newCode = `/* #__PURE__ */ ${runtime(newName, newName, {
178183
directiveMatch: match,
179184
hasBoundArgs: bindVars.length > 0,
185+
parameters: {
186+
count: node.params.length,
187+
hasRest: node.params.some(
188+
(parameter) => parameter.type === 'RestElement',
189+
),
190+
},
180191
})}`
181192
if (bindVars.length > 0) {
182193
const bindArgs = options.encode

packages/plugin-rsc/src/transforms/wrap-export.ts

Lines changed: 63 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,16 @@ import type { Program } from 'estree'
33
import MagicString from 'magic-string'
44
import { extractNames, validateNonAsyncFunction } from './utils'
55

6-
type ExportMeta = {
6+
export type FunctionParameters = {
7+
count: number
8+
hasRest: boolean
9+
}
10+
11+
export type ExportMeta = {
712
declName?: string
813
isFunction?: boolean
914
defaultExportIdentifierName?: string
15+
parameters?: FunctionParameters
1016
}
1117

1218
export type TransformWrapExportFilter = (
@@ -33,6 +39,26 @@ export function transformWrapExport(
3339
const exportNames: string[] = []
3440
const toAppend: string[] = []
3541
const filter = options.filter ?? (() => true)
42+
const localFunctionParameters = new Map<string, FunctionParameters>()
43+
44+
for (const node of ast.body) {
45+
if (node.type === 'FunctionDeclaration' && node.id) {
46+
localFunctionParameters.set(node.id.name, getFunctionParameters(node))
47+
} else if (node.type === 'VariableDeclaration') {
48+
for (const declaration of node.declarations) {
49+
if (
50+
declaration.id.type === 'Identifier' &&
51+
(declaration.init?.type === 'ArrowFunctionExpression' ||
52+
declaration.init?.type === 'FunctionExpression')
53+
) {
54+
localFunctionParameters.set(
55+
declaration.id.name,
56+
getFunctionParameters(declaration.init),
57+
)
58+
}
59+
}
60+
}
61+
}
3662

3763
function wrapSimple(
3864
start: number,
@@ -103,7 +129,14 @@ export function transformWrapExport(
103129
* export function foo() {}
104130
*/
105131
const name = node.declaration.id.name
106-
const meta = { isFunction: true, declName: name }
132+
const meta = {
133+
isFunction: node.declaration.type === 'FunctionDeclaration',
134+
declName: name,
135+
parameters:
136+
node.declaration.type === 'FunctionDeclaration'
137+
? getFunctionParameters(node.declaration)
138+
: undefined,
139+
}
107140
if (filter(name, meta)) {
108141
validateNonAsyncFunction(options, node.declaration)
109142
}
@@ -124,6 +157,7 @@ export function transformWrapExport(
124157
)
125158
// treat only simple single decl as function
126159
let isFunction: boolean | undefined
160+
let parameters: FunctionParameters | undefined
127161
if (node.declaration.declarations.length === 1) {
128162
const decl = node.declaration.declarations[0]!
129163
if (decl.id.type === 'Identifier') {
@@ -132,6 +166,7 @@ export function transformWrapExport(
132166
decl.init?.type === 'FunctionExpression'
133167
) {
134168
isFunction = true
169+
parameters = getFunctionParameters(decl.init)
135170
} else if (
136171
decl.init?.type === 'Literal' ||
137172
decl.init?.type === 'ObjectExpression' ||
@@ -157,7 +192,7 @@ export function transformWrapExport(
157192
node.declaration.start,
158193
names.map((name) => ({
159194
name,
160-
meta: { isFunction, declName: name },
195+
meta: { isFunction, declName: name, parameters },
161196
})),
162197
)
163198
} else {
@@ -196,7 +231,12 @@ export function transformWrapExport(
196231
{ pos: spec.exported.start },
197232
)
198233
}
199-
wrapExport(spec.local.name, spec.exported.name)
234+
wrapExport(spec.local.name, spec.exported.name, {
235+
isFunction: localFunctionParameters.has(spec.local.name)
236+
? true
237+
: undefined,
238+
parameters: localFunctionParameters.get(spec.local.name),
239+
})
200240
}
201241
}
202242
}
@@ -223,6 +263,14 @@ export function transformWrapExport(
223263
* export default () => {}
224264
*/
225265
if (node.type === 'ExportDefaultDeclaration') {
266+
const parameters =
267+
node.declaration.type === 'FunctionDeclaration' ||
268+
node.declaration.type === 'FunctionExpression' ||
269+
node.declaration.type === 'ArrowFunctionExpression'
270+
? getFunctionParameters(node.declaration)
271+
: node.declaration.type === 'Identifier'
272+
? localFunctionParameters.get(node.declaration.name)
273+
: undefined
226274
let localName: string
227275
let isFunction: boolean | undefined
228276
let declName: string | undefined
@@ -261,15 +309,12 @@ export function transformWrapExport(
261309
isFunction,
262310
declName,
263311
defaultExportIdentifierName,
312+
parameters,
264313
}
265314
if (filter('default', defaultMeta)) {
266315
validateNonAsyncFunction(options, node.declaration)
267316
}
268-
wrapExport(localName, 'default', {
269-
isFunction,
270-
declName,
271-
defaultExportIdentifierName,
272-
})
317+
wrapExport(localName, 'default', defaultMeta)
273318
}
274319
}
275320

@@ -279,3 +324,12 @@ export function transformWrapExport(
279324

280325
return { exportNames, output }
281326
}
327+
328+
function getFunctionParameters(node: {
329+
params: import('estree').Pattern[]
330+
}): FunctionParameters {
331+
return {
332+
count: node.params.length,
333+
hasRest: node.params.some((parameter) => parameter.type === 'RestElement'),
334+
}
335+
}

0 commit comments

Comments
 (0)