diff --git a/packages/plugin-rsc/src/transforms/fixtures/source-map/wrap-export/dependent-declarators.js.map.snap.md b/packages/plugin-rsc/src/transforms/fixtures/source-map/wrap-export/dependent-declarators.js.map.snap.md index eef48cd07..09bc16669 100644 --- a/packages/plugin-rsc/src/transforms/fixtures/source-map/wrap-export/dependent-declarators.js.map.snap.md +++ b/packages/plugin-rsc/src/transforms/fixtures/source-map/wrap-export/dependent-declarators.js.map.snap.md @@ -6,9 +6,8 @@ (2:12) " first = async () => 'first action called',\n" --> (2:3) " first = async () => 'first action called',\n" (3:0) " second = first\n" --> (3:0) " second = first\n" (2:0) "export const first = async () => 'first action called',\n" --> (4:0) "first = /* #__PURE__ */ registerServerReference(first, \"first\");\n" -(2:0) "export const first = async () => 'first action called',\n" --> (5:0) "export { first };\n" -(2:0) "export const first = async () => 'first action called',\n" --> (6:0) "second = /* #__PURE__ */ registerServerReference(second, \"second\");\n" -(2:0) "export const first = async () => 'first action called',\n" --> (7:0) "export { second };\n" +(2:0) "export const first = async () => 'first action called',\n" --> (5:0) "second = /* #__PURE__ */ registerServerReference(second, \"second\");\n" +(2:0) "export const first = async () => 'first action called',\n" --> (6:0) "export { first, second };\n" ``` ## module-export-effect diff --git a/packages/plugin-rsc/src/transforms/fixtures/source-map/wrap-export/dependent-declarators.js.snap.md b/packages/plugin-rsc/src/transforms/fixtures/source-map/wrap-export/dependent-declarators.js.snap.md index e7a106b22..2a66ed25c 100644 --- a/packages/plugin-rsc/src/transforms/fixtures/source-map/wrap-export/dependent-declarators.js.snap.md +++ b/packages/plugin-rsc/src/transforms/fixtures/source-map/wrap-export/dependent-declarators.js.snap.md @@ -13,7 +13,7 @@ export const first = async () => 'first action called', **References:** first, second -[Source map visualization](https://evanw.github.io/source-map-visualization/#MjQ4ACd1c2Ugc2VydmVyJwoKbGV0IGZpcnN0ID0gYXN5bmMgKCkgPT4gJ2ZpcnN0IGFjdGlvbiBjYWxsZWQnLAogIHNlY29uZCA9IGZpcnN0CmZpcnN0ID0gLyogI19fUFVSRV9fICovIHJlZ2lzdGVyU2VydmVyUmVmZXJlbmNlKGZpcnN0LCAiZmlyc3QiKTsKZXhwb3J0IHsgZmlyc3QgfTsKc2Vjb25kID0gLyogI19fUFVSRV9fICovIHJlZ2lzdGVyU2VydmVyUmVmZXJlbmNlKHNlY29uZCwgInNlY29uZCIpOwpleHBvcnQgeyBzZWNvbmQgfTsKMzU4AHsidmVyc2lvbiI6Mywic291cmNlcyI6WyIiXSwic291cmNlc0NvbnRlbnQiOlsiJ3VzZSBzZXJ2ZXInXG5cbmV4cG9ydCBjb25zdCBmaXJzdCA9IGFzeW5jICgpID0+ICdmaXJzdCBhY3Rpb24gY2FsbGVkJyxcbiAgc2Vjb25kID0gZmlyc3RcbiJdLCJuYW1lcyI6W10sIm1hcHBpbmdzIjoiQUFBQSxDQUFDLEdBQUcsQ0FBQyxNQUFNOztBQUVKLEdBQUssQ0FBQyxLQUFLLENBQUMsQ0FBQyxDQUFDLEtBQUssQ0FBQyxDQUFDLENBQUMsQ0FBQyxDQUFDLENBQUMsQ0FBQyxDQUFDLEtBQUssQ0FBQyxNQUFNLENBQUMsTUFBTSxDQUFDO0FBQ3RELENBQUMsQ0FBQyxNQUFNLENBQUMsQ0FBQyxDQUFDO0FBRFg7QUFBQTtBQUFBO0FBQUE7In0=) +[Source map visualization](https://evanw.github.io/source-map-visualization/#MjM3ACd1c2Ugc2VydmVyJwoKbGV0IGZpcnN0ID0gYXN5bmMgKCkgPT4gJ2ZpcnN0IGFjdGlvbiBjYWxsZWQnLAogIHNlY29uZCA9IGZpcnN0CmZpcnN0ID0gLyogI19fUFVSRV9fICovIHJlZ2lzdGVyU2VydmVyUmVmZXJlbmNlKGZpcnN0LCAiZmlyc3QiKTsKc2Vjb25kID0gLyogI19fUFVSRV9fICovIHJlZ2lzdGVyU2VydmVyUmVmZXJlbmNlKHNlY29uZCwgInNlY29uZCIpOwpleHBvcnQgeyBmaXJzdCwgc2Vjb25kIH07CjM1MwB7InZlcnNpb24iOjMsInNvdXJjZXMiOlsiIl0sInNvdXJjZXNDb250ZW50IjpbIid1c2Ugc2VydmVyJ1xuXG5leHBvcnQgY29uc3QgZmlyc3QgPSBhc3luYyAoKSA9PiAnZmlyc3QgYWN0aW9uIGNhbGxlZCcsXG4gIHNlY29uZCA9IGZpcnN0XG4iXSwibmFtZXMiOltdLCJtYXBwaW5ncyI6IkFBQUEsQ0FBQyxHQUFHLENBQUMsTUFBTTs7QUFFSixHQUFLLENBQUMsS0FBSyxDQUFDLENBQUMsQ0FBQyxLQUFLLENBQUMsQ0FBQyxDQUFDLENBQUMsQ0FBQyxDQUFDLENBQUMsQ0FBQyxLQUFLLENBQUMsTUFBTSxDQUFDLE1BQU0sQ0FBQztBQUN0RCxDQUFDLENBQUMsTUFBTSxDQUFDLENBQUMsQ0FBQztBQURYO0FBQUE7QUFBQTsifQ==) ```js 'use server' @@ -21,9 +21,8 @@ export const first = async () => 'first action called', let first = async () => 'first action called', second = first first = /* #__PURE__ */ registerServerReference(first, "first"); -export { first }; second = /* #__PURE__ */ registerServerReference(second, "second"); -export { second }; +export { first, second }; ``` ## module-export-effect diff --git a/packages/plugin-rsc/src/transforms/module-export-scan.ts b/packages/plugin-rsc/src/transforms/module-export-scan.ts index d5ff110f1..92b3b3bac 100644 --- a/packages/plugin-rsc/src/transforms/module-export-scan.ts +++ b/packages/plugin-rsc/src/transforms/module-export-scan.ts @@ -147,6 +147,9 @@ export function scanModuleExports( : undefined return { node: declarator, + // uniformly handle destructured exports such as + // export const { foo, bar } = ... + // even though associated `meta` doesn't make sense anymore exports: extractNames(declarator.id).map((name) => ({ localName: name, exportName: name, diff --git a/packages/plugin-rsc/src/transforms/wrap-export.test.ts b/packages/plugin-rsc/src/transforms/wrap-export.test.ts index 976d511a9..58d01d77c 100644 --- a/packages/plugin-rsc/src/transforms/wrap-export.test.ts +++ b/packages/plugin-rsc/src/transforms/wrap-export.test.ts @@ -99,9 +99,8 @@ export const { x, y: [z] } = { x: 0, y: [1] }; " let { x, y: [z] } = { x: 0, y: [1] }; x = /* #__PURE__ */ $$wrap(x, "", "x"); - export { x }; z = /* #__PURE__ */ $$wrap(z, "", "z"); - export { z }; + export { x, z }; " `) }) @@ -235,8 +234,7 @@ export { a as aa }; a = /* #__PURE__ */ $$wrap(a, "", "a"); export { a }; b = /* #__PURE__ */ $$wrap(b, "", "b"); - export { b }; - export { b_no }; + export { b, b_no }; ; import { c as $$import_c } from "./c"; const $$wrap_$$import_c = /* #__PURE__ */ $$wrap($$import_c, "", "c"); @@ -247,6 +245,70 @@ export { a as aa }; `) }) + test.each([ + `export function action() {}`, + `export const action = () => {}`, + `const action = () => {}; export { action }`, + `export { action } from './dep'`, + `export default () => {}`, + ])('does not rewrite fully filtered export groups: %s', async (input) => { + const ast = await parseAstAsync(input) + const result = transformWrapExport(input, ast, { + runtime: (value) => `$$wrap(${value})`, + filter: () => false, + }) + + expect(result.exportNames).toEqual([]) + expect(result.output.hasChanged()).toBe(false) + expect(result.output.toString()).toBe(input) + }) + + test('preserves filtered export specifiers', async () => { + const input = `\ +const selected = 0, skipped = 1 +export { selected, skipped as renamed } +export { remote, ignored as forwarded } from './dep' +` + const result = await testTransform(input, { + filter: (name) => name === 'selected' || name === 'remote', + }) + + expect(result).toMatchInlineSnapshot(` + "const selected = 0, skipped = 1 + + + ; + const $$wrap_selected = /* #__PURE__ */ $$wrap(selected, \"\", \"selected\"); + export { $$wrap_selected as selected }; + export { skipped as renamed }; + import { remote as $$import_remote } from './dep'; + const $$wrap_$$import_remote = /* #__PURE__ */ $$wrap($$import_remote, \"\", \"remote\"); + export { $$wrap_$$import_remote as remote }; + export { ignored as forwarded } from './dep'; + " + `) + }) + + test('rejects selected uninitialized variables', async () => { + const input = `export let selected, skipped` + const ast = await parseAstAsync(input) + + expect(() => + transformWrapExport(input, ast, { + runtime: (value) => `$$wrap(${value})`, + rejectNonAsyncFunction: true, + filter: (name) => name === 'selected', + }), + ).toThrow('unsupported non async function') + + const filtered = transformWrapExport(input, ast, { + runtime: (value) => `$$wrap(${value})`, + rejectNonAsyncFunction: true, + filter: () => false, + }) + expect(filtered.output.hasChanged()).toBe(false) + }) + test('filter meta', async () => { const input = ` export const a = 0; @@ -259,11 +321,10 @@ export default function d() {} }) expect(result).toMatchInlineSnapshot(` " - let a = 0; + export const a = 0; let b = function() {} let c = () => {} function d() {} - export { a }; b = /* #__PURE__ */ $$wrap(b, "", "b"); export { b }; c = /* #__PURE__ */ $$wrap(c, "", "c"); @@ -282,13 +343,7 @@ export default () => {} const result = await testTransform(input, { filter: (_name, meta) => !!(meta.isFunction && meta.declName), }) - expect(result).toMatchInlineSnapshot(` - " - const $$default = () => {} - ; - export { $$default as default }; - " - `) + expect(result).toMatchInlineSnapshot(`false`) }) test('filter defaultExportIdentifierName', async () => { @@ -329,12 +384,10 @@ export const tags = [] expect(result.exportNames).toEqual(['action']) expect(result.output.toString()).toMatchInlineSnapshot(` "let action = async () => {} - let metadata = {} - let tags = [] + export const metadata = {} + export const tags = [] action = /* #__PURE__ */ $$wrap(action, "action"); export { action }; - export { metadata }; - export { tags }; " `) }) @@ -353,9 +406,8 @@ export default async function Page() {} expect(result.exportNames).toEqual(['default']) expect(result.output.toString()).toMatchInlineSnapshot(` " - let revalidate = 1; + export const revalidate = 1; async function Page() {} - export { revalidate }; ; const $$wrap_Page = /* #__PURE__ */ $$wrap(Page, "default"); export { $$wrap_Page as default }; @@ -372,11 +424,8 @@ export default async function Page() {} filter: () => false, }) expect(result.exportNames).toEqual([]) - expect(result.output.toString()).toMatchInlineSnapshot(` - "const $$default = 1;; - export { $$default as default }; - " - `) + expect(result.output.hasChanged()).toBe(false) + expect(result.output.toString()).toBe(input) }) test('unknown identifier exports remain eligible for wrapping', async () => { diff --git a/packages/plugin-rsc/src/transforms/wrap-export.ts b/packages/plugin-rsc/src/transforms/wrap-export.ts index af0914657..d7a92c690 100644 --- a/packages/plugin-rsc/src/transforms/wrap-export.ts +++ b/packages/plugin-rsc/src/transforms/wrap-export.ts @@ -6,7 +6,7 @@ import { type ModuleExportEntry, type ModuleExportMeta, } from './module-export-scan' -import { validateNonAsyncFunction } from './utils' +import { rejectNonAsyncFunction, validateNonAsyncFunction } from './utils' export type TransformWrapExportFilter = ( name: string, @@ -20,6 +20,43 @@ export type TransformWrapExportOptions = { filter?: TransformWrapExportFilter } +/** + * Replaces selected module-local export bindings with runtime wrappers. + * + * Conceptually: + * + * ```js + * export async function action() {} + * export const loader = async () => {} + * export default function Page() {} + * ``` + * + * becomes: + * + * ```js + * async function action() {} + * let loader = async () => {} + * function Page() {} + * + * action = __WRAP__(action, 'action') + * export { action } + * loader = __WRAP__(loader, 'loader') + * export { loader } + * const $$wrap_Page = __WRAP__(Page, 'default') + * export { $$wrap_Page as default } + * ``` + * + * Here, `__WRAP__(...)` represents the expression returned by `runtime`. + * Unlike `transformModuleExportWrap`, direct local references observe the + * wrapped value because the original binding itself is reassigned. + * + * Declaration rewrites are moved to the end of the module so each generated + * runtime call retains the original export token's source mapping. Wrappers for + * export specifiers and default exports are appended without explicit mappings. + * + * Generated `$$wrap_*` and `$$import_*` names are not deconflicted from user + * bindings, consistent with the other transform helpers. + */ export function transformWrapExport( input: string, viteAst: ESTree.Program, @@ -30,63 +67,75 @@ export function transformWrapExport( } { const output = new MagicString(input) const exportNames: string[] = [] - const toAppend: string[] = [] + const appendedCode: string[] = [] const filter = options.filter ?? (() => true) - function wrapSimple( + /** + * Strips a direct export declaration and emits assignments and exports at + * module end. All bindings are re-exported, but only selected bindings are + * assigned runtime wrappers. + * + * The rewritten source range is moved so generated runtime calls borrow the + * original export site's mapping: + * + * ```js + * // input + * export async function action() {} + * ^^^^^^ + * + * // output + * async function action() {} + * action = __WRAP__(action, 'action') << maps to the `export` token + * export { action } << + * ``` + */ + function emitWrappedAssignments( + // start/end represents original `export` token range start: number, end: number, exports: ModuleExportEntry[], + selectedExportNames: Set, ) { - const filteredExports = exports.map((item) => { - return { - ...item, - shouldWrap: filter(item.exportName, item.meta), - } - }) - exportNames.push( - ...filteredExports - .filter((item) => item.shouldWrap) - .map((item) => item.exportName), - ) - // update code and move to preserve `registerServerReference` position - // e.g. - // input - // export async function f() {} - // ^^^^^^ - // output - // async function f() {} - // f = registerServerReference(f, ...) << maps to original "export" token - // export { f } << - const newCode = filteredExports - .map((e) => [ - e.shouldWrap && - `${e.localName} = /* #__PURE__ */ ${options.runtime( - e.localName, - e.exportName, - e.meta, - )};\n`, - `export { ${e.localName} };\n`, - ]) - .flat() - .filter(Boolean) - .join('') + exportNames.push(...selectedExportNames) + const newCode = + exports + .map( + (e) => + selectedExportNames.has(e.exportName) && + `${e.localName} = /* #__PURE__ */ ${options.runtime( + e.localName, + e.exportName, + e.meta, + )};\n`, + ) + .filter(Boolean) + .join('') + + `export { ${exports.map((e) => e.localName).join(', ')} };\n` output.update(start, end, newCode) output.move(start, end, input.length) } - function wrapExport( + /** + * Emits a separate wrapper binding for exports that cannot reassign an + * existing direct declaration, such as export specifiers and defaults. + * + * ```js + * // existing source binding remains unchanged + * const local = init() + * export { local as renamed } // caller removes the original export + * + * // appended code + * const $$wrap_local = __WRAP__(local, 'renamed') + * export { $$wrap_local as renamed } + * ``` + */ + function emitWrappedBinding( name: string, exportName: string, meta: ModuleExportMeta = {}, ) { - if (!filter(exportName, meta)) { - toAppend.push(`export { ${name} as ${exportName} }`) - return - } exportNames.push(exportName) - - toAppend.push( + appendedCode.push( `const $$wrap_${name} = /* #__PURE__ */ ${options.runtime( name, exportName, @@ -98,12 +147,35 @@ export function transformWrapExport( for (const group of scanModuleExports(viteAst)) { if (group.type === 'declaration') { + // export function f() {} + // ⬇️ + // function f() {} << strip export + // f = __WRAP__(f, 'f') << emit + // export { f } << emit const entry = group.export - if (filter(entry.exportName, entry.meta)) { - validateNonAsyncFunction(options, group.declaration) - } - wrapSimple(group.node.start, group.declaration.start, [entry]) + if (!filter(entry.exportName, entry.meta)) continue + validateNonAsyncFunction(options, group.declaration) + emitWrappedAssignments( + group.node.start, + group.declaration.start, + [entry], + new Set([entry.exportName]), + ) } else if (group.type === 'variable-declaration') { + // export const selected = init(), skipped = init() + // ⬇️ + // let selected = init(), skipped = init() << strip export + // selected = __WRAP__(selected, 'selected') << emit + // export { selected, skipped } << emit + const exports = group.declarators.flatMap((item) => item.exports) + const selectedExportNames = new Set( + exports + .filter((entry) => filter(entry.exportName, entry.meta)) + .map((entry) => entry.exportName), + ) + if (selectedExportNames.size === 0) continue + + // change `const` to `let` to reassign local name if (group.declaration.kind === 'const') { output.update( group.declaration.start, @@ -111,50 +183,72 @@ export function transformWrapExport( 'let', ) } - const exports: ModuleExportEntry[] = [] for (const declarator of group.declarators) { - exports.push(...declarator.exports) if ( - declarator.node.init && - declarator.exports.some(({ exportName, meta }) => - filter(exportName, meta), + declarator.exports.some(({ exportName }) => + selectedExportNames.has(exportName), ) ) { - validateNonAsyncFunction(options, declarator.node.init) + if (declarator.node.init) { + validateNonAsyncFunction(options, declarator.node.init) + } else { + rejectNonAsyncFunction(options, declarator.node.start) + } } } - wrapSimple(group.node.start, group.declaration.start, exports) + emitWrappedAssignments( + group.node.start, + group.declaration.start, + exports, + selectedExportNames, + ) } else if (group.type === 'specifiers') { - if (group.node.source) { - output.remove(group.node.start, group.node.end) - for (const entry of group.exports) { - tinyassert(entry.node.local.type === 'Identifier') - if (entry.node.exported.type !== 'Identifier') { - throw Object.assign( - new Error('unsupported string literal export name'), - { pos: entry.node.exported.start }, - ) - } - toAppend.push( - `import { ${entry.localName} as $$import_${entry.localName} } from ${group.node.source.raw}`, + // export { selected as renamed, skipped } + // ⬇️ + // const $$wrap_selected = __WRAP__(selected, 'renamed') + // export { $$wrap_selected as renamed, skipped } + const skippedExports: string[] = [] + let selected = false + for (const entry of group.exports) { + tinyassert(entry.node.local.type === 'Identifier') + if (entry.node.exported.type !== 'Identifier') { + throw Object.assign( + new Error('unsupported string literal export name'), + { pos: entry.node.exported.start }, ) - wrapExport( - `$$import_${entry.localName}`, - entry.exportName, - entry.meta, + } + if (!filter(entry.exportName, entry.meta)) { + skippedExports.push( + entry.localName === entry.exportName + ? entry.localName + : `${entry.localName} as ${entry.exportName}`, ) + continue } - } else { + selected = true + + let binding = entry.localName + const source = group.node.source + if (source) { + // introduce local variable via renamed import + // export { remote as action } from './dep' + // ⬇️ + // import { remote as $$import_remote } from './dep' + binding = `$$import_${entry.localName}` + appendedCode.push( + // TODO: Preserve import attributes from the original re-export. + `import { ${entry.localName} as ${binding} } from ${source.raw}`, + ) + } + emitWrappedBinding(binding, entry.exportName, entry.meta) + } + if (selected) { output.remove(group.node.start, group.node.end) - for (const entry of group.exports) { - tinyassert(entry.node.local.type === 'Identifier') - if (entry.node.exported.type !== 'Identifier') { - throw Object.assign( - new Error('unsupported string literal export name'), - { pos: entry.node.exported.start }, - ) - } - wrapExport(entry.localName, entry.exportName, entry.meta) + if (skippedExports.length > 0) { + const source = group.node.source + ? ` from ${group.node.source.raw}` + : '' + appendedCode.push(`export { ${skippedExports.join(', ')} }${source}`) } } } else if (group.type === 'export-all') { @@ -167,39 +261,34 @@ export function transformWrapExport( }) } } else if (group.type === 'default') { + const meta = group.meta + if (!filter('default', meta)) continue + const localName = group.localName ?? '$$default' if (group.kind === 'named-declaration') { - // preserve name scope for `function foo() {}` and `class Foo {}` - // e.g. - // export default foo() {} - // ^^^^^^^^^^^^^^ - //. ⬇️ (remove `export default`) - // function foo() {} + // export default function Page() {} + // ⬇️ + // function Page() {} output.remove(group.node.start, group.node.declaration.start) } else { - // otherwise we can introduce new variable - // e.g. - // export default foo - // ^^^^^^^^^^^^^^ - //. ⬇️ (replace `export default`) - // const $$default = foo - // ^^^^^^^^^^^^^^^^^ + // export default expression + // ⬇️ + // const $$default = expression output.update( group.node.start, group.node.declaration.start, 'const $$default = ', ) } - const meta = group.meta - if (filter('default', meta)) { - validateNonAsyncFunction(options, group.node.declaration) - } - wrapExport(localName, 'default', meta) + validateNonAsyncFunction(options, group.node.declaration) + emitWrappedBinding(localName, 'default', meta) } } - if (toAppend.length > 0) { - output.append(['', ...toAppend, ''].join(';\n')) + // Emit wrapper bindings, reconstructed skipped exports, and imports for + // selected forwarded exports in their discovery order. + if (appendedCode.length > 0) { + output.append(['', ...appendedCode, ''].join(';\n')) } return { exportNames, output }