diff --git a/packages/next-swc/crates/napi/src/lib.rs b/packages/next-swc/crates/napi/src/lib.rs index 4d097014d18bed6..dcc6441fbff6a0c 100644 --- a/packages/next-swc/crates/napi/src/lib.rs +++ b/packages/next-swc/crates/napi/src/lib.rs @@ -44,7 +44,7 @@ mod bundle; mod minify; mod transform; mod util; - +mod parse; static COMPILER: Lazy> = Lazy::new(|| { let cm = Arc::new(SourceMap::new(FilePathMapping::empty())); @@ -68,6 +68,8 @@ fn init(mut exports: JsObject) -> napi::Result<()> { exports.create_named_method("minify", minify::minify)?; exports.create_named_method("minifySync", minify::minify_sync)?; + + exports.create_named_method("parse", parse::parse)?; Ok(()) } diff --git a/packages/next-swc/crates/napi/src/parse.rs b/packages/next-swc/crates/napi/src/parse.rs new file mode 100644 index 000000000000000..a6cb68a0291bf47 --- /dev/null +++ b/packages/next-swc/crates/napi/src/parse.rs @@ -0,0 +1,70 @@ +use crate::util::{deserialize_json, CtxtExt, MapErr}; +use anyhow::Context as _; +use napi::{CallContext, Either, Env, JsObject, JsString, JsUndefined, Task}; +use std::sync::Arc; +use swc::{config::ParseOptions, try_with_handler}; +use swc_common::{FileName, FilePathMapping, SourceMap}; + +pub struct ParseTask { + pub filename: FileName, + pub src: String, + pub options: String, +} + +pub fn complete_parse<'a>(env: &Env, ast_json: String) -> napi::Result { + env.create_string_from_std(ast_json) +} + +impl Task for ParseTask { + type Output = String; + type JsValue = JsString; + + fn compute(&mut self) -> napi::Result { + let c = swc::Compiler::new(Arc::new(SourceMap::new(FilePathMapping::empty()))); + + let options: ParseOptions = deserialize_json(&self.options).convert_err()?; + let fm = + c.cm.new_source_file(self.filename.clone(), self.src.clone()); + let program = try_with_handler(c.cm.clone(), false, |handler| { + c.parse_js( + fm, + &handler, + options.target, + options.syntax, + options.is_module, + options.comments, + ) + }) + .convert_err()?; + + let ast_json = serde_json::to_string(&program) + .context("failed to serialize Program") + .convert_err()?; + + Ok(ast_json) + } + + fn resolve(self, env: Env, result: Self::Output) -> napi::Result { + complete_parse(&env, result) + } +} + +#[js_function(3)] +pub fn parse(ctx: CallContext) -> napi::Result { + let src = ctx.get::(0)?.into_utf8()?.as_str()?.to_string(); + let options = ctx.get_buffer_as_string(1)?; + let filename = ctx.get::>(2)?; + let filename = if let Either::A(value) = filename { + FileName::Real(value.into_utf8()?.as_str()?.to_owned().into()) + } else { + FileName::Anon + }; + + ctx.env + .spawn(ParseTask { + filename, + src, + options, + }) + .map(|t| t.promise_object()) +} diff --git a/packages/next/build/swc/index.js b/packages/next/build/swc/index.js index 6907c51e8605139..8998782147c3abf 100644 --- a/packages/next/build/swc/index.js +++ b/packages/next/build/swc/index.js @@ -72,6 +72,9 @@ async function loadWasm() { minify(src, options) { return Promise.resolve(bindings.minifySync(src.toString(), options)) }, + parse(src, options) { + return Promise.resolve(bindings.parse(src.toString(), options)) + }, } return wasmBindings } catch (e) { @@ -179,6 +182,10 @@ function loadNative() { bundle(options) { return bindings.bundle(toBuffer(options)) }, + + parse(src, options) { + return bindings.parse(src, toBuffer(options ?? {})) + }, } return nativeBindings } @@ -219,3 +226,8 @@ export async function bundle(options) { let bindings = loadBindingsSync() return bindings.bundle(toBuffer(options)) } + +export async function parse(src, options) { + let bindings = loadBindingsSync() + return bindings.parse(src, options).then((astStr) => JSON.parse(astStr)) +} diff --git a/packages/next/build/swc/options.js b/packages/next/build/swc/options.js index 103119885e47b0a..c6d3e560e491c46 100644 --- a/packages/next/build/swc/options.js +++ b/packages/next/build/swc/options.js @@ -5,7 +5,7 @@ const regeneratorRuntimePath = require.resolve( 'next/dist/compiled/regenerator-runtime' ) -function getBaseSWCOptions({ +export function getBaseSWCOptions({ filename, jest, development, @@ -45,9 +45,9 @@ function getBaseSWCOptions({ pragma: 'React.createElement', pragmaFrag: 'React.Fragment', throwIfNamespace: true, - development: development, + development: !!development, useBuiltins: true, - refresh: hasReactRefresh, + refresh: !!hasReactRefresh, }, optimizer: { simplify: false, diff --git a/packages/next/build/webpack/loaders/next-flight-client-loader.ts b/packages/next/build/webpack/loaders/next-flight-client-loader.ts index 2a78484a48684d7..6a916a364e39f0c 100644 --- a/packages/next/build/webpack/loaders/next-flight-client-loader.ts +++ b/packages/next/build/webpack/loaders/next-flight-client-loader.ts @@ -5,29 +5,16 @@ * LICENSE file in the root directory of this source tree. */ -import * as acorn from 'next/dist/compiled/acorn' - -type ResolveContext = { - conditions: Array - parentURL: string | void -} - -type ResolveFunction = ( - specifier: string, - context: ResolveContext, - resolve: ResolveFunction -) => { url: string } | Promise<{ url: string }> - -type TransformSourceFunction = (url: string, callback: () => void) => void - -type Source = string | ArrayBuffer | Uint8Array - -let stashedResolve: null | ResolveFunction = null +// TODO: add ts support for next-swc api +// @ts-ignore +import { parse } from '../../swc' +// @ts-ignore +import { getBaseSWCOptions } from '../../swc/options' function addExportNames(names: string[], node: any) { switch (node.type) { case 'Identifier': - names.push(node.name) + names.push(node.value) return case 'ObjectPattern': for (let i = 0; i < node.properties.length; i++) @@ -56,50 +43,25 @@ function addExportNames(names: string[], node: any) { } } -function resolveClientImport( - specifier: string, - parentURL: string -): { url: string } | Promise<{ url: string }> { - // Resolve an import specifier as if it was loaded by the client. This doesn't use - // the overrides that this loader does but instead reverts to the default. - // This resolution algorithm will not necessarily have the same configuration - // as the actual client loader. It should mostly work and if it doesn't you can - // always convert to explicit exported names instead. - const conditions = ['node', 'import'] - if (stashedResolve === null) { - throw new Error( - 'Expected resolve to have been called before transformSource' - ) - } - return stashedResolve(specifier, { conditions, parentURL }, stashedResolve) -} - async function parseExportNamesInto( + resourcePath: string, transformedSource: string, - names: Array, - parentURL: string, - loadModule: TransformSourceFunction + names: Array ): Promise { - const { body } = acorn.parse(transformedSource, { - ecmaVersion: 11, - sourceType: 'module', - }) as any + const opts = getBaseSWCOptions({ + filename: resourcePath, + globalWindow: true, + }) + + const { body } = await parse(transformedSource, { + ...opts.jsc.parser, + isModule: true, + }) for (let i = 0; i < body.length; i++) { const node = body[i] switch (node.type) { - case 'ExportAllDeclaration': - if (node.exported) { - addExportNames(names, node.exported) - continue - } else { - const { url } = await resolveClientImport( - node.source.value, - parentURL - ) - const source = '' - parseExportNamesInto(source, names, url, loadModule) - continue - } + // TODO: support export * from module path + // case 'ExportAllDeclaration': case 'ExportDefaultDeclaration': names.push('default') continue @@ -129,8 +91,8 @@ async function parseExportNamesInto( export default async function transformSource( this: any, - source: Source -): Promise { + source: string +): Promise { const { resourcePath, resourceQuery } = this if (resourceQuery !== '?flight') return source @@ -142,12 +104,7 @@ export default async function transformSource( } const names: string[] = [] - await parseExportNamesInto( - transformedSource as string, - names, - url + resourceQuery, - this.loadModule - ) + await parseExportNamesInto(resourcePath, transformedSource, names) // next.js/packages/next/.js if (/[\\/]next[\\/](link|image)\.js$/.test(url)) { diff --git a/packages/next/build/webpack/loaders/next-flight-server-loader.ts b/packages/next/build/webpack/loaders/next-flight-server-loader.ts index 6c7f1bfc1eabb11..8d5b84927e235c8 100644 --- a/packages/next/build/webpack/loaders/next-flight-server-loader.ts +++ b/packages/next/build/webpack/loaders/next-flight-server-loader.ts @@ -1,4 +1,8 @@ -import * as acorn from 'next/dist/compiled/acorn' +// TODO: add ts support for next-swc api +// @ts-ignore +import { parse } from '../../swc' +// @ts-ignore +import { getBaseSWCOptions } from '../../swc/options' import { getRawPageExtensions } from '../../utils' function isClientComponent(importSource: string, pageExtensions: string[]) { @@ -28,6 +32,7 @@ export function isImageImport(importSource: string) { } async function parseImportsInfo( + resourcePath: string, source: string, imports: Array, isClientCompilation: boolean, @@ -36,21 +41,22 @@ async function parseImportsInfo( source: string defaultExportName: string }> { - const { body } = acorn.parse(source, { - ecmaVersion: 11, - sourceType: 'module', - }) as any - + const opts = getBaseSWCOptions({ + filename: resourcePath, + globalWindow: isClientCompilation, + }) + + const ast = await parse(source, { ...opts.jsc.parser, isModule: true }) + const { body } = ast + const beginPos = ast.span.start let transformedSource = '' let lastIndex = 0 - let defaultExportName = 'RSCComponent' - + let defaultExportName for (let i = 0; i < body.length; i++) { const node = body[i] switch (node.type) { case 'ImportDeclaration': { const importSource = node.source.value - if (!isClientCompilation) { if ( !( @@ -61,10 +67,11 @@ async function parseImportsInfo( ) { continue } - transformedSource += source.substring( + const importDeclarations = source.substring( lastIndex, - node.source.start - 1 + node.source.span.start - beginPos ) + transformedSource += importDeclarations transformedSource += JSON.stringify(`${node.source.value}?flight`) } else { // For the client compilation, we skip all modules imports but @@ -84,16 +91,16 @@ async function parseImportsInfo( } } - lastIndex = node.source.end + lastIndex = node.source.span.end - beginPos imports.push(`require(${JSON.stringify(importSource)})`) continue } case 'ExportDefaultDeclaration': { - const def = node.declaration + const def = node.decl if (def.type === 'Identifier') { defaultExportName = def.name - } else if (def.type === 'FunctionDeclaration') { - defaultExportName = def.id.name + } else if (def.type === 'FunctionExpression') { + defaultExportName = def.identifier.value } break } @@ -129,6 +136,7 @@ export default async function transformSource( const imports: string[] = [] const { source: transformedSource, defaultExportName } = await parseImportsInfo( + resourcePath, source, imports, isClientCompilation, @@ -150,7 +158,9 @@ export default async function transformSource( const noop = `export const __rsc_noop__=()=>{${imports.join(';')}}` const defaultExportNoop = isClientCompilation ? `export default function ${defaultExportName}(){}\n${defaultExportName}.__next_rsc__=1;` - : `${defaultExportName}.__next_rsc__=1;` + : defaultExportName + ? `${defaultExportName}.__next_rsc__=1;` + : '' const transformed = transformedSource + '\n' + noop + '\n' + defaultExportNoop diff --git a/test/integration/react-streaming-and-server-components/app/components/client-exports-all.client.js b/test/integration/react-streaming-and-server-components/app/components/client-exports-all.client.js new file mode 100644 index 000000000000000..aa9197254b20278 --- /dev/null +++ b/test/integration/react-streaming-and-server-components/app/components/client-exports-all.client.js @@ -0,0 +1 @@ +export * from './client-exports' diff --git a/test/integration/react-streaming-and-server-components/app/components/client-exports-all.js b/test/integration/react-streaming-and-server-components/app/components/client-exports-all.js new file mode 100644 index 000000000000000..d7d3f726f5a1d6a --- /dev/null +++ b/test/integration/react-streaming-and-server-components/app/components/client-exports-all.js @@ -0,0 +1,20 @@ +export * from './client-exports' + +// TODO: add exports all test case in pages +/** + +import * as all from '../components/client-exports-all' +import * as allClient from '../components/client-exports-all.client' + +export default function Page() { + const { a, b, c, d, e } = all + const { a: ac, b: bc, c: cc, d: dc, e: ec } = allClient + return ( +
+
{a}{b}{c}{d}{e[0]}
+
{ac}{bc}{cc}{dc}{ec[0]}
+
+ ) +} + +*/ diff --git a/test/integration/react-streaming-and-server-components/app/components/client-exports.js b/test/integration/react-streaming-and-server-components/app/components/client-exports.js new file mode 100644 index 000000000000000..6fd69b677b4d4c0 --- /dev/null +++ b/test/integration/react-streaming-and-server-components/app/components/client-exports.js @@ -0,0 +1,10 @@ +const a = 'a' +const b = 'b' +const _c = 'c' +const _d = 'd' +const _e = 'e' +const _eArr = [_e] + +export const c = _c +export { a, b } +export { _d as d, _eArr as e } diff --git a/test/integration/react-streaming-and-server-components/app/pages/client-exports-all.server.js b/test/integration/react-streaming-and-server-components/app/pages/client-exports-all.server.js new file mode 100644 index 000000000000000..d4a270114143572 --- /dev/null +++ b/test/integration/react-streaming-and-server-components/app/pages/client-exports-all.server.js @@ -0,0 +1,25 @@ +import * as all from '../components/client-exports-all' +import * as allClient from '../components/client-exports-all.client' + +export default function Page() { + const { a, b, c, d, e } = all + const { a: ac, b: bc, c: cc, d: dc, e: ec } = allClient + return ( +
+
+ {a} + {b} + {c} + {d} + {e[0]} +
+
+ {ac} + {bc} + {cc} + {dc} + {ec[0]} +
+
+ ) +} diff --git a/test/integration/react-streaming-and-server-components/app/pages/client-exports.server.js b/test/integration/react-streaming-and-server-components/app/pages/client-exports.server.js new file mode 100644 index 000000000000000..68bb37e7ee1def5 --- /dev/null +++ b/test/integration/react-streaming-and-server-components/app/pages/client-exports.server.js @@ -0,0 +1,13 @@ +import { a, b, c, d, e } from '../components/client-exports' + +export default function Page() { + return ( +
+ {a} + {b} + {c} + {d} + {e[0]} +
+ ) +} diff --git a/test/integration/react-streaming-and-server-components/test/index.test.js b/test/integration/react-streaming-and-server-components/test/index.test.js index dfb68408db28062..a298254caaff563 100644 --- a/test/integration/react-streaming-and-server-components/test/index.test.js +++ b/test/integration/react-streaming-and-server-components/test/index.test.js @@ -400,6 +400,19 @@ async function runBasicTests(context, env) { expect(imageTag.attr('src')).toContain('data:image') }) + it('should handle multiple named exports correctly', async () => { + const clientExportsHTML = await renderViaHTTP( + context.appPort, + '/client-exports' + ) + const $clientExports = cheerio.load(clientExportsHTML) + expect($clientExports('div[hidden] > div').text()).toBe('abcde') + + const browser = await webdriver(context.appPort, '/client-exports') + const text = await browser.waitForElementByCss('#__next').text() + expect(text).toBe('abcde') + }) + it('should support multi-level server component imports', async () => { const html = await renderViaHTTP(context.appPort, '/multi') expect(html).toContain('bar.server.js:')