Skip to content

feat(preview-server): Proper error handling for prettier's invalid HTML error #2265

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 19 commits into
base: canary
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/rare-fans-worry.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@react-email/preview-server": patch
"react-email": patch
---

Add custom error handling for prettier's syntax errors
24 changes: 24 additions & 0 deletions packages/preview-server/jsx-runtime/jsx-dev-runtime.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
// This hack is necessary because React forces the use of the non-dev JSX runtime
// when NODE_ENV is set to 'production', which would break the data-source references
// we need for stack traces in the preview server.
const ReactJSXDevRuntime = require('react/jsx-dev-runtime');

export function jsxDEV(type, props, key, isStaticChildren, source, self) {
const newProps = { ...props };

if (source) {
newProps['data-source-file'] = source.fileName;
newProps['data-source-line'] = source.lineNumber;
}

return ReactJSXDevRuntime.jsxDEV(
type,
newProps,
key,
isStaticChildren,
source,
self,
);
}

export const Fragment = ReactJSXDevRuntime.Fragment;
113 changes: 106 additions & 7 deletions packages/preview-server/src/actions/render-email-by-path.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,15 @@ import path from 'node:path';
import chalk from 'chalk';
import logSymbols from 'log-symbols';
import ora, { type Ora } from 'ora';
import { isBuilding, isPreviewDevelopment } from '../app/env';
import {
isBuilding,
isPreviewDevelopment,
previewServerLocation,
userProjectLocation,
} from '../app/env';
import { convertStackWithSourceMap } from '../utils/convert-stack-with-sourcemap';
import { createJsxRuntime } from '../utils/create-jsx-runtime';
import { getEmailComponent } from '../utils/get-email-component';
import { improveErrorWithSourceMap } from '../utils/improve-error-with-sourcemap';
import { registerSpinnerAutostopping } from '../utils/register-spinner-autostopping';
import type { ErrorObject } from '../utils/types/error-object';

Expand Down Expand Up @@ -44,7 +50,15 @@ export const renderEmailByPath = async (
registerSpinnerAutostopping(spinner);
}

const componentResult = await getEmailComponent(emailPath);
const originalJsxRuntimePath = path.resolve(
previewServerLocation,
'jsx-runtime',
);
const jsxRuntimePath = await createJsxRuntime(
userProjectLocation,
originalJsxRuntimePath,
);
const componentResult = await getEmailComponent(emailPath, jsxRuntimePath);

if ('error' in componentResult) {
spinner?.stopAndPersist({
Expand Down Expand Up @@ -110,12 +124,97 @@ export const renderEmailByPath = async (
text: `Failed while rendering ${emailFilename}`,
});

return {
error: improveErrorWithSourceMap(
error,
if (exception instanceof SyntaxError) {
interface SpanPosition {
file: {
content: string;
};
offset: number;
line: number;
col: number;
}
// means the email's HTML was invalid and prettier threw this error
// TODO: always throw when the HTML is invalid during `render`
const cause = exception.cause as {
msg: string;
span: {
start: SpanPosition;
end: SpanPosition;
};
};

const sourceFileAttributeMatches = cause.span.start.file.content.matchAll(
/data-source-file="(?<file>[^"]*)"/g,
);
let closestSourceFileAttribute: RegExpExecArray | undefined;
for (const sourceFileAttributeMatch of sourceFileAttributeMatches) {
if (closestSourceFileAttribute === undefined) {
closestSourceFileAttribute = sourceFileAttributeMatch;
}
if (
Math.abs(sourceFileAttributeMatch.index - cause.span.start.offset) <
Math.abs(closestSourceFileAttribute.index - cause.span.start.offset)
) {
closestSourceFileAttribute = sourceFileAttributeMatch;
}
}

const findClosestAttributeValue = (
attributeName: string,
): string | undefined => {
const attributeMatches = cause.span.start.file.content.matchAll(
new RegExp(`${attributeName}="(?<value>[^"]*)"`, 'g'),
);
let closestAttribute: RegExpExecArray | undefined;
for (const attributeMatch of attributeMatches) {
if (closestAttribute === undefined) {
closestAttribute = attributeMatch;
}
if (
Math.abs(attributeMatch.index - cause.span.start.offset) <
Math.abs(closestAttribute.index - cause.span.start.offset)
) {
closestAttribute = attributeMatch;
}
}
return closestAttribute?.groups?.value;
};

let stack = convertStackWithSourceMap(
error.stack,
emailPath,
sourceMapToOriginalFile,
),
);

const sourceFile = findClosestAttributeValue('data-source-file');
const sourceLine = findClosestAttributeValue('data-source-line');
if (sourceFile && sourceLine) {
stack = ` at ${sourceFile}:${sourceLine}\n${stack}`;
}

return {
error: {
name: exception.name,
message: cause.msg,
stack,
cause: error.cause ? JSON.parse(JSON.stringify(cause)) : undefined,
},
};
}

return {
error: {
name: error.name,
message: error.message,
stack: convertStackWithSourceMap(
error.stack,
emailPath,
sourceMapToOriginalFile,
),
cause: error.cause
? JSON.parse(JSON.stringify(error.cause))
: undefined,
},
};
}
};
3 changes: 3 additions & 0 deletions packages/preview-server/src/app/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@ export const emailsDirRelativePath = process.env.EMAILS_DIR_RELATIVE_PATH!;
/** ONLY ACCESSIBLE ON THE SERVER */
export const userProjectLocation = process.env.USER_PROJECT_LOCATION!;

/** ONLY ACCESSIBLE ON THE SERVER */
export const previewServerLocation = process.env.PREVIEW_SERVER_LOCATION!;

/** ONLY ACCESSIBLE ON THE SERVER */
export const emailsDirectoryAbsolutePath =
process.env.EMAILS_DIR_ABSOLUTE_PATH!;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
'use client';
import type { ErrorObject } from '../../../utils/types/error-object';

interface ErrorOverlayProps {
error: ErrorObject;
}

const Message = ({ children: content }: { children: string }) => {
const match = content.match(
/(Unexpected closing tag "[^"]+". It may happen when the tag has already been closed by another tag). (For more info see) (.+)/,
);
if (match) {
const [_, errorMessage, moreInfo, link] = match;
return (
<>
{errorMessage}.
<p className="text-lg">
{moreInfo}{' '}
<a className="underline" rel="noreferrer" target="_blank" href={link}>
{link}
</a>
</p>
</>
);
}
return content;
};

export const ErrorOverlay = ({ error }: ErrorOverlayProps) => {
return (
<>
<div className="absolute inset-0 z-50 bg-black/80" />
<div
className="
min-h-[50vh] w-full max-w-lg sm:rounded-lg md:max-w-[568px] lg:max-w-[920px]
absolute left-[50%] top-[50%] z-50 translate-x-[-50%] translate-y-[-50%]
rounded-t-sm overflow-hidden bg-white text-black shadow-lg duration-200
flex flex-col selection:!text-black
"
>
<div className="bg-red-500 h-3" />
<div className="flex flex-grow p-6 min-w-0 max-w-full flex-col space-y-1.5">
<div className="flex-shrink pb-2 text-xl tracking-tight">
<b>{error.name}</b>: <Message>{error.message}</Message>
</div>
{error.stack ? (
<div className="flex-grow scroll-px-4 overflow-x-auto rounded-lg bg-black p-2 text-gray-100">
<pre className="w-full min-w-0 font-mono leading-6 selection:!text-cyan-12 text-xs">
{error.stack}
</pre>
</div>
) : undefined}
</div>
</div>
</>
);
};
4 changes: 2 additions & 2 deletions packages/preview-server/src/app/preview/[...slug]/preview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ import { ViewSizeControls } from '../../../components/topbar/view-size-controls'
import { PreviewContext } from '../../../contexts/preview';
import { useClampedState } from '../../../hooks/use-clamped-state';
import { cn } from '../../../utils';
import { RenderingError } from './rendering-error';
import { ErrorOverlay } from './error-overlay';

interface PreviewProps extends React.ComponentProps<'div'> {
emailTitle: string;
Expand Down Expand Up @@ -136,7 +136,7 @@ const Preview = ({ emailTitle, className, ...props }: PreviewProps) => {
};
}}
>
{hasErrors ? <RenderingError error={renderingResult.error} /> : null}
{hasErrors ? <ErrorOverlay error={renderingResult.error} /> : null}

{hasRenderingMetadata ? (
<>
Expand Down

This file was deleted.

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -1,14 +1,13 @@
import path from 'node:path';
import { type RawSourceMap, SourceMapConsumer } from 'source-map-js';
import * as stackTraceParser from 'stacktrace-parser';
import type { ErrorObject } from './types/error-object';

export const improveErrorWithSourceMap = (
error: Error,
export const convertStackWithSourceMap = (
rawStack: string | undefined,

originalFilePath: string,
sourceMapToOriginalFile: RawSourceMap,
): ErrorObject => {
): string | undefined => {
let stack: string | undefined;

const sourceRoot =
Expand All @@ -32,8 +31,8 @@ export const improveErrorWithSourceMap = (
})`;
};

if (typeof error.stack !== 'undefined') {
const parsedStack = stackTraceParser.parse(error.stack);
if (rawStack) {
const parsedStack = stackTraceParser.parse(rawStack);
const sourceMapConsumer = new SourceMapConsumer(sourceMapToOriginalFile);
const newStackLines = [] as string[];
for (const stackFrame of parsedStack) {
Expand Down Expand Up @@ -77,9 +76,5 @@ export const improveErrorWithSourceMap = (
stack = newStackLines.join('\n');
}

return {
name: error.name,
message: error.message,
stack,
};
return stack;
};
37 changes: 37 additions & 0 deletions packages/preview-server/src/utils/create-jsx-runtime.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import fs from 'node:fs';
import path from 'node:path';
import esbuild from 'esbuild';

/**
* Bundles the JSX runtime with the specified {@link cwd}. This is needed because the JSX runtime
* imports React's which is forcefully the production one if the `NODE_ENV` is set to `production`,
* even though we want to use the development one.
*
* It bundles into `/node_modules/.react-email-jsx-runtime` with the root being the {@link cwd}.
*/
export const createJsxRuntime = async (
cwd: string,
originalJsxRuntimePath: string,
) => {
const jsxRuntimePath = path.join(
cwd,
'node_modules',
'.react-email-jsx-runtime',
);
await esbuild.build({
bundle: true,
outfile: path.join(jsxRuntimePath, 'jsx-dev-runtime.js'),
format: 'cjs',
logLevel: 'silent',
stdin: {
resolveDir: cwd,
sourcefile: 'jsx-dev-runtime.js',
loader: 'js',
contents: await fs.promises.readFile(
path.join(originalJsxRuntimePath, 'jsx-dev-runtime.js'),
),
},
});

return jsxRuntimePath;
};
2 changes: 2 additions & 0 deletions packages/preview-server/src/utils/get-email-component.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ describe('getEmailComponent()', () => {
test('Request', async () => {
const result = await getEmailComponent(
path.resolve(__dirname, './testing/request-response-email.tsx'),
path.resolve(__dirname, '../../jsx-runtime'),
);
if ('error' in result) {
console.log(result.error);
Expand All @@ -20,6 +21,7 @@ describe('getEmailComponent()', () => {
__dirname,
'../../../../apps/demo/emails/notifications/vercel-invite-user.tsx',
),
path.resolve(__dirname, '../../jsx-runtime'),
);

if ('error' in result) {
Expand Down
Loading
Loading