Bugfix: JSX renderers can be declared in any order (#1686)

This commit is contained in:
Drew Powers 2021-10-28 09:26:15 -06:00 committed by GitHub
parent acd13914f4
commit ee7b5e482b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
14 changed files with 160 additions and 46 deletions

View file

@ -53,7 +53,7 @@ class AstroBuilder {
async build() { async build() {
const { logging, origin } = this; const { logging, origin } = this;
const timer: Record<string, number> = {viteStart: performance.now()}; const timer: Record<string, number> = { viteStart: performance.now() };
const viteConfig = await createVite( const viteConfig = await createVite(
{ {
mode: this.mode, mode: this.mode,
@ -97,8 +97,8 @@ class AstroBuilder {
); );
// After all routes have been collected, start building them. // After all routes have been collected, start building them.
// TODO: test parallel vs. serial performance. Promise.all() may be // TODO: test parallel vs. serial performance. Promise.all() may be
// making debugging harder without any perf gain. If parallel is best, // making debugging harder without any perf gain. If parallel is best,
// then we should set a max number of parallel builds. // then we should set a max number of parallel builds.
const input: InputHTMLOptions[] = []; const input: InputHTMLOptions[] = [];
await Promise.all( await Promise.all(
@ -126,7 +126,7 @@ class AstroBuilder {
debug(logging, 'build', timerMessage('All pages rendered', timer.renderStart)); debug(logging, 'build', timerMessage('All pages rendered', timer.renderStart));
// Bundle the assets in your final build: This currently takes the HTML output // Bundle the assets in your final build: This currently takes the HTML output
// of every page (stored in memory) and bundles the assets pointed to on those pages. // of every page (stored in memory) and bundles the assets pointed to on those pages.
timer.buildStart = performance.now(); timer.buildStart = performance.now();
await vite.build({ await vite.build({
logLevel: 'error', logLevel: 'error',

View file

@ -11,7 +11,7 @@ import path from 'path';
import { error } from '../core/logger.js'; import { error } from '../core/logger.js';
import { parseNpmName } from '../core/util.js'; import { parseNpmName } from '../core/util.js';
const JSX_RENDERERS = new Map<string, Renderer>(); const JSX_RENDERER_CACHE = new WeakMap<AstroConfig, Map<string, Renderer>>();
const JSX_EXTENSIONS = new Set(['.jsx', '.tsx']); const JSX_EXTENSIONS = new Set(['.jsx', '.tsx']);
const IMPORT_STATEMENTS: Record<string, string> = { const IMPORT_STATEMENTS: Record<string, string> = {
react: "import React from 'react'", react: "import React from 'react'",
@ -22,11 +22,6 @@ const IMPORT_STATEMENTS: Record<string, string> = {
// be careful about esbuild not treating h, React, Fragment, etc. as unused. // be careful about esbuild not treating h, React, Fragment, etc. as unused.
const PREVENT_UNUSED_IMPORTS = ';;(React,Fragment,h);'; const PREVENT_UNUSED_IMPORTS = ';;(React,Fragment,h);';
interface AstroPluginJSXOptions {
config: AstroConfig;
logging: LogOptions;
}
// https://github.com/vitejs/vite/discussions/5109#discussioncomment-1450726 // https://github.com/vitejs/vite/discussions/5109#discussioncomment-1450726
function isSSR(options: undefined | boolean | { ssr: boolean }): boolean { function isSSR(options: undefined | boolean | { ssr: boolean }): boolean {
if (options === undefined) { if (options === undefined) {
@ -41,6 +36,11 @@ function isSSR(options: undefined | boolean | { ssr: boolean }): boolean {
return false; return false;
} }
interface AstroPluginJSXOptions {
config: AstroConfig;
logging: LogOptions;
}
/** Use Astro config to allow for alternate or multiple JSX renderers (by default Vite will assume React) */ /** 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 { export default function jsx({ config, logging }: AstroPluginJSXOptions): Plugin {
let viteConfig: ResolvedConfig; let viteConfig: ResolvedConfig;
@ -58,11 +58,13 @@ export default function jsx({ config, logging }: AstroPluginJSXOptions): Plugin
} }
const { mode } = viteConfig; const { mode } = viteConfig;
let jsxRenderers = JSX_RENDERER_CACHE.get(config);
// load renderers (on first run only) // load renderers (on first run only)
if (JSX_RENDERERS.size === 0) { if (!jsxRenderers) {
const jsxRenderers = await loadJSXRenderers(config.renderers); jsxRenderers = new Map();
if (jsxRenderers.size === 0) { const possibleRenderers = await loadJSXRenderers(config.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 // 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( throw new Error(
`${colors.yellow( `${colors.yellow(
@ -70,20 +72,21 @@ export default function jsx({ config, logging }: AstroPluginJSXOptions): Plugin
)}\nUnable to resolve a renderer that handles JSX transforms! Please include a \`renderer\` plugin which supports JSX in your \`astro.config.mjs\` file.` )}\nUnable to resolve a renderer that handles JSX transforms! Please include a \`renderer\` plugin which supports JSX in your \`astro.config.mjs\` file.`
); );
} }
for (const [importSource, renderer] of jsxRenderers) { for (const [importSource, renderer] of possibleRenderers) {
JSX_RENDERERS.set(importSource, renderer); jsxRenderers.set(importSource, renderer);
} }
JSX_RENDERER_CACHE.set(config, jsxRenderers);
} }
// Attempt: Single JSX renderer // Attempt: Single JSX renderer
// If we only have one renderer, we can skip a bunch of work! // If we only have one renderer, we can skip a bunch of work!
if (JSX_RENDERERS.size === 1) { if (jsxRenderers.size === 1) {
// downlevel any non-standard syntax, but preserve JSX // downlevel any non-standard syntax, but preserve JSX
const { code: jsxCode } = await esbuild.transform(code, { const { code: jsxCode } = await esbuild.transform(code, {
loader: getLoader(path.extname(id)), loader: getLoader(path.extname(id)),
jsx: 'preserve', jsx: 'preserve',
}); });
return transformJSX({ code: jsxCode, id, renderer: [...JSX_RENDERERS.values()][0], mode, ssr }); return transformJSX({ code: jsxCode, id, renderer: [...jsxRenderers.values()][0], mode, ssr });
} }
// Attempt: Multiple JSX renderers // Attempt: Multiple JSX renderers
@ -105,7 +108,7 @@ export default function jsx({ config, logging }: AstroPluginJSXOptions): Plugin
for (let { n: spec } of imports) { for (let { n: spec } of imports) {
const pkg = spec && parseNpmName(spec); const pkg = spec && parseNpmName(spec);
if (!pkg) continue; if (!pkg) continue;
if (JSX_RENDERERS.has(pkg.name)) { if (jsxRenderers.has(pkg.name)) {
importSource = pkg.name; importSource = pkg.name;
break; break;
} }
@ -126,7 +129,7 @@ export default function jsx({ config, logging }: AstroPluginJSXOptions): Plugin
// if JSX renderer found, then use that // if JSX renderer found, then use that
if (importSource) { if (importSource) {
const jsxRenderer = JSX_RENDERERS.get(importSource); const jsxRenderer = jsxRenderers.get(importSource);
// if renderer not installed for this JSX source, throw error // if renderer not installed for this JSX source, throw error
if (!jsxRenderer) { if (!jsxRenderer) {
error(logging, 'renderer', `${colors.yellow(id)} No renderer installed for ${importSource}. Try adding \`@astrojs/renderer-${importSource}\` to your dependencies.`); error(logging, 'renderer', `${colors.yellow(id)} No renderer installed for ${importSource}. Try adding \`@astrojs/renderer-${importSource}\` to your dependencies.`);
@ -137,11 +140,11 @@ export default function jsx({ config, logging }: AstroPluginJSXOptions): Plugin
loader: getLoader(path.extname(id)), loader: getLoader(path.extname(id)),
jsx: 'preserve', jsx: 'preserve',
}); });
return transformJSX({ code: jsxCode, id, renderer: JSX_RENDERERS.get(importSource) as Renderer, mode, ssr }); return transformJSX({ code: jsxCode, id, renderer: jsxRenderers.get(importSource) as Renderer, mode, ssr });
} }
// if we still cant tell, throw error // if we still cant tell, throw error
const defaultRenderer = [...JSX_RENDERERS.keys()][0]; const defaultRenderer = [...jsxRenderers.keys()][0];
error( error(
logging, logging,
'renderer', 'renderer',

View file

@ -0,0 +1,41 @@
import { expect } from 'chai';
import { loadFixture } from './test-utils.js';
let cwd = './fixtures/astro-jsx/';
let orders = [
['preact', 'react', 'solid'],
['preact', 'solid', 'react'],
['react', 'preact', 'solid'],
['react', 'solid', 'preact'],
['solid', 'react', 'preact'],
['solid', 'preact', 'react'],
];
let fixtures = {};
before(async () => {
await Promise.all(
orders.map((renderers, n) =>
loadFixture({
projectRoot: cwd,
renderers: renderers.map((name) => `@astrojs/renderer-${name}`),
dist: new URL(`${cwd}dist-${n}/`, import.meta.url),
}).then((fixture) => {
fixtures[renderers.toString()] = fixture;
return fixture.build();
})
)
);
});
it('Renderer order', () => {
it('JSX renderers can be defined in any order', async () => {
if (!Object.values(fixtures).length) {
throw new Error(`JSX renderers didnt build properly`);
}
for (const [name, fixture] of Object.entries(fixtures)) {
const html = await fixture.readFile('/index.html');
expect(html, name).to.be.ok;
}
});
});

View file

@ -105,7 +105,7 @@ describe('Styles SSR', () => {
}); });
// test 1: Astro component has some scoped class // test 1: Astro component has some scoped class
expect(scopedClass).to.be.ok(); expect(scopedClass).to.be.ok;
// test 23: children get scoped class // test 23: children get scoped class
expect(el1.attr('class')).to.equal(`blue ${scopedClass}`); expect(el1.attr('class')).to.equal(`blue ${scopedClass}`);

View file

@ -0,0 +1 @@
/dist-*

View file

@ -0,0 +1,20 @@
import { h, Fragment } from 'preact';
import { useState } from 'preact/hooks';
/** a counter written in Preact */
export default function PreactCounter() {
const [count, setCount] = useState(0);
const add = () => setCount((i) => i + 1);
const subtract = () => setCount((i) => i - 1);
return (
<div id="preact">
<div className="counter">
<button onClick={subtract}>-</button>
<pre>{count}</pre>
<button onClick={add}>+</button>
</div>
<div className="children">Preact</div>
</div>
);
}

View file

@ -0,0 +1,19 @@
import React, { useState } from 'react';
/** a counter written in React */
export default function ReactCounter() {
const [count, setCount] = useState(0);
const add = () => setCount((i) => i + 1);
const subtract = () => setCount((i) => i - 1);
return (
<div id="react">
<div className="counter">
<button onClick={subtract}>-</button>
<pre>{count}</pre>
<button onClick={add}>+</button>
</div>
<div className="children">React</div>
</div>
);
}

View file

@ -0,0 +1,19 @@
import { createSignal } from 'solid-js';
/** a counter written with Solid */
export default function SolidCounter() {
const [count, setCount] = createSignal(0);
const add = () => setCount(count() + 1);
const subtract = () => setCount(count() - 1);
return (
<div id="solid">
<div class="counter">
<button onClick={subtract}>-</button>
<pre>{count()}</pre>
<button onClick={add}>+</button>
</div>
<div class="children">Solid</div>
</div>
);
}

View file

@ -0,0 +1,9 @@
---
import ReactCounter from '../components/ReactCounter.jsx';
import PreactCounter from '../components/PreactCounter.tsx';
import SolidCounter from '../components/SolidCounter.jsx';
---
<ReactCounter />
<PreactCounter />
<SolidCounter />

View file

@ -45,13 +45,13 @@ describe('LitElement test', () => {
// test 2: shadow rendered // test 2: shadow rendered
expect($('my-element').html()).to.include(`<div>Testing...</div>`); expect($('my-element').html()).to.include(`<div>Testing...</div>`);
}); });
});
after(async () => {
// The Lit renderer adds browser globals that interfere with other tests, so remove them now. after(async () => {
const globals = Object.keys(globalThis.window || {}); // The Lit renderer adds browser globals that interfere with other tests, so remove them now.
globals.splice(globals.indexOf('global'), 1); const globals = Object.keys(globalThis.window || {});
for (let name of globals) { globals.splice(globals.indexOf('global'), 1);
delete globalThis[name]; for (let name of globals) {
} delete globalThis[name];
}); }
}); });

View file

@ -2,17 +2,17 @@ import { expect } from 'chai';
import cheerio from 'cheerio'; import cheerio from 'cheerio';
import { loadFixture } from './test-utils.js'; import { loadFixture } from './test-utils.js';
describe.skip('Solid component', () => { let fixture;
let fixture;
before(async () => { before(async () => {
fixture = await loadFixture({ fixture = await loadFixture({
projectRoot: './fixtures/solid-component/', projectRoot: './fixtures/solid-component/',
renderers: ['@astrojs/renderer-solid'], renderers: ['@astrojs/renderer-solid'],
});
await fixture.build();
}); });
await fixture.build();
});
describe('Solid component', () => {
it('Can load a component', async () => { it('Can load a component', async () => {
const html = await fixture.readFile('/index.html'); const html = await fixture.readFile('/index.html');
const $ = cheerio.load(html); const $ = cheerio.load(html);

View file

@ -11,7 +11,6 @@ function check(Component, props, children) {
try { try {
const { html } = renderToStaticMarkup(Component, props, children); const { html } = renderToStaticMarkup(Component, props, children);
if (typeof html !== 'string') { if (typeof html !== 'string') {
return false; return false;
} }
@ -20,7 +19,7 @@ function check(Component, props, children) {
// but components would be <undefined></undefined> // but components would be <undefined></undefined>
return !/\<undefined\>/.test(html); return !/\<undefined\>/.test(html);
} catch { } catch (err) {
return false; return false;
} }
} }

View file

@ -5,7 +5,7 @@ import StaticHtml from './static-html.js';
const reactTypeof = Symbol.for('react.element'); const reactTypeof = Symbol.for('react.element');
function errorIsComingFromPreactComponent(err) { function errorIsComingFromPreactComponent(err) {
return err.message && err.message.startsWith("Cannot read property '__H'"); return err.message && (err.message.startsWith("Cannot read property '__H'") || err.message.includes("(reading '__H')"));
} }
function check(Component, props, children) { function check(Component, props, children) {
@ -24,7 +24,7 @@ function check(Component, props, children) {
isReactComponent = true; isReactComponent = true;
} }
} catch (err) { } catch (err) {
if(!errorIsComingFromPreactComponent(err)) { if (!errorIsComingFromPreactComponent(err)) {
error = err; error = err;
} }
} }

View file

@ -2,9 +2,12 @@ import { renderToString, ssr, createComponent } from 'solid-js/web/dist/server.j
function check(Component, props, children) { function check(Component, props, children) {
if (typeof Component !== 'function') return false; if (typeof Component !== 'function') return false;
try {
const { html } = renderToStaticMarkup(Component, props, children); const { html } = renderToStaticMarkup(Component, props, children);
return typeof html === 'string'; return typeof html === 'string';
} catch (err) {
return false;
}
} }
function renderToStaticMarkup(Component, props, children) { function renderToStaticMarkup(Component, props, children) {