Skip to content

Commit 24d6376

Browse files
james-elicxhi-ogawaOpenCode
authored
fix(rsc): fix transformWrapExport with filter and rejectNonAsyncFunction (#1254)
Co-authored-by: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Co-authored-by: OpenCode <noreply@opencode.ai> Co-authored-by: Hiroshi Ogawa <hi.ogawa.zz@gmail.com>
1 parent 0f5348d commit 24d6376

2 files changed

Lines changed: 261 additions & 42 deletions

File tree

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

Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -314,4 +314,166 @@ export default Page;
314314
"
315315
`)
316316
})
317+
318+
test('filtered exports are not validated or reported', async () => {
319+
const input = `
320+
export const revalidate = 1;
321+
export default async function Page() {}
322+
`
323+
const ast = await parseAstAsync(input)
324+
const result = transformWrapExport(input, ast, {
325+
runtime: (value, name) => `$$wrap(${value}, ${JSON.stringify(name)})`,
326+
rejectNonAsyncFunction: true,
327+
filter: (name) => name !== 'revalidate',
328+
})
329+
expect(result.exportNames).toEqual(['default'])
330+
expect(result.output.toString()).toMatchInlineSnapshot(`
331+
"
332+
let revalidate = 1;
333+
async function Page() {}
334+
export { revalidate };
335+
;
336+
const $$wrap_Page = /* #__PURE__ */ $$wrap(Page, "default");
337+
export { $$wrap_Page as default };
338+
"
339+
`)
340+
})
341+
342+
test('filtered default exports are not validated or reported', async () => {
343+
const input = `export default 1;`
344+
const ast = await parseAstAsync(input)
345+
const result = transformWrapExport(input, ast, {
346+
runtime: (value, name) => `$$wrap(${value}, ${JSON.stringify(name)})`,
347+
rejectNonAsyncFunction: true,
348+
filter: () => false,
349+
})
350+
expect(result.exportNames).toEqual([])
351+
expect(result.output.toString()).toMatchInlineSnapshot(`
352+
"const $$default = 1;;
353+
export { $$default as default };
354+
"
355+
`)
356+
})
357+
358+
test('unknown identifier exports remain eligible for wrapping', async () => {
359+
const input = `
360+
const cached = async () => 1;
361+
export default cached;
362+
`
363+
const ast = await parseAstAsync(input)
364+
const result = transformWrapExport(input, ast, {
365+
runtime: (value, name) => `$$wrap(${value}, ${JSON.stringify(name)})`,
366+
filter: (_name, meta) => meta.isFunction !== false,
367+
})
368+
expect(result.exportNames).toEqual(['default'])
369+
expect(result.output.toString()).toMatchInlineSnapshot(`
370+
"
371+
const cached = async () => 1;
372+
const $$default = cached;
373+
;
374+
const $$wrap_$$default = /* #__PURE__ */ $$wrap($$default, "default");
375+
export { $$wrap_$$default as default };
376+
"
377+
`)
378+
})
379+
380+
test('runtime export meta', async () => {
381+
const examples: [input: string, expected: unknown[]][] = [
382+
[`export function Fn() {}`, [{ isFunction: true, declName: 'Fn' }]],
383+
[`export class Cls {}`, [{ isFunction: false, declName: 'Cls' }]],
384+
[
385+
`export const Arrow = () => {}`,
386+
[{ isFunction: true, declName: 'Arrow' }],
387+
],
388+
[
389+
`export const FnExpression = function () {}`,
390+
[{ isFunction: true, declName: 'FnExpression' }],
391+
],
392+
[
393+
`export const Literal = 1`,
394+
[{ isFunction: false, declName: 'Literal' }],
395+
],
396+
[
397+
`export const ObjectValue = {}`,
398+
[{ isFunction: false, declName: 'ObjectValue' }],
399+
],
400+
[
401+
`export const ArrayValue = []`,
402+
[{ isFunction: false, declName: 'ArrayValue' }],
403+
],
404+
[
405+
`export const ClassValue = class {}`,
406+
[{ isFunction: false, declName: 'ClassValue' }],
407+
],
408+
[`export const Unknown = getValue()`, [{ declName: 'Unknown' }]],
409+
[`export const { id } = getValue()`, [{ declName: 'id' }]],
410+
[`export const [a, b] = []`, [{ declName: 'a' }, { declName: 'b' }]],
411+
[
412+
`export const MultiFn = () => {}, MultiValue = 1, MultiUnknown = getValue()`,
413+
[
414+
{ isFunction: true, declName: 'MultiFn' },
415+
{ isFunction: false, declName: 'MultiValue' },
416+
{ declName: 'MultiUnknown' },
417+
],
418+
],
419+
[
420+
`export default function Page() {}`,
421+
[
422+
{
423+
isFunction: true,
424+
declName: 'Page',
425+
},
426+
],
427+
],
428+
[`export default function () {}`, [{ isFunction: true }]],
429+
[
430+
`export default class Page {}`,
431+
[
432+
{
433+
isFunction: false,
434+
declName: 'Page',
435+
},
436+
],
437+
],
438+
[`export default class {}`, [{ isFunction: false }]],
439+
[
440+
`export default () => {}`,
441+
[
442+
{
443+
isFunction: true,
444+
},
445+
],
446+
],
447+
[
448+
`export default 1`,
449+
[
450+
{
451+
isFunction: false,
452+
},
453+
],
454+
],
455+
[
456+
`const Page = () => {}; export default Page`,
457+
[
458+
{
459+
defaultExportIdentifierName: 'Page',
460+
},
461+
],
462+
],
463+
[`const id = async () => {}; export { id }`, [{}]],
464+
[`export { id } from './dep'`, [{}]],
465+
]
466+
467+
for (const [input, expected] of examples) {
468+
const actual: unknown[] = []
469+
const ast = await parseAstAsync(input)
470+
transformWrapExport(input, ast, {
471+
runtime(value, _name, meta) {
472+
actual.push(meta)
473+
return value
474+
},
475+
})
476+
expect(actual).toEqual(expected)
477+
}
478+
})
317479
})

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

Lines changed: 99 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,41 @@
11
import { tinyassert } from '@hiogawa/utils'
2-
import type { Program } from 'estree'
2+
import type { ExportDefaultDeclaration, Node, Program } from 'estree'
33
import MagicString from 'magic-string'
44
import { extractNames, validateNonAsyncFunction } from './utils'
55

66
type ExportMeta = {
7+
/**
8+
* The local declaration name when statically available.
9+
*
10+
* - `"Page"` for `export function Page() {}`
11+
* - `"Page"` for `export const Page = () => {}`
12+
* - `undefined` for `export default () => {}`
13+
* - `undefined` for `export { Page }`
14+
*/
715
declName?: string
16+
/**
17+
* Whether the exported value is statically known to be a function.
18+
*
19+
* - `true` for `export const Page = () => {}`
20+
* - `false` for `export const value = 1`
21+
* - `undefined` for `export const value = getValue()`
22+
* - `undefined` for `export default Page`
23+
*/
824
isFunction?: boolean
25+
/**
26+
* The local identifier referenced by a default export.
27+
* The RSC CSS transform uses this to detect a component by its capitalized
28+
* local name and preserve that name on the generated wrapper.
29+
*
30+
* - `"Page"` for `const Page = () => {}; export default Page`
31+
* - `undefined` for `export default function Page() {}`
32+
* - `undefined` for `export default () => {}`
33+
*/
934
defaultExportIdentifierName?: string
1035
}
1136

37+
type ExportWithMeta = { name: string; meta: ExportMeta }
38+
1239
export type TransformWrapExportFilter = (
1340
name: string,
1441
meta: ExportMeta,
@@ -34,12 +61,16 @@ export function transformWrapExport(
3461
const toAppend: string[] = []
3562
const filter = options.filter ?? (() => true)
3663

37-
function wrapSimple(
38-
start: number,
39-
end: number,
40-
exports: { name: string; meta: ExportMeta }[],
41-
) {
42-
exportNames.push(...exports.map((e) => e.name))
64+
function wrapSimple(start: number, end: number, exports: ExportWithMeta[]) {
65+
const filteredExports = exports.map((item) => ({
66+
...item,
67+
shouldWrap: filter(item.name, item.meta),
68+
}))
69+
exportNames.push(
70+
...filteredExports
71+
.filter((item) => item.shouldWrap)
72+
.map((item) => item.name),
73+
)
4374
// update code and move to preserve `registerServerReference` position
4475
// e.g.
4576
// input
@@ -49,9 +80,9 @@ export function transformWrapExport(
4980
// async function f() {}
5081
// f = registerServerReference(f, ...) << maps to original "export" token
5182
// export { f } <<
52-
const newCode = exports
83+
const newCode = filteredExports
5384
.map((e) => [
54-
filter(e.name, e.meta) &&
85+
e.shouldWrap &&
5586
`${e.name} = /* #__PURE__ */ ${options.runtime(
5687
e.name,
5788
e.name,
@@ -67,11 +98,11 @@ export function transformWrapExport(
6798
}
6899

69100
function wrapExport(name: string, exportName: string, meta: ExportMeta = {}) {
70-
exportNames.push(exportName)
71101
if (!filter(exportName, meta)) {
72102
toAppend.push(`export { ${name} as ${exportName} }`)
73103
return
74104
}
105+
exportNames.push(exportName)
75106

76107
toAppend.push(
77108
`const $$wrap_${name} = /* #__PURE__ */ ${options.runtime(
@@ -94,47 +125,47 @@ export function transformWrapExport(
94125
/**
95126
* export function foo() {}
96127
*/
97-
validateNonAsyncFunction(options, node.declaration)
98128
const name = node.declaration.id.name
99-
wrapSimple(node.start, node.declaration.start, [
100-
{ name, meta: { isFunction: true, declName: name } },
101-
])
129+
const meta: ExportMeta = {
130+
isFunction: getIsFunction(node.declaration),
131+
declName: name,
132+
}
133+
if (filter(name, meta)) {
134+
validateNonAsyncFunction(options, node.declaration)
135+
}
136+
wrapSimple(node.start, node.declaration.start, [{ name, meta }])
102137
} else if (node.declaration.type === 'VariableDeclaration') {
103138
/**
104139
* export const foo = 1, bar = 2
105140
*/
106-
for (const decl of node.declaration.declarations) {
107-
if (decl.init) {
108-
validateNonAsyncFunction(options, decl.init)
109-
}
110-
}
111141
if (node.declaration.kind === 'const') {
112142
output.update(
113143
node.declaration.start,
114144
node.declaration.start + 5,
115145
'let',
116146
)
117147
}
118-
const names = node.declaration.declarations.flatMap((decl) =>
119-
extractNames(decl.id),
120-
)
121-
// treat only simple single decl as function
122-
let isFunction = false
123-
if (node.declaration.declarations.length === 1) {
124-
const decl = node.declaration.declarations[0]!
125-
isFunction =
126-
decl.id.type === 'Identifier' &&
127-
(decl.init?.type === 'ArrowFunctionExpression' ||
128-
decl.init?.type === 'FunctionExpression')
129-
}
130-
wrapSimple(
131-
node.start,
132-
node.declaration.start,
133-
names.map((name) => ({
148+
const exports: ExportWithMeta[] = []
149+
for (const decl of node.declaration.declarations) {
150+
const isFunction =
151+
decl.id.type === 'Identifier' && decl.init
152+
? getIsFunction(decl.init)
153+
: undefined
154+
const declarationExports: ExportWithMeta[] = extractNames(
155+
decl.id,
156+
).map((name) => ({
134157
name,
135158
meta: { isFunction, declName: name },
136-
})),
137-
)
159+
}))
160+
exports.push(...declarationExports)
161+
if (
162+
decl.init &&
163+
declarationExports.some(({ name, meta }) => filter(name, meta))
164+
) {
165+
validateNonAsyncFunction(options, decl.init)
166+
}
167+
}
168+
wrapSimple(node.start, node.declaration.start, exports)
138169
} else {
139170
node.declaration satisfies never
140171
}
@@ -198,9 +229,8 @@ export function transformWrapExport(
198229
* export default () => {}
199230
*/
200231
if (node.type === 'ExportDefaultDeclaration') {
201-
validateNonAsyncFunction(options, node.declaration)
202232
let localName: string
203-
let isFunction = false
233+
let isFunction: boolean | undefined
204234
let declName: string | undefined
205235
let defaultExportIdentifierName: string | undefined
206236
if (
@@ -211,21 +241,27 @@ export function transformWrapExport(
211241
// preserve name scope for `function foo() {}` and `class Foo {}`
212242
localName = node.declaration.id.name
213243
output.remove(node.start, node.declaration.start)
214-
isFunction = node.declaration.type === 'FunctionDeclaration'
244+
isFunction = getIsFunction(node.declaration)
215245
declName = node.declaration.id.name
216246
} else {
217247
// otherwise we can introduce new variable
218248
localName = '$$default'
219249
output.update(node.start, node.declaration.start, 'const $$default = ')
220250
if (node.declaration.type === 'Identifier') {
221251
defaultExportIdentifierName = node.declaration.name
252+
} else {
253+
isFunction = getIsFunction(node.declaration)
222254
}
223255
}
224-
wrapExport(localName, 'default', {
256+
const defaultMeta: ExportMeta = {
225257
isFunction,
226258
declName,
227259
defaultExportIdentifierName,
228-
})
260+
}
261+
if (filter('default', defaultMeta)) {
262+
validateNonAsyncFunction(options, node.declaration)
263+
}
264+
wrapExport(localName, 'default', defaultMeta)
229265
}
230266
}
231267

@@ -235,3 +271,24 @@ export function transformWrapExport(
235271

236272
return { exportNames, output }
237273
}
274+
275+
function getIsFunction(
276+
node: Node | ExportDefaultDeclaration['declaration'],
277+
): boolean | undefined {
278+
if (
279+
node.type === 'FunctionDeclaration' ||
280+
node.type === 'ArrowFunctionExpression' ||
281+
node.type === 'FunctionExpression'
282+
) {
283+
return true
284+
}
285+
if (
286+
node.type === 'ClassDeclaration' ||
287+
node.type === 'Literal' ||
288+
node.type === 'ObjectExpression' ||
289+
node.type === 'ArrayExpression' ||
290+
node.type === 'ClassExpression'
291+
) {
292+
return false
293+
}
294+
}

0 commit comments

Comments
 (0)