diff --git a/.changeset/silver-dragons-run.md b/.changeset/silver-dragons-run.md new file mode 100644 index 000000000000..a18538e9b32b --- /dev/null +++ b/.changeset/silver-dragons-run.md @@ -0,0 +1,5 @@ +--- +'astro': patch +--- + +Refactor JSX build plugin, improve performance diff --git a/packages/astro/src/vite-plugin-jsx/index.ts b/packages/astro/src/vite-plugin-jsx/index.ts index 0a7ff4c9f083..a38a6a103d3d 100644 --- a/packages/astro/src/vite-plugin-jsx/index.ts +++ b/packages/astro/src/vite-plugin-jsx/index.ts @@ -13,7 +13,6 @@ import { error } from '../core/logger/core.js'; import { parseNpmName } from '../core/util.js'; import tagExportsPlugin from './tag.js'; -const JSX_RENDERER_CACHE = new WeakMap>(); const JSX_EXTENSIONS = new Set(['.jsx', '.tsx', '.mdx']); const IMPORT_STATEMENTS: Record = { react: "import React from 'react'", @@ -26,6 +25,8 @@ const IMPORT_STATEMENTS: Record = { // The `tsx` loader in esbuild will remove unused imports, so we need to // be careful about esbuild not treating h, React, Fragment, etc. as unused. const PREVENT_UNUSED_IMPORTS = ';;(React,Fragment,h);'; +// A fast check regex for the import keyword. False positives are okay. +const IMPORT_KEYWORD_REGEX = /import/; function getEsbuildLoader(fileExt: string): string { if (fileExt === '.mdx') return 'jsx'; @@ -39,6 +40,53 @@ function collectJSXRenderers(renderers: AstroRenderer[]): Map +) { + // We need valid JS to scan for imports. + // NOTE: Because we only need imports, it is okay to use `h` and `Fragment` as placeholders. + const { code: jsCode } = await esbuild.transform(code + PREVENT_UNUSED_IMPORTS, { + loader: getEsbuildLoader(path.extname(id)) as esbuild.Loader, + jsx: 'transform', + jsxFactory: 'h', + jsxFragment: 'Fragment', + sourcefile: id, + sourcemap: 'inline', + }); + const [imports] = eslexer.parse(jsCode); + if (imports.length > 0) { + for (let { n: spec } of imports) { + const pkg = spec && parseNpmName(spec); + if (!pkg) continue; + if (jsxRenderers.has(pkg.name)) { + return pkg.name; + } + } + } +} interface TransformJSXOptions { code: string; id: string; @@ -106,12 +154,29 @@ interface AstroPluginJSXOptions { /** Use Astro config to allow for alternate or multiple JSX renderers (by default Vite will assume React) */ export default function jsx({ config, logging }: AstroPluginJSXOptions): Plugin { let viteConfig: ResolvedConfig; + const jsxRenderers = new Map(); + const jsxRenderersIntegrationOnly = new Map(); + // A reference to Astro's internal JSX renderer. + let astroJSXRenderer: AstroRenderer; + // The first JSX renderer provided is considered the default renderer. + // This is a useful reference for when the user only gives a single render. + let defaultJSXRendererEntry: [string, AstroRenderer]; return { name: 'astro:jsx', enforce: 'pre', // run transforms before other plugins - configResolved(resolvedConfig) { + async configResolved(resolvedConfig) { viteConfig = resolvedConfig; + const possibleRenderers = await collectJSXRenderers(config._ctx.renderers); + for (const [importSource, renderer] of possibleRenderers) { + jsxRenderers.set(importSource, renderer); + if (importSource === 'astro') { + astroJSXRenderer = renderer; + } else { + jsxRenderersIntegrationOnly.set(importSource, renderer); + } + } + defaultJSXRendererEntry = [...jsxRenderersIntegrationOnly.entries()][0]; }, async transform(code, id, opts) { const ssr = Boolean(opts?.ssr); @@ -120,30 +185,8 @@ export default function jsx({ config, logging }: AstroPluginJSXOptions): Plugin } const { mode } = viteConfig; - let jsxRenderers = JSX_RENDERER_CACHE.get(config); - - // load renderers (on first run only) - if (!jsxRenderers) { - jsxRenderers = new Map(); - const possibleRenderers = await collectJSXRenderers(config._ctx.renderers); - if (possibleRenderers.size === 0) { - // note: we have filtered out all non-JSX files, so this error should only show if a JSX file is loaded with no matching renderers - throw new Error( - `${colors.yellow( - id - )}\nUnable to resolve a JSX renderer! Did you forget to include one? Add a JSX integration like \`@astrojs/react\` to your \`astro.config.mjs\` file.` - ); - } - for (const [importSource, renderer] of possibleRenderers) { - jsxRenderers.set(importSource, renderer); - } - JSX_RENDERER_CACHE.set(config, jsxRenderers); - } - - const astroRenderer = jsxRenderers.get('astro'); - // Shortcut: only use Astro renderer for MD and MDX files - if ((id.includes('.mdx') || id.includes('.md')) && astroRenderer) { + if (id.includes('.mdx') || id.includes('.md')) { const { code: jsxCode } = await esbuild.transform(code, { loader: getEsbuildLoader(path.extname(id)) as esbuild.Loader, jsx: 'preserve', @@ -153,18 +196,12 @@ export default function jsx({ config, logging }: AstroPluginJSXOptions): Plugin return transformJSX({ code: jsxCode, id, - renderer: astroRenderer, + renderer: astroJSXRenderer, mode, ssr, }); } - - // Attempt: Single JSX integration - // If we only have one renderer, we can skip a bunch of work! - const nonAstroJsxRenderers = new Map( - [...jsxRenderers.entries()].filter(([key]) => key !== 'astro') - ); - if (nonAstroJsxRenderers.size === 1) { + if (defaultJSXRendererEntry && jsxRenderersIntegrationOnly.size === 1) { // downlevel any non-standard syntax, but preserve JSX const { code: jsxCode } = await esbuild.transform(code, { loader: getEsbuildLoader(path.extname(id)) as esbuild.Loader, @@ -175,95 +212,60 @@ export default function jsx({ config, logging }: AstroPluginJSXOptions): Plugin return transformJSX({ code: jsxCode, id, - renderer: [...nonAstroJsxRenderers.values()][0], + renderer: defaultJSXRendererEntry[1], mode, ssr, }); } - // Attempt: Multiple JSX renderers - // we need valid JS to scan, so we can use `h` and `Fragment` as placeholders - const { code: jsCode } = await esbuild.transform(code + PREVENT_UNUSED_IMPORTS, { - loader: getEsbuildLoader(path.extname(id)) as esbuild.Loader, - jsx: 'transform', - jsxFactory: 'h', - jsxFragment: 'Fragment', - sourcefile: id, - sourcemap: 'inline', - }); - - let imports: eslexer.ImportSpecifier[] = []; - if (/import/.test(jsCode)) { - let [i] = eslexer.parse(jsCode); - imports = i as any; - } - let importSource: string | undefined; - if (imports.length > 0) { - for (let { n: spec } of imports) { - const pkg = spec && parseNpmName(spec); - if (!pkg) continue; - if (jsxRenderers.has(pkg.name)) { - importSource = pkg.name; - break; - } - } + let importSource = detectImportSourceFromComments(code); + if (!importSource && IMPORT_KEYWORD_REGEX.test(code)) { + importSource = await detectImportSourceFromImports(code, id, jsxRenderers); } - // if no imports were found, look for @jsxImportSource comment + // if we still can’t tell the import source, now is the time to throw an error. if (!importSource) { - const multiline = code.match(/\/\*\*?[\S\s]*\*\//gm) || []; - for (const comment of multiline) { - const [_, lib] = comment.slice(0, -2).match(/@jsxImportSource\s*(\S+)/) || []; - if (lib) { - importSource = lib.trim(); - break; - } - } + const [defaultRendererName] = defaultJSXRendererEntry[0]; + error( + logging, + 'renderer', + `${colors.yellow(id)} +Unable to resolve a renderer that handles this file! With more than one renderer enabled, you should include an import or use a pragma comment. +Add ${colors.cyan( + IMPORT_STATEMENTS[defaultRendererName] || `import '${defaultRendererName}';` + )} or ${colors.cyan(`/* jsxImportSource: ${defaultRendererName} */`)} to this file. +` + ); + return null; } - // if JSX renderer found, then use that - if (importSource) { - const jsxRenderer = jsxRenderers.get(importSource); - // if renderer not installed for this JSX source, throw error - if (!jsxRenderer) { - error( - logging, - 'renderer', - `${colors.yellow( - id - )} No renderer installed for ${importSource}. Try adding \`@astrojs/${importSource}\` to your project.` - ); - return null; - } - // downlevel any non-standard syntax, but preserve JSX - const { code: jsxCode } = await esbuild.transform(code, { - loader: getEsbuildLoader(path.extname(id)) as esbuild.Loader, - jsx: 'preserve', - sourcefile: id, - sourcemap: 'inline', - }); - return await transformJSX({ - code: jsxCode, - id, - renderer: jsxRenderers.get(importSource) as AstroRenderer, - mode, - ssr, - }); + const selectedJsxRenderer = jsxRenderers.get(importSource); + // if the renderer is not installed for this JSX source, throw error + if (!selectedJsxRenderer) { + error( + logging, + 'renderer', + `${colors.yellow( + id + )} No renderer installed for ${importSource}. Try adding \`@astrojs/${importSource}\` to your project.` + ); + return null; } - // if we still can’t tell, throw error - const defaultRenderer = [...jsxRenderers.keys()][0]; - error( - logging, - 'renderer', - `${colors.yellow(id)} -Unable to resolve a renderer that handles this file! With more than one renderer enabled, you should include an import or use a pragma comment. -Add ${colors.cyan( - IMPORT_STATEMENTS[defaultRenderer] || `import '${defaultRenderer}';` - )} or ${colors.cyan(`/* jsxImportSource: ${defaultRenderer} */`)} to this file. -` - ); - return null; + // downlevel any non-standard syntax, but preserve JSX + const { code: jsxCode } = await esbuild.transform(code, { + loader: getEsbuildLoader(path.extname(id)) as esbuild.Loader, + jsx: 'preserve', + sourcefile: id, + sourcemap: 'inline', + }); + return await transformJSX({ + code: jsxCode, + id, + renderer: selectedJsxRenderer, + mode, + ssr, + }); }, }; }