Skip to content
Merged
162 changes: 162 additions & 0 deletions packages/plugin-rsc/src/transforms/wrap-export.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -314,4 +314,166 @@ export default Page;
"
`)
})

test('filtered exports are not validated or reported', async () => {
const input = `
export const revalidate = 1;
export default async function Page() {}
`
const ast = await parseAstAsync(input)
const result = transformWrapExport(input, ast, {
runtime: (value, name) => `$$wrap(${value}, ${JSON.stringify(name)})`,
rejectNonAsyncFunction: true,
filter: (name) => name !== 'revalidate',
})
expect(result.exportNames).toEqual(['default'])
expect(result.output.toString()).toMatchInlineSnapshot(`
"
let revalidate = 1;
async function Page() {}
export { revalidate };
;
const $$wrap_Page = /* #__PURE__ */ $$wrap(Page, "default");
export { $$wrap_Page as default };
"
`)
})

test('filtered default exports are not validated or reported', async () => {
const input = `export default 1;`
const ast = await parseAstAsync(input)
const result = transformWrapExport(input, ast, {
runtime: (value, name) => `$$wrap(${value}, ${JSON.stringify(name)})`,
rejectNonAsyncFunction: true,
filter: () => false,
})
expect(result.exportNames).toEqual([])
expect(result.output.toString()).toMatchInlineSnapshot(`
"const $$default = 1;;
export { $$default as default };
"
`)
})

test('unknown identifier exports remain eligible for wrapping', async () => {
const input = `
const cached = async () => 1;
export default cached;
`
const ast = await parseAstAsync(input)
const result = transformWrapExport(input, ast, {
runtime: (value, name) => `$$wrap(${value}, ${JSON.stringify(name)})`,
filter: (_name, meta) => meta.isFunction !== false,
})
expect(result.exportNames).toEqual(['default'])
expect(result.output.toString()).toMatchInlineSnapshot(`
"
const cached = async () => 1;
const $$default = cached;
;
const $$wrap_$$default = /* #__PURE__ */ $$wrap($$default, "default");
export { $$wrap_$$default as default };
"
`)
})

test('runtime export meta', async () => {
const examples: [input: string, expected: unknown[]][] = [
[`export function Fn() {}`, [{ isFunction: true, declName: 'Fn' }]],
[`export class Cls {}`, [{ isFunction: false, declName: 'Cls' }]],
[
`export const Arrow = () => {}`,
[{ isFunction: true, declName: 'Arrow' }],
],
[
`export const FnExpression = function () {}`,
[{ isFunction: true, declName: 'FnExpression' }],
],
[
`export const Literal = 1`,
[{ isFunction: false, declName: 'Literal' }],
],
[
`export const ObjectValue = {}`,
[{ isFunction: false, declName: 'ObjectValue' }],
],
[
`export const ArrayValue = []`,
[{ isFunction: false, declName: 'ArrayValue' }],
],
[
`export const ClassValue = class {}`,
[{ isFunction: false, declName: 'ClassValue' }],
],
[`export const Unknown = getValue()`, [{ declName: 'Unknown' }]],
[`export const { id } = getValue()`, [{ declName: 'id' }]],
[`export const [a, b] = []`, [{ declName: 'a' }, { declName: 'b' }]],
[
`export const MultiFn = () => {}, MultiValue = 1, MultiUnknown = getValue()`,
[
{ isFunction: true, declName: 'MultiFn' },
{ isFunction: false, declName: 'MultiValue' },
{ declName: 'MultiUnknown' },
],
],
[
`export default function Page() {}`,
[
{
isFunction: true,
declName: 'Page',
},
],
],
[`export default function () {}`, [{ isFunction: true }]],
[
`export default class Page {}`,
[
{
isFunction: false,
declName: 'Page',
},
],
],
[`export default class {}`, [{ isFunction: false }]],
[
`export default () => {}`,
[
{
isFunction: true,
},
],
],
[
`export default 1`,
[
{
isFunction: false,
},
],
],
[
`const Page = () => {}; export default Page`,
[
{
defaultExportIdentifierName: 'Page',
},
],
],
[`const id = async () => {}; export { id }`, [{}]],
[`export { id } from './dep'`, [{}]],
]

for (const [input, expected] of examples) {
const actual: unknown[] = []
const ast = await parseAstAsync(input)
transformWrapExport(input, ast, {
runtime(value, _name, meta) {
actual.push(meta)
return value
},
})
expect(actual).toEqual(expected)
}
})
})
141 changes: 99 additions & 42 deletions packages/plugin-rsc/src/transforms/wrap-export.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,41 @@
import { tinyassert } from '@hiogawa/utils'
import type { Program } from 'estree'
import type { ExportDefaultDeclaration, Node, Program } from 'estree'
import MagicString from 'magic-string'
import { extractNames, validateNonAsyncFunction } from './utils'

type ExportMeta = {
/**
* The local declaration name when statically available.
*
* - `"Page"` for `export function Page() {}`
* - `"Page"` for `export const Page = () => {}`
* - `undefined` for `export default () => {}`
* - `undefined` for `export { Page }`
*/
declName?: string
/**
* Whether the exported value is statically known to be a function.
*
* - `true` for `export const Page = () => {}`
* - `false` for `export const value = 1`
* - `undefined` for `export const value = getValue()`
* - `undefined` for `export default Page`
*/
isFunction?: boolean
/**
* The local identifier referenced by a default export.
* The RSC CSS transform uses this to detect a component by its capitalized
* local name and preserve that name on the generated wrapper.
*
* - `"Page"` for `const Page = () => {}; export default Page`
* - `undefined` for `export default function Page() {}`
* - `undefined` for `export default () => {}`
*/
defaultExportIdentifierName?: string
}

type ExportWithMeta = { name: string; meta: ExportMeta }

export type TransformWrapExportFilter = (
name: string,
meta: ExportMeta,
Expand All @@ -34,12 +61,16 @@ export function transformWrapExport(
const toAppend: string[] = []
const filter = options.filter ?? (() => true)

function wrapSimple(
start: number,
end: number,
exports: { name: string; meta: ExportMeta }[],
) {
exportNames.push(...exports.map((e) => e.name))
function wrapSimple(start: number, end: number, exports: ExportWithMeta[]) {
const filteredExports = exports.map((item) => ({
...item,
shouldWrap: filter(item.name, item.meta),
}))
exportNames.push(
...filteredExports
.filter((item) => item.shouldWrap)
.map((item) => item.name),
)
// update code and move to preserve `registerServerReference` position
// e.g.
// input
Expand All @@ -49,9 +80,9 @@ export function transformWrapExport(
// async function f() {}
// f = registerServerReference(f, ...) << maps to original "export" token
// export { f } <<
const newCode = exports
const newCode = filteredExports
.map((e) => [
filter(e.name, e.meta) &&
e.shouldWrap &&
`${e.name} = /* #__PURE__ */ ${options.runtime(
e.name,
e.name,
Expand All @@ -67,11 +98,11 @@ export function transformWrapExport(
}

function wrapExport(name: string, exportName: string, meta: ExportMeta = {}) {
exportNames.push(exportName)
if (!filter(exportName, meta)) {
toAppend.push(`export { ${name} as ${exportName} }`)
return
}
exportNames.push(exportName)

toAppend.push(
`const $$wrap_${name} = /* #__PURE__ */ ${options.runtime(
Expand All @@ -94,47 +125,47 @@ export function transformWrapExport(
/**
* export function foo() {}
*/
validateNonAsyncFunction(options, node.declaration)
const name = node.declaration.id.name
wrapSimple(node.start, node.declaration.start, [
{ name, meta: { isFunction: true, declName: name } },
])
const meta: ExportMeta = {
isFunction: getIsFunction(node.declaration),
declName: name,
}
if (filter(name, meta)) {
validateNonAsyncFunction(options, node.declaration)
}
wrapSimple(node.start, node.declaration.start, [{ name, meta }])
} 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 (node.declaration.kind === 'const') {
output.update(
node.declaration.start,
node.declaration.start + 5,
'let',
)
}
const names = node.declaration.declarations.flatMap((decl) =>
extractNames(decl.id),
)
// treat only simple single decl as function
let isFunction = false
if (node.declaration.declarations.length === 1) {
const decl = node.declaration.declarations[0]!
isFunction =
decl.id.type === 'Identifier' &&
(decl.init?.type === 'ArrowFunctionExpression' ||
decl.init?.type === 'FunctionExpression')
}
wrapSimple(
node.start,
node.declaration.start,
names.map((name) => ({
const exports: ExportWithMeta[] = []
for (const decl of node.declaration.declarations) {
const isFunction =
decl.id.type === 'Identifier' && decl.init
? getIsFunction(decl.init)
: undefined
const declarationExports: ExportWithMeta[] = extractNames(
decl.id,
).map((name) => ({
name,
meta: { isFunction, declName: name },
})),
)
}))
exports.push(...declarationExports)
if (
decl.init &&
declarationExports.some(({ name, meta }) => filter(name, meta))
) {
validateNonAsyncFunction(options, decl.init)
}
}
wrapSimple(node.start, node.declaration.start, exports)
} else {
node.declaration satisfies never
}
Expand Down Expand Up @@ -198,9 +229,8 @@ export function transformWrapExport(
* export default () => {}
*/
if (node.type === 'ExportDefaultDeclaration') {
validateNonAsyncFunction(options, node.declaration)
let localName: string
let isFunction = false
let isFunction: boolean | undefined
let declName: string | undefined
let defaultExportIdentifierName: string | undefined
if (
Expand All @@ -211,21 +241,27 @@ export function transformWrapExport(
// preserve name scope for `function foo() {}` and `class Foo {}`
localName = node.declaration.id.name
output.remove(node.start, node.declaration.start)
isFunction = node.declaration.type === 'FunctionDeclaration'
isFunction = getIsFunction(node.declaration)
declName = node.declaration.id.name
} else {
// otherwise we can introduce new variable
localName = '$$default'
output.update(node.start, node.declaration.start, 'const $$default = ')
if (node.declaration.type === 'Identifier') {
defaultExportIdentifierName = node.declaration.name
} else {
isFunction = getIsFunction(node.declaration)
}
}
wrapExport(localName, 'default', {
const defaultMeta: ExportMeta = {
isFunction,
declName,
defaultExportIdentifierName,
})
}
if (filter('default', defaultMeta)) {
validateNonAsyncFunction(options, node.declaration)
}
wrapExport(localName, 'default', defaultMeta)
}
}

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

return { exportNames, output }
}

function getIsFunction(
node: Node | ExportDefaultDeclaration['declaration'],
): boolean | undefined {
if (
node.type === 'FunctionDeclaration' ||
node.type === 'ArrowFunctionExpression' ||
node.type === 'FunctionExpression'
) {
return true
}
if (
node.type === 'ClassDeclaration' ||
node.type === 'Literal' ||
node.type === 'ObjectExpression' ||
node.type === 'ArrayExpression' ||
node.type === 'ClassExpression'
) {
return false
}
}
Loading