Improve error handling (#3859)

* feat: tag JSX exports with correct renderer

* feat(error): enhance generic errors with frame

* feat(error): surface errors from streaming response

* feat(error): use vite overlay to display errors

* chore: fix build issues

* feat: use custom logger to hide vite errors for known packages

* chore: move error-react-spectrum to e2e test

* chore: add todo comment

* test: fix error overlay handling

* refactor: extract overlay message to util

* test(e2e): update shared component tests

* fix: give error overlay more time

* refactor: move errors tests to e2e

* fix: appease ts

* test: move sass error to e2e tests

* fix: scope optimizeDeps to `src/pages/**/*`

* chore: update lockfile

* chore: update test script

* chore: log error overlay

* chore: log error tests

* chore: update playwright config

* test(e2e): update errors tests

* test(e2e): fix overlay util

* test(e2e): fix test utils

* test(e2e): try timeout

* test(e2e): give up on overlay tests

* fix: typo

* fix: typo

* refactor: collapse definition

* fix: let errors throw

* chore: revert scanner change

* chore: refactor err.plugin handling

* chore: add clarifying comments

* fix: make astro:renderer non enumerable

* chore: update comments

* refactor: replace astro:renderer string with Symbol

* chore: add comment about tagged components

* feat: improve error overlay when hint exists

Co-authored-by: Nate Moore <nate@astro.build>
This commit is contained in:
Nate Moore 2022-07-19 16:47:31 -05:00 committed by GitHub
parent 4ee997da43
commit 4412fe61f4
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
68 changed files with 453 additions and 375 deletions

View file

@ -0,0 +1,24 @@
import { expect } from '@playwright/test';
import { testFactory, getErrorOverlayMessage } from './test-utils.js';
const test = testFactory({ root: './fixtures/error-react-spectrum/' });
let devServer;
test.beforeEach(async ({ astro }) => {
devServer = await astro.startDevServer();
});
test.afterEach(async ({ astro }) => {
await devServer.stop();
astro.resetAllFiles();
});
test.describe('Error: React Spectrum', () => {
test('overlay', async ({ page, astro }) => {
await page.goto(astro.resolveUrl('/'));
const message = await getErrorOverlayMessage(page);
expect(message).toMatch('@adobe/react-spectrum is not compatible')
});
});

View file

@ -0,0 +1,24 @@
import { expect } from '@playwright/test';
import { testFactory, getErrorOverlayMessage } from './test-utils.js';
const test = testFactory({ root: './fixtures/error-sass/' });
let devServer;
test.beforeEach(async ({ astro }) => {
devServer = await astro.startDevServer();
});
test.afterEach(async ({ astro }) => {
await devServer.stop();
astro.resetAllFiles();
});
test.describe('Error: Sass', () => {
test('overlay', async ({ page, astro }) => {
await page.goto(astro.resolveUrl('/'));
const message = await getErrorOverlayMessage(page);
expect(message).toMatch('Undefined variable')
});
});

View file

@ -0,0 +1,67 @@
import { expect } from '@playwright/test';
import { testFactory, getErrorOverlayMessage } from './test-utils.js';
const test = testFactory({ root: './fixtures/errors/' });
let devServer;
test.beforeEach(async ({ astro }) => {
devServer = await astro.startDevServer();
});
test.afterEach(async ({ astro }) => {
await devServer.stop();
astro.resetAllFiles();
});
test.describe('Error display', () => {
test('detect syntax errors in template', async ({ page, astro }) => {
await page.goto(astro.resolveUrl('/astro-syntax-error'));
const message = await getErrorOverlayMessage(page);
expect(message).toMatch('Unexpected "}"')
await Promise.all([
// Wait for page reload
page.waitForNavigation(),
// Edit the component file
await astro.editFile('./src/pages/astro-syntax-error.astro', () => `<h1>No syntax error</h1>`)
]);
expect(await page.locator('vite-error-overlay').count()).toEqual(0);
});
test('shows useful error when frontmatter import is not found', async ({ page, astro }) => {
await page.goto(astro.resolveUrl('/import-not-found'));
const message = await getErrorOverlayMessage(page);
expect(message).toMatch('failed to load module for ssr: ../abc.astro')
await Promise.all([
// Wait for page reload
page.waitForNavigation(),
// Edit the component file
astro.editFile('./src/pages/import-not-found.astro', () => `<h1>No import error</h1>`)
]);
expect(await page.locator('vite-error-overlay').count()).toEqual(0);
});
test('framework errors recover when fixed', async ({ page, astro }) => {
await page.goto(astro.resolveUrl('/svelte-syntax-error'));
const message = await getErrorOverlayMessage(page);
expect(message).toMatch('</div> attempted to close an element that was not open')
await Promise.all([
// Wait for page reload
page.waitForNavigation(),
// Edit the component file
astro.editFile('./src/components/SvelteSyntaxError.svelte', () => `<h1>No mismatch</h1>`)
]);
expect(await page.locator('vite-error-overlay').count()).toEqual(0);
});
});

View file

@ -1,5 +1,5 @@
{
"name": "@test/error-react-spectrum",
"name": "@e2e/error-react-spectrum",
"version": "0.0.0",
"private": true,
"dependencies": {

View file

@ -1,5 +1,5 @@
{
"name": "@test/sass",
"name": "@e2e/error-sass",
"version": "0.0.0",
"private": true,
"dependencies": {

View file

@ -1,5 +1,5 @@
{
"name": "@test/errors",
"name": "@e2e/errors",
"version": "0.0.0",
"private": true,
"dependencies": {

View file

@ -1,4 +1,4 @@
export { default } from '../components/Layout.astro';
import Counter from '../components/Counter.jsx';
import PreactComponent from '../components/JSXComponent.jsx';

View file

@ -1,3 +1,4 @@
export { default } from '../components/Layout.astro';
import Counter from '../components/Counter.jsx';
import ReactComponent from '../components/JSXComponent.jsx';

View file

@ -1,3 +1,4 @@
export { default } from '../components/Layout.astro';
import Counter from '../components/Counter.jsx';
import SolidComponent from '../components/SolidComponent.jsx';

View file

@ -1,3 +1,4 @@
export { default } from '../components/Layout.astro';
import Counter from '../components/Counter.svelte';
import SvelteComponent from '../components/SvelteComponent.svelte';

View file

@ -1,3 +1,4 @@
export { default } from '../components/Layout.astro';
import Counter from '../components/Counter.vue';
import VueComponent from '../components/VueComponent.vue';

View file

@ -2,18 +2,23 @@ import { prepareTestFactory } from './shared-component-tests.js';
const { test, createTests } = prepareTestFactory({ root: './fixtures/preact-compat-component/' });
const config = {
counterComponentFilePath: './src/components/Counter.jsx',
componentFilePath: './src/components/JSXComponent.jsx',
}
test.describe('preact/compat components in Astro files', () => {
createTests({
...config,
pageUrl: '/',
pageSourceFilePath: './src/pages/index.astro',
componentFilePath: './src/components/JSXComponent.jsx',
});
});
test.describe('preact/compat components in Markdown files', () => {
createTests({
...config,
pageUrl: '/markdown/',
pageSourceFilePath: './src/pages/markdown.md',
componentFilePath: './src/components/JSXComponent.jsx',
});
});

View file

@ -2,26 +2,31 @@ import { prepareTestFactory } from './shared-component-tests.js';
const { test, createTests } = prepareTestFactory({ root: './fixtures/preact-component/' });
const config = {
counterComponentFilePath: './src/components/Counter.jsx',
componentFilePath: './src/components/JSXComponent.jsx',
}
test.describe('Preact components in Astro files', () => {
createTests({
...config,
pageUrl: '/',
pageSourceFilePath: './src/pages/index.astro',
componentFilePath: './src/components/JSXComponent.jsx',
});
});
test.describe('Preact components in Markdown files', () => {
createTests({
...config,
pageUrl: '/markdown/',
pageSourceFilePath: './src/pages/markdown.md',
componentFilePath: './src/components/JSXComponent.jsx',
});
});
test.describe('Preact components in MDX files', () => {
createTests({
...config,
pageUrl: '/mdx/',
pageSourceFilePath: './src/pages/mdx.mdx',
componentFilePath: './src/components/JSXComponent.jsx',
});
});

View file

@ -2,26 +2,31 @@ import { prepareTestFactory } from './shared-component-tests.js';
const { test, createTests } = prepareTestFactory({ root: './fixtures/react-component/' });
const config = {
counterComponentFilePath: './src/components/Counter.jsx',
componentFilePath: './src/components/JSXComponent.jsx',
}
test.describe('React components in Astro files', () => {
createTests({
...config,
pageUrl: '/',
pageSourceFilePath: './src/pages/index.astro',
componentFilePath: './src/components/JSXComponent.jsx',
});
});
test.describe('React components in Markdown files', () => {
createTests({
...config,
pageUrl: '/markdown/',
pageSourceFilePath: './src/pages/markdown.md',
componentFilePath: './src/components/JSXComponent.jsx',
});
});
test.describe('React components in MDX files', () => {
createTests({
...config,
pageUrl: '/mdx/',
pageSourceFilePath: './src/pages/mdx.mdx',
componentFilePath: './src/components/JSXComponent.jsx',
});
});

View file

@ -1,5 +1,5 @@
import { expect } from '@playwright/test';
import { testFactory } from './test-utils.js';
import { testFactory, getErrorOverlayMessage } from './test-utils.js';
export function prepareTestFactory(opts) {
const test = testFactory(opts);

View file

@ -2,26 +2,31 @@ import { prepareTestFactory } from './shared-component-tests.js';
const { test, createTests } = prepareTestFactory({ root: './fixtures/solid-component/' });
const config = {
componentFilePath: './src/components/SolidComponent.jsx',
counterComponentFilePath: './src/components/Counter.jsx',
}
test.describe('Solid components in Astro files', () => {
createTests({
...config,
pageUrl: '/',
pageSourceFilePath: './src/pages/index.astro',
componentFilePath: './src/components/SolidComponent.jsx',
});
});
test.describe('Solid components in Markdown files', () => {
createTests({
...config,
pageUrl: '/markdown/',
pageSourceFilePath: './src/pages/markdown.md',
componentFilePath: './src/components/SolidComponent.jsx',
});
});
test.describe('Solid components in MDX files', () => {
createTests({
...config,
pageUrl: '/mdx/',
pageSourceFilePath: './src/pages/mdx.mdx',
componentFilePath: './src/components/SolidComponent.jsx',
});
});

View file

@ -2,29 +2,32 @@ import { prepareTestFactory } from './shared-component-tests.js';
const { test, createTests } = prepareTestFactory({ root: './fixtures/svelte-component/' });
const config = {
componentFilePath: './src/components/SvelteComponent.svelte',
counterComponentFilePath: './src/components/Counter.svelte',
counterCssFilePath: './src/components/Counter.svelte',
}
test.describe('Svelte components in Astro files', () => {
createTests({
...config,
pageUrl: '/',
pageSourceFilePath: './src/pages/index.astro',
componentFilePath: './src/components/SvelteComponent.svelte',
counterCssFilePath: './src/components/Counter.svelte',
});
});
test.describe('Svelte components in Markdown files', () => {
createTests({
...config,
pageUrl: '/markdown/',
pageSourceFilePath: './src/pages/markdown.md',
componentFilePath: './src/components/SvelteComponent.svelte',
counterCssFilePath: './src/components/Counter.svelte',
});
});
test.describe('Svelte components in MDX files', () => {
createTests({
...config,
pageUrl: '/mdx/',
pageSourceFilePath: './src/pages/mdx.mdx',
componentFilePath: './src/components/SvelteComponent.svelte',
counterCssFilePath: './src/components/Counter.svelte',
});
});

View file

@ -1,4 +1,4 @@
import { test as testBase } from '@playwright/test';
import { test as testBase, expect } from '@playwright/test';
import { loadFixture as baseLoadFixture } from '../test/test-utils.js';
export function loadFixture(inlineConfig) {
@ -29,3 +29,11 @@ export function testFactory(inlineConfig) {
return test;
}
export async function getErrorOverlayMessage(page) {
const overlay = await page.waitForSelector('vite-error-overlay', { strict: true, timeout: 10 * 1000 })
expect(overlay).toBeTruthy()
return await overlay.$$eval('.message-body', (m) => m[0].textContent)
}

View file

@ -2,29 +2,32 @@ import { prepareTestFactory } from './shared-component-tests.js';
const { test, createTests } = prepareTestFactory({ root: './fixtures/vue-component/' });
const config = {
componentFilePath: './src/components/VueComponent.vue',
counterCssFilePath: './src/components/Counter.vue',
counterComponentFilePath: './src/components/Counter.vue',
}
test.describe('Vue components in Astro files', () => {
createTests({
...config,
pageUrl: '/',
pageSourceFilePath: './src/pages/index.astro',
componentFilePath: './src/components/VueComponent.vue',
counterCssFilePath: './src/components/Counter.vue',
});
});
test.describe('Vue components in Markdown files', () => {
createTests({
...config,
pageUrl: '/markdown/',
pageSourceFilePath: './src/pages/markdown.md',
componentFilePath: './src/components/VueComponent.vue',
counterCssFilePath: './src/components/Counter.vue',
});
});
test.describe('Vue components in MDX files', () => {
createTests({
...config,
pageUrl: '/mdx/',
pageSourceFilePath: './src/pages/mdx.mdx',
componentFilePath: './src/components/VueComponent.vue',
counterCssFilePath: './src/components/Counter.vue',
});
});

View file

@ -76,7 +76,7 @@
"dev": "astro-scripts dev --prebuild \"src/runtime/server/astro-island.ts\" --prebuild \"src/runtime/client/{idle,load,media,only,visible}.ts\" \"src/**/*.ts\"",
"postbuild": "astro-scripts copy \"src/**/*.astro\"",
"benchmark": "node test/benchmark/dev.bench.js && node test/benchmark/build.bench.js",
"test": "mocha --exit --timeout 20000 --ignore **/lit-element.test.js --ignore **/errors.test.js && mocha --timeout 20000 **/lit-element.test.js && mocha --timeout 20000 **/errors.test.js",
"test": "mocha --exit --timeout 20000 --ignore **/lit-element.test.js && mocha --timeout 20000 **/lit-element.test.js",
"test:match": "mocha --timeout 20000 -g",
"test:e2e": "playwright test",
"test:e2e:match": "playwright test -g"

View file

@ -9,12 +9,12 @@ const config = {
* Maximum time expect() should wait for the condition to be met.
* For example in `await expect(locator).toHaveText();`
*/
timeout: 5000,
timeout: 3000,
},
/* Fail the build on CI if you accidentally left test in the source code. */
forbidOnly: !!process.env.CI,
/* Retry on CI only */
retries: process.env.CI ? 5 : 0,
retries: process.env.CI ? 3 : 0,
/* Opt out of parallel tests on CI. */
workers: 1,
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
@ -33,6 +33,7 @@ const config = {
use: {
browserName: 'chromium',
channel: 'chrome',
args: ["--use-gl=egl"]
},
},
],

View file

@ -4,6 +4,7 @@ import type { LogOptions } from './logger/core';
import fs from 'fs';
import { fileURLToPath } from 'url';
import * as vite from 'vite';
import { createCustomViteLogger } from './errors.js';
import astroPostprocessVitePlugin from '../vite-plugin-astro-postprocess/index.js';
import astroViteServerPlugin from '../vite-plugin-astro-server/index.js';
import astroVitePlugin from '../vite-plugin-astro/index.js';
@ -60,7 +61,7 @@ export async function createVite(
clearScreen: false, // we want to control the output, not Vite
logLevel: 'warn', // log warnings and errors only
optimizeDeps: {
entries: ['src/**/*'], // Try and scan a users project (wont catch everything),
entries: ['src/**/*'],
exclude: ['node-fetch'],
},
plugins: [
@ -133,6 +134,8 @@ export async function createVite(
sortPlugins(result.plugins);
}
result.customLogger = createCustomViteLogger(result.logLevel ?? 'warn');
return result;
}

View file

@ -1,8 +1,12 @@
import eol from 'eol';
import type { BuildResult } from 'esbuild';
import fs from 'fs';
import type { ViteDevServer } from 'vite';
import type { ViteDevServer, ErrorPayload, LogLevel, Logger } from 'vite';
import type { SSRError } from '../@types/astro';
import { fileURLToPath } from 'node:url';
import { createLogger } from 'vite';
import eol from 'eol';
import fs from 'fs';
import stripAnsi from 'strip-ansi';
import { codeFrame, createSafeError } from './util.js';
export enum AstroErrorCodes {
@ -34,11 +38,12 @@ export function cleanErrorStack(stack: string) {
return stack
.split(/\n/g)
.filter((l) => /^\s*at/.test(l))
.map(l => l.replace(/\/@fs\//g, '/'))
.join('\n');
}
/** Update the error message to correct any vite-isms that we don't want to expose to the user. */
export function fixViteErrorMessage(_err: unknown, server: ViteDevServer) {
export function fixViteErrorMessage(_err: unknown, server: ViteDevServer, filePath?: URL) {
const err = createSafeError(_err);
// Vite will give you better stacktraces, using sourcemaps.
server.ssrFixStacktrace(err);
@ -47,6 +52,18 @@ export function fixViteErrorMessage(_err: unknown, server: ViteDevServer) {
if (err.message === 'import.meta.glob() can only accept string literals.') {
err.message = 'Astro.glob() and import.meta.glob() can only accept string literals.';
}
if (filePath && /failed to load module for ssr:/.test(err.message)) {
const importName = err.message.split('for ssr:').at(1)?.trim();
if (importName) {
const content = fs.readFileSync(fileURLToPath(filePath)).toString();
const lns = content.split('\n')
const line = lns.findIndex(ln => ln.includes(importName));
const column = lns[line].indexOf(importName);
if (!(err as any).id) {
(err as any).id = `${fileURLToPath(filePath)}:${line + 1}:${column + 1}`
}
}
}
return err;
}
@ -55,7 +72,21 @@ const incompatiblePackages = {
};
const incompatPackageExp = new RegExp(`(${Object.keys(incompatiblePackages).join('|')})`);
function generateHint(err: ErrorWithMetadata): string | undefined {
export function createCustomViteLogger(logLevel: LogLevel): Logger {
const viteLogger = createLogger(logLevel);
const logger: Logger = {
...viteLogger,
error(msg, options?) {
// Silence warnings from incompatible packages (we log better errors for these)
if (incompatPackageExp.test(msg)) return;
return viteLogger.error(msg, options);
},
}
return logger;
}
function generateHint(err: ErrorWithMetadata, filePath?: URL): string | undefined {
if (/Unknown file extension \"\.(jsx|vue|svelte|astro|css)\" for /.test(err.message)) {
return 'You likely need to add this package to `vite.ssr.noExternal` in your astro config file.';
} else {
@ -72,29 +103,54 @@ function generateHint(err: ErrorWithMetadata): string | undefined {
* Takes any error-like object and returns a standardized Error + metadata object.
* Useful for consistent reporting regardless of where the error surfaced from.
*/
export function collectErrorMetadata(e: any): ErrorWithMetadata {
// normalize error stack line-endings to \n
export function collectErrorMetadata(e: any, filePath?: URL): ErrorWithMetadata {
const err = e as SSRError;
if ((e as any).stack) {
// normalize error stack line-endings to \n
(e as any).stack = eol.lf((e as any).stack);
// derive error location from stack (if possible)
const stackText = stripAnsi(e.stack);
// TODO: this could be better, `src` might be something else
const possibleFilePath = err.pluginCode || err.id || stackText.split('\n').find(ln => ln.includes('src') || ln.includes('node_modules'));
const source = possibleFilePath?.replace(/^[^(]+\(([^)]+).*$/, '$1');
const [file, line, column] = source?.split(':') ?? [];
if (!err.loc && line && column) {
err.loc = {
file,
line: Number.parseInt(line),
column: Number.parseInt(column)
}
}
// Derive plugin from stack (if possible)
if (!err.plugin) {
err.plugin =
/withastro\/astro\/packages\/integrations\/([\w-]+)/gmi.exec(stackText)?.at(1) ||
/(@astrojs\/[\w-]+)\/(server|client|index)/gmi.exec(stackText)?.at(1) ||
undefined;
}
// Normalize stack (remove `/@fs/` urls, etc)
err.stack = cleanErrorStack(e.stack)
}
if (e.name === 'YAMLException') {
const err = e as SSRError;
err.loc = { file: (e as any).id, line: (e as any).mark.line, column: (e as any).mark.column };
err.message = (e as any).reason;
}
if (!err.frame) {
try {
const fileContents = fs.readFileSync(err.loc.file!, 'utf8');
err.frame = codeFrame(fileContents, err.loc);
} catch {}
}
if (!err.frame && err.loc) {
try {
const fileContents = fs.readFileSync(err.loc.file!, 'utf8');
const frame = codeFrame(fileContents, err.loc);
err.frame = frame;
} catch {}
}
// Astro error (thrown by esbuild so it needs to be formatted for Vite)
if (Array.isArray((e as any).errors)) {
const { location, pluginName, text } = (e as BuildResult).errors[0];
const err = e as SSRError;
if (location) {
err.loc = { file: location.file, line: location.line, column: location.column };
err.id = err.id || location?.file;
@ -111,11 +167,28 @@ export function collectErrorMetadata(e: any): ErrorWithMetadata {
if (pluginName) {
err.plugin = pluginName;
}
err.hint = generateHint(err);
err.hint = generateHint(err, filePath);
return err;
}
// Generic error (probably from Vite, and already formatted)
e.hint = generateHint(e);
return e;
err.hint = generateHint(e, filePath);
return err;
}
export function getViteErrorPayload(err: ErrorWithMetadata): ErrorPayload {
let plugin = err.plugin;
if (!plugin && err.hint) {
plugin = 'astro';
}
const message = `${err.message}\n\n${err.hint ?? ''}`;
return {
type: 'error',
err: {
...err,
plugin,
message: message.trim(),
stack: err.stack,
}
}
}

View file

@ -79,7 +79,7 @@ export function createSafeError(err: any): Error {
/** generate code frame from esbuild error */
export function codeFrame(src: string, loc: ErrorPayload['err']['loc']): string {
if (!loc) return '';
const lines = eol.lf(src).split('\n');
const lines = eol.lf(src).split('\n').map(ln => ln.replace(/\t/g, ' '));
// grab 2 lines before, and 3 lines after focused line
const visibleLines = [];
for (let n = -2; n <= 2; n++) {
@ -98,9 +98,7 @@ export function codeFrame(src: string, loc: ErrorPayload['err']['loc']): string
output += isFocusedLine ? '> ' : ' ';
output += `${lineNo + 1} | ${lines[lineNo]}\n`;
if (isFocusedLine)
output += `${[...new Array(gutterWidth)].join(' ')} | ${[...new Array(loc.column)].join(
' '
)}^\n`;
output += `${Array.from({ length: gutterWidth }).join(' ')} | ${Array.from({ length: loc.column }).join(' ')}^\n`;
}
return output;
}

View file

@ -156,6 +156,7 @@ export function mergeSlots(...slotted: unknown[]) {
}
export const Fragment = Symbol.for('astro:fragment');
export const Renderer = Symbol.for('astro:renderer');
export const ClientOnlyPlaceholder = 'astro-client-only';
function guessRenderers(componentUrl?: string): string[] {
@ -182,6 +183,17 @@ function formatList(values: string[]): string {
const rendererAliases = new Map([['solid', 'solid-js']]);
/** @internal Assosciate JSX components with a specific renderer (see /src/vite-plugin-jsx/tag.ts) */
export function __astro_tag_component__(Component: unknown, rendererName: string) {
if (!Component) return;
if (typeof Component !== 'function') return;
Object.defineProperty(Component, Renderer, {
value: rendererName,
enumerable: false,
writable: false
})
}
export async function renderComponent(
result: SSRResult,
displayName: string,
@ -270,20 +282,30 @@ Did you mean to add ${formatList(probableRendererNames.map((r) => '`' + r + '`')
// Call the renderers `check` hook to see if any claim this component.
let renderer: SSRLoadedRenderer | undefined;
if (metadata.hydrate !== 'only') {
let error;
for (const r of renderers) {
try {
if (await r.ssr.check.call({ result }, Component, props, children)) {
renderer = r;
break;
}
} catch (e) {
error ??= e;
}
// If this component ran through `__astro_tag_component__`, we already know
// which renderer to match to and can skip the usual `check` calls.
// This will help us throw most relevant error message for modules with runtime errors
if (Component && (Component as any)[Renderer]) {
const rendererName = (Component as any)[Renderer];
renderer = renderers.find(({ name }) => name === rendererName);
}
if (error) {
throw error;
if (!renderer) {
let error;
for (const r of renderers) {
try {
if (await r.ssr.check.call({ result }, Component, props, children)) {
renderer = r;
break;
}
} catch (e) {
error ??= e;
}
}
if (error) {
throw error;
}
}
if (!renderer && typeof HTMLElement === 'function' && componentIsHTMLElement(Component)) {
@ -298,9 +320,9 @@ Did you mean to add ${formatList(probableRendererNames.map((r) => '`' + r + '`')
const rendererName = rendererAliases.has(passedName)
? rendererAliases.get(passedName)
: passedName;
renderer = renderers.filter(
renderer = renderers.find(
({ name }) => name === `@astrojs/${rendererName}` || name === rendererName
)[0];
);
}
// Attempt: user only has a single renderer, default to that
if (!renderer && renderers.length === 1) {
@ -766,17 +788,21 @@ export async function renderPage(
start(controller) {
async function read() {
let i = 0;
for await (const chunk of iterable) {
let html = chunk.toString();
if (i === 0) {
if (!/<!doctype html/i.test(html)) {
controller.enqueue(encoder.encode('<!DOCTYPE html>\n'));
try {
for await (const chunk of iterable) {
let html = chunk.toString();
if (i === 0) {
if (!/<!doctype html/i.test(html)) {
controller.enqueue(encoder.encode('<!DOCTYPE html>\n'));
}
}
controller.enqueue(encoder.encode(html));
i++;
}
controller.enqueue(encoder.encode(html));
i++;
controller.close();
} catch (e) {
controller.error(e)
}
controller.close();
}
read();
},
@ -851,7 +877,7 @@ export async function* renderAstroComponent(
if (value || value === 0) {
for await (const chunk of _render(value)) {
yield markHTMLString(chunk);
}
}
}
}
}

View file

@ -1,80 +0,0 @@
import { encode } from 'html-entities';
import { baseCSS } from './css.js';
interface ErrorTemplateOptions {
/** a short description of the error */
message: string;
/** information about where the error occurred */
stack?: string;
/** HTTP error code */
statusCode?: number;
/** HTML <title> */
tabTitle: string;
/** page title */
title: string;
/** show user a URL for more info or action to take */
url?: string;
}
/** Display all errors */
export default function template({
title,
url,
message,
stack,
statusCode,
tabTitle,
}: ErrorTemplateOptions): string {
let error = url ? message.replace(url, '') : message;
return `<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>${tabTitle}</title>
<style>
${baseCSS}
.wrapper {
max-width: 80rem;
margin-left: auto;
margin-right: auto;
padding-left: 1.5rem;
padding-right: 1.5rem;
width: 100%;
}
.statusCode {
color: var(--orange);
}
h1 {
margin-top: 0;
}
header {
margin-bottom: 3rem;
margin-top: 4rem;
text-align: center;
}
.astro {
height: 4rem;
width: 4rem;
}
</style>
</head>
<body>
<main class="wrapper">
<header>
<svg class="astro" viewBox="0 0 256 256" fill="none" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" clip-rule="evenodd" d="M163.008 18.929c1.944 2.413 2.935 5.67 4.917 12.181l43.309 142.27a180.277 180.277 0 00-51.778-17.53l-28.198-95.29a3.67 3.67 0 00-7.042.01l-27.857 95.232a180.225 180.225 0 00-52.01 17.557l43.52-142.281c1.99-6.502 2.983-9.752 4.927-12.16a15.999 15.999 0 016.484-4.798c2.872-1.154 6.271-1.154 13.07-1.154h31.085c6.807 0 10.211 0 13.086 1.157a16.004 16.004 0 016.487 4.806z" fill="white"></path><path fill-rule="evenodd" clip-rule="evenodd" d="M168.19 180.151c-7.139 6.105-21.39 10.268-37.804 10.268-20.147 0-37.033-6.272-41.513-14.707-1.602 4.835-1.961 10.367-1.961 13.902 0 0-1.056 17.355 11.015 29.426 0-6.268 5.081-11.349 11.349-11.349 10.743 0 10.731 9.373 10.721 16.977v.679c0 11.542 7.054 21.436 17.086 25.606a23.27 23.27 0 01-2.339-10.2c0-11.008 6.463-15.107 13.974-19.87 5.976-3.79 12.616-8.001 17.192-16.449a31.024 31.024 0 003.743-14.82c0-3.299-.513-6.479-1.463-9.463z" fill="#ff5d01"></path></svg>
<h1>${
statusCode ? `<span class="statusCode">${statusCode}: </span> ` : ''
}<span class="statusMessage">${title}</span></h1>
</header>
<pre class="error-message">${encode(error)}</pre>
${url ? `<a target="_blank" href="${url}">${url}</a>` : ''}
<pre class="error-stack">${encode(stack)}</pre>
</main>
</body>
</html>`;
}

View file

@ -5,9 +5,8 @@ import type { AstroConfig, ManifestData } from '../@types/astro';
import type { SSROptions } from '../core/render/dev/index';
import { Readable } from 'stream';
import stripAnsi from 'strip-ansi';
import { call as callEndpoint } from '../core/endpoint/dev/index.js';
import { collectErrorMetadata, fixViteErrorMessage } from '../core/errors.js';
import { collectErrorMetadata, ErrorWithMetadata, fixViteErrorMessage, getViteErrorPayload } from '../core/errors.js';
import { error, info, LogOptions, warn } from '../core/logger/core.js';
import * as msg from '../core/messages.js';
import { appendForwardSlash } from '../core/path.js';
@ -18,7 +17,6 @@ import { createRequest } from '../core/request.js';
import { createRouteManifest, matchRoute } from '../core/routing/index.js';
import { createSafeError, isBuildingToSSR, resolvePages } from '../core/util.js';
import notFoundTemplate, { subpathNotUsedTemplate } from '../template/4xx.js';
import serverErrorTemplate from '../template/5xx.js';
interface AstroPluginOptions {
config: AstroConfig;
@ -151,19 +149,14 @@ async function handle500Response(
origin: string,
req: http.IncomingMessage,
res: http.ServerResponse,
err: any
err: ErrorWithMetadata
) {
const pathname = decodeURI(new URL('./index.html', origin + req.url).pathname);
const html = serverErrorTemplate({
statusCode: 500,
title: 'Internal Error',
tabTitle: '500: Error',
message: stripAnsi(err.hint ?? err.message),
url: err.url || undefined,
stack: truncateString(stripAnsi(err.stack), 500),
});
const transformedHtml = await viteServer.transformIndexHtml(pathname, html);
writeHtmlResponse(res, 500, transformedHtml);
res.on('close', () => setTimeout(() => viteServer.ws.send(getViteErrorPayload(err)), 200))
if (res.headersSent) {
res.end()
} else {
writeHtmlResponse(res, 500, `<title>${err.name}</title><script type="module" src="/@vite/client"></script>`);
}
}
function getCustom404Route(config: AstroConfig, manifest: ManifestData) {
@ -232,6 +225,7 @@ async function handleRequest(
clientAddress: buildingToSSR ? req.socket.remoteAddress : undefined,
});
let filePath: URL|undefined;
try {
if (!pathname.startsWith(devRoot)) {
log404(logging, pathname);
@ -252,7 +246,7 @@ async function handleRequest(
}
}
const filePath = new URL(`./${route.component}`, config.root);
filePath = new URL(`./${route.component}`, config.root);
const preloadedComponent = await preload({ astroConfig: config, filePath, viteServer });
const [, mod] = preloadedComponent;
// attempt to get static paths
@ -330,10 +324,10 @@ async function handleRequest(
return await writeSSRResult(result, res);
}
} catch (_err) {
const err = fixViteErrorMessage(createSafeError(_err), viteServer);
const errorWithMetadata = collectErrorMetadata(_err);
const err = fixViteErrorMessage(createSafeError(_err), viteServer, filePath);
const errorWithMetadata = collectErrorMetadata(err);
error(logging, null, msg.formatErrorMessage(errorWithMetadata));
handle500Response(viteServer, origin, req, res, err);
handle500Response(viteServer, origin, req, res, errorWithMetadata);
}
}

View file

@ -5,6 +5,7 @@ import type { LogOptions } from '../core/logger/core.js';
import type { PluginMetadata } from '../vite-plugin-astro/types';
import babel from '@babel/core';
import tagExportsPlugin from './tag.js';
import * as eslexer from 'es-module-lexer';
import esbuild from 'esbuild';
import * as colors from 'kleur/colors';
@ -55,7 +56,7 @@ async function transformJSX({
}: TransformJSXOptions): Promise<TransformResult> {
const { jsxTransformOptions } = renderer;
const options = await jsxTransformOptions!({ mode, ssr });
const plugins = [...(options.plugins || [])];
const plugins = [...(options.plugins || []), tagExportsPlugin({ rendererName: renderer.name })];
const result = await babel.transformAsync(code, {
presets: options.presets,
plugins,

View file

@ -0,0 +1,64 @@
import type { PluginObj } from '@babel/core';
import * as t from '@babel/types';
/**
* This plugin handles every file that runs through our JSX plugin.
* Since we statically match every JSX file to an Astro renderer based on import scanning,
* it would be helpful to embed some of that metadata at runtime.
*
* This plugin crawls each export in the file and "tags" each export with a given `rendererName`.
* This allows us to automatically match a component to a renderer and skip the usual `check()` calls.
*/
export default function tagExportsWithRenderer({ rendererName }: { rendererName: string }): PluginObj {
return {
visitor: {
Program: {
// Inject `import { __astro_tag_component__ } from 'astro/server/index.js'`
enter(path) {
path.node.body.splice(
0,
0,
t.importDeclaration(
[t.importSpecifier(t.identifier('__astro_tag_component__'), t.identifier('__astro_tag_component__'))],
t.stringLiteral('astro/server/index.js')
)
);
},
// For each export we found, inject `__astro_tag_component__(exportName, rendererName)`
exit(path, state) {
const exportedIds = state.get('astro:tags')
if (exportedIds) {
for (const id of exportedIds) {
path.node.body.push(
t.expressionStatement(t.callExpression(t.identifier('__astro_tag_component__'), [t.identifier(id), t.stringLiteral(rendererName)]))
)
}
}
}
},
ExportDeclaration(path, state) {
const node = path.node;
if (node.exportKind === 'type') return;
if (node.type === 'ExportAllDeclaration') return;
if (node.type === 'ExportNamedDeclaration') {
if (t.isFunctionDeclaration(node.declaration)) {
if (node.declaration.id?.name) {
const id = node.declaration.id.name;
const tags = state.get('astro:tags') ?? []
state.set('astro:tags', [...tags, id])
}
}
} else if (node.type === 'ExportDefaultDeclaration') {
if (t.isFunctionDeclaration(node.declaration)) {
if (node.declaration.id?.name) {
const id = node.declaration.id.name;
const tags = state.get('astro:tags') ?? []
state.set('astro:tags', [...tags, id])
}
}
}
},
}
};
}

View file

@ -1,29 +0,0 @@
import { isWindows, loadFixture } from './test-utils.js';
import { expect } from 'chai';
import * as cheerio from 'cheerio';
describe('Error packages: react-spectrum', () => {
if (isWindows) return;
/** @type {import('./test-utils').Fixture} */
let fixture;
/** @type {import('./test-utils').DevServer} */
let devServer;
before(async () => {
fixture = await loadFixture({
root: './fixtures/error-react-spectrum',
});
});
after(async () => {
devServer && devServer.stop();
});
it('properly detect syntax errors in template', async () => {
devServer = await fixture.startDevServer();
let html = await fixture.fetch('/').then((res) => res.text());
let $ = cheerio.load(html);
const msg = $('.error-message').text();
expect(msg).to.match(/@adobe\/react-spectrum is not compatible/);
});
});

View file

@ -1,100 +0,0 @@
import { isWindows, isLinux, loadFixture } from './test-utils.js';
import { expect } from 'chai';
import * as cheerio from 'cheerio';
describe('Error display', () => {
if (isWindows) return;
/** @type {import('./test-utils').Fixture} */
let fixture;
before(async () => {
fixture = await loadFixture({
root: './fixtures/errors',
});
});
/**
* TODO: Track down reliability issue
*
* After fixing a syntax error on one page, the dev server hangs on the hmr.js request.
* This is specific to a project that has other framework component errors,
* in this case the fixture has multiple broken pages and components.
*
* The issue could be internal to vite, the hmr.js request triggers connect:dispatcher
* events but vite:load is never actually called.
*/
describe.skip('Astro template syntax', async () => {
let devServer;
beforeEach(async () => {
devServer = await fixture.startDevServer();
});
afterEach(async () => {
await devServer.stop();
});
it('properly detect syntax errors in template', async () => {
let html = await fixture.fetch('/astro-syntax-error').then((res) => res.text());
// 1. Verify an error message is being shown.
let $ = cheerio.load(html);
expect($('.statusMessage').text()).to.equal('Internal Error');
expect($('.error-message').text()).to.contain('Unexpected "}"');
// 2. Edit the file, fixing the error
await fixture.editFile('./src/pages/astro-syntax-error.astro', `<h1>No syntax error</h1>`);
// 3. Verify that the file is fixed.
html = await fixture.fetch('/astro-syntax-error').then((res) => res.text());
$ = cheerio.load(html);
expect($('h1').text()).to.equal('No syntax error');
});
it('shows useful error when frontmatter import is not found', async () => {
let html = await fixture.fetch('/import-not-found').then((res) => res.text());
// 1. Verify an error message is being shown.
let $ = cheerio.load(html);
expect($('.statusMessage').text()).to.equal('Internal Error');
expect($('.error-message').text()).to.equal('failed to load module for ssr: ../abc.astro');
// 2. Edit the file, fixing the error
await fixture.editFile('./src/pages/import-not-found.astro', '<h1>No import error</h1>');
// 3. Verify that the file is fixed.
html = await fixture.fetch('/import-not-found').then((res) => res.text());
$ = cheerio.load(html);
expect($('h1').text()).to.equal('No import error');
});
});
describe('Framework components', function () {
let devServer;
before(async () => {
devServer = await fixture.startDevServer();
});
after(async () => {
await devServer.stop();
});
it('Errors recover when fixed', async () => {
let html = await fixture.fetch('/svelte-syntax-error').then((res) => res.text());
// 1. Verify an error message is being shown.
let $ = cheerio.load(html);
expect($('.statusMessage').text()).to.equal('Internal Error');
// 2. Edit the file, fixing the error
await fixture.editFile('./src/components/SvelteSyntaxError.svelte', `<h1>No mismatch</h1>`);
// 3. Verify that the file is fixed.
html = await fixture.fetch('/svelte-syntax-error').then((res) => res.text());
$ = cheerio.load(html);
expect($('h1').text()).to.equal('No mismatch');
});
});
});

View file

@ -1,26 +0,0 @@
import { expect } from 'chai';
import os from 'os';
import { loadFixture } from './test-utils.js';
// note: many Sass tests live in 0-css.test.js to test within context of a framework.
// these tests are independent of framework.
describe('Sass', () => {
let fixture;
let devServer;
before(async () => {
fixture = await loadFixture({ root: './fixtures/sass/' });
devServer = await fixture.startDevServer();
});
after(async () => {
await devServer.stop();
});
// TODO: Sass cannot be found on macOS for some reason... Vite issue?
const test = os.platform() === 'darwin' ? it.skip : it;
test('shows helpful error on failure', async () => {
const text = await fixture.fetch('/error').then((res) => res.text());
expect(text).to.include('Undefined variable');
});
});

View file

@ -700,6 +700,48 @@ importers:
'@astrojs/vue': link:../../../../integrations/vue
astro: link:../../..
packages/astro/e2e/fixtures/error-react-spectrum:
specifiers:
'@adobe/react-spectrum': ^3.18.0
'@astrojs/react': workspace:*
astro: workspace:*
react: ^18.1.0
react-dom: ^18.1.0
dependencies:
'@adobe/react-spectrum': 3.19.0_biqbaboplfbrettd7655fr4n2y
'@astrojs/react': link:../../../../integrations/react
astro: link:../../..
react: 18.2.0
react-dom: 18.2.0_react@18.2.0
packages/astro/e2e/fixtures/error-sass:
specifiers:
astro: workspace:*
sass: ^1.52.2
dependencies:
astro: link:../../..
sass: 1.53.0
packages/astro/e2e/fixtures/errors:
specifiers:
'@astrojs/preact': workspace:*
'@astrojs/react': workspace:*
'@astrojs/solid-js': workspace:*
'@astrojs/svelte': workspace:*
'@astrojs/vue': workspace:*
astro: workspace:*
react: ^18.1.0
react-dom: ^18.1.0
dependencies:
'@astrojs/preact': link:../../../../integrations/preact
'@astrojs/react': link:../../../../integrations/react
'@astrojs/solid-js': link:../../../../integrations/solid
'@astrojs/svelte': link:../../../../integrations/svelte
'@astrojs/vue': link:../../../../integrations/vue
astro: link:../../..
react: 18.2.0
react-dom: 18.2.0_react@18.2.0
packages/astro/e2e/fixtures/lit-component:
specifiers:
'@astrojs/lit': workspace:*
@ -1467,40 +1509,6 @@ importers:
'@astrojs/preact': link:../../../../integrations/preact
astro: link:../../..
packages/astro/test/fixtures/error-react-spectrum:
specifiers:
'@adobe/react-spectrum': ^3.18.0
'@astrojs/react': workspace:*
astro: workspace:*
react: ^18.1.0
react-dom: ^18.1.0
dependencies:
'@adobe/react-spectrum': 3.19.0_biqbaboplfbrettd7655fr4n2y
'@astrojs/react': link:../../../../integrations/react
astro: link:../../..
react: 18.2.0
react-dom: 18.2.0_react@18.2.0
packages/astro/test/fixtures/errors:
specifiers:
'@astrojs/preact': workspace:*
'@astrojs/react': workspace:*
'@astrojs/solid-js': workspace:*
'@astrojs/svelte': workspace:*
'@astrojs/vue': workspace:*
astro: workspace:*
react: ^18.1.0
react-dom: ^18.1.0
dependencies:
'@astrojs/preact': link:../../../../integrations/preact
'@astrojs/react': link:../../../../integrations/react
'@astrojs/solid-js': link:../../../../integrations/solid
'@astrojs/svelte': link:../../../../integrations/svelte
'@astrojs/vue': link:../../../../integrations/vue
astro: link:../../..
react: 18.2.0
react-dom: 18.2.0_react@18.2.0
packages/astro/test/fixtures/fetch:
specifiers:
'@astrojs/preact': workspace:*
@ -1679,14 +1687,6 @@ importers:
dependencies:
astro: link:../../..
packages/astro/test/fixtures/sass:
specifiers:
astro: workspace:*
sass: ^1.52.2
dependencies:
astro: link:../../..
sass: 1.53.0
packages/astro/test/fixtures/slots-preact:
specifiers:
'@astrojs/preact': workspace:*