diff --git a/packages/plugin-rsc/src/transforms/wrap-export.test.ts b/packages/plugin-rsc/src/transforms/wrap-export.test.ts index c3cb7473a..1e63d1e59 100644 --- a/packages/plugin-rsc/src/transforms/wrap-export.test.ts +++ b/packages/plugin-rsc/src/transforms/wrap-export.test.ts @@ -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) + } + }) }) diff --git a/packages/plugin-rsc/src/transforms/wrap-export.ts b/packages/plugin-rsc/src/transforms/wrap-export.ts index f5328e967..4e7bd6c86 100644 --- a/packages/plugin-rsc/src/transforms/wrap-export.ts +++ b/packages/plugin-rsc/src/transforms/wrap-export.ts @@ -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, @@ -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 @@ -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, @@ -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( @@ -94,20 +125,19 @@ 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, @@ -115,26 +145,27 @@ export function transformWrapExport( '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 } @@ -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 ( @@ -211,7 +241,7 @@ 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 @@ -219,13 +249,19 @@ export function transformWrapExport( 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) } } @@ -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 + } +}