Skip to content

Commit e52818c

Browse files
authored
feat(preview-server): error handling for prettier's invalid HTML error (#2265)
1 parent 6062566 commit e52818c

File tree

17 files changed

+320
-107
lines changed

17 files changed

+320
-107
lines changed

.changeset/rare-fans-worry.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"@react-email/preview-server": minor
3+
"react-email": minor
4+
---
5+
6+
add custom error handling for prettier's syntax errors
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
// This hack is necessary because React forces the use of the non-dev JSX runtime
2+
// when NODE_ENV is set to 'production', which would break the data-source references
3+
// we need for stack traces in the preview server.
4+
const ReactJSXDevRuntime = require('react/jsx-dev-runtime');
5+
6+
export function jsxDEV(type, props, key, isStaticChildren, source, self) {
7+
const newProps = { ...props };
8+
9+
if (source && shouldIncludeSourceReference) {
10+
newProps['data-source-file'] = source.fileName;
11+
newProps['data-source-line'] = source.lineNumber;
12+
}
13+
14+
return ReactJSXDevRuntime.jsxDEV(
15+
type,
16+
newProps,
17+
key,
18+
isStaticChildren,
19+
source,
20+
self,
21+
);
22+
}
23+
24+
export const Fragment = ReactJSXDevRuntime.Fragment;

packages/preview-server/scripts/dev.mts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ await fs.writeFile(
1616
`EMAILS_DIR_RELATIVE_PATH=./emails
1717
EMAILS_DIR_ABSOLUTE_PATH=${emailsDirectoryPath}
1818
USER_PROJECT_LOCATION=${previewServerRoot}
19+
PREVIEW_SERVER_LOCATION=${previewServerRoot}
1920
NEXT_PUBLIC_IS_PREVIEW_DEVELOPMENT=true`,
2021
'utf8',
2122
);

packages/preview-server/src/actions/render-email-by-path.tsx

Lines changed: 122 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,25 @@ import path from 'node:path';
44
import chalk from 'chalk';
55
import logSymbols from 'log-symbols';
66
import ora, { type Ora } from 'ora';
7-
import { isBuilding, isPreviewDevelopment } from '../app/env';
7+
import {
8+
isBuilding,
9+
isPreviewDevelopment,
10+
previewServerLocation,
11+
userProjectLocation,
12+
} from '../app/env';
13+
import { convertStackWithSourceMap } from '../utils/convert-stack-with-sourcemap';
14+
import { createJsxRuntime } from '../utils/create-jsx-runtime';
815
import { getEmailComponent } from '../utils/get-email-component';
9-
import { improveErrorWithSourceMap } from '../utils/improve-error-with-sourcemap';
1016
import { registerSpinnerAutostopping } from '../utils/register-spinner-autostopping';
1117
import type { ErrorObject } from '../utils/types/error-object';
1218

1319
export interface RenderedEmailMetadata {
1420
markup: string;
21+
/**
22+
* HTML markup with `data-source-file` and `data-source-line` attributes pointing to the original
23+
* .jsx/.tsx files corresponding to the rendered tag
24+
*/
25+
markupWithReferences?: string;
1526
plainText: string;
1627
reactMarkup: string;
1728
}
@@ -44,7 +55,15 @@ export const renderEmailByPath = async (
4455
registerSpinnerAutostopping(spinner);
4556
}
4657

47-
const componentResult = await getEmailComponent(emailPath);
58+
const originalJsxRuntimePath = path.resolve(
59+
previewServerLocation,
60+
'jsx-runtime',
61+
);
62+
const jsxRuntimePath = await createJsxRuntime(
63+
userProjectLocation,
64+
originalJsxRuntimePath,
65+
);
66+
const componentResult = await getEmailComponent(emailPath, jsxRuntimePath);
4867

4968
if ('error' in componentResult) {
5069
spinner?.stopAndPersist({
@@ -58,21 +77,23 @@ export const renderEmailByPath = async (
5877
emailComponent: Email,
5978
createElement,
6079
render,
80+
renderWithReferences,
6181
sourceMapToOriginalFile,
6282
} = componentResult;
6383

6484
const previewProps = Email.PreviewProps || {};
6585
const EmailComponent = Email as React.FC;
6686
try {
67-
const markup = await render(createElement(EmailComponent, previewProps), {
87+
const element = createElement(EmailComponent, previewProps);
88+
const markupWithReferences = await renderWithReferences(element, {
6889
pretty: true,
6990
});
70-
const plainText = await render(
71-
createElement(EmailComponent, previewProps),
72-
{
73-
plainText: true,
74-
},
75-
);
91+
const markup = await render(element, {
92+
pretty: true,
93+
});
94+
const plainText = await render(element, {
95+
plainText: true,
96+
});
7697

7798
const reactMarkup = await fs.promises.readFile(emailPath, 'utf-8');
7899

@@ -90,11 +111,12 @@ export const renderEmailByPath = async (
90111
text: `Successfully rendered ${emailFilename} in ${timeForConsole}`,
91112
});
92113

93-
const renderingResult = {
114+
const renderingResult: RenderedEmailMetadata = {
94115
// This ensures that no null byte character ends up in the rendered
95116
// markup making users suspect of any issues. These null byte characters
96117
// only seem to happen with React 18, as it has no similar incident with React 19.
97118
markup: markup.replaceAll('\0', ''),
119+
markupWithReferences: markupWithReferences.replaceAll('\0', ''),
98120
plainText,
99121
reactMarkup,
100122
};
@@ -110,12 +132,97 @@ export const renderEmailByPath = async (
110132
text: `Failed while rendering ${emailFilename}`,
111133
});
112134

113-
return {
114-
error: improveErrorWithSourceMap(
115-
error,
135+
if (exception instanceof SyntaxError) {
136+
interface SpanPosition {
137+
file: {
138+
content: string;
139+
};
140+
offset: number;
141+
line: number;
142+
col: number;
143+
}
144+
// means the email's HTML was invalid and prettier threw this error
145+
// TODO: always throw when the HTML is invalid during `render`
146+
const cause = exception.cause as {
147+
msg: string;
148+
span: {
149+
start: SpanPosition;
150+
end: SpanPosition;
151+
};
152+
};
153+
154+
const sourceFileAttributeMatches = cause.span.start.file.content.matchAll(
155+
/data-source-file="(?<file>[^"]*)"/g,
156+
);
157+
let closestSourceFileAttribute: RegExpExecArray | undefined;
158+
for (const sourceFileAttributeMatch of sourceFileAttributeMatches) {
159+
if (closestSourceFileAttribute === undefined) {
160+
closestSourceFileAttribute = sourceFileAttributeMatch;
161+
}
162+
if (
163+
Math.abs(sourceFileAttributeMatch.index - cause.span.start.offset) <
164+
Math.abs(closestSourceFileAttribute.index - cause.span.start.offset)
165+
) {
166+
closestSourceFileAttribute = sourceFileAttributeMatch;
167+
}
168+
}
169+
170+
const findClosestAttributeValue = (
171+
attributeName: string,
172+
): string | undefined => {
173+
const attributeMatches = cause.span.start.file.content.matchAll(
174+
new RegExp(`${attributeName}="(?<value>[^"]*)"`, 'g'),
175+
);
176+
let closestAttribute: RegExpExecArray | undefined;
177+
for (const attributeMatch of attributeMatches) {
178+
if (closestAttribute === undefined) {
179+
closestAttribute = attributeMatch;
180+
}
181+
if (
182+
Math.abs(attributeMatch.index - cause.span.start.offset) <
183+
Math.abs(closestAttribute.index - cause.span.start.offset)
184+
) {
185+
closestAttribute = attributeMatch;
186+
}
187+
}
188+
return closestAttribute?.groups?.value;
189+
};
190+
191+
let stack = convertStackWithSourceMap(
192+
error.stack,
116193
emailPath,
117194
sourceMapToOriginalFile,
118-
),
195+
);
196+
197+
const sourceFile = findClosestAttributeValue('data-source-file');
198+
const sourceLine = findClosestAttributeValue('data-source-line');
199+
if (sourceFile && sourceLine) {
200+
stack = ` at ${sourceFile}:${sourceLine}\n${stack}`;
201+
}
202+
203+
return {
204+
error: {
205+
name: exception.name,
206+
message: cause.msg,
207+
stack,
208+
cause: error.cause ? JSON.parse(JSON.stringify(cause)) : undefined,
209+
},
210+
};
211+
}
212+
213+
return {
214+
error: {
215+
name: error.name,
216+
message: error.message,
217+
stack: convertStackWithSourceMap(
218+
error.stack,
219+
emailPath,
220+
sourceMapToOriginalFile,
221+
),
222+
cause: error.cause
223+
? JSON.parse(JSON.stringify(error.cause))
224+
: undefined,
225+
},
119226
};
120227
}
121228
};

packages/preview-server/src/app/env.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,9 @@ export const emailsDirRelativePath = process.env.EMAILS_DIR_RELATIVE_PATH!;
55
/** ONLY ACCESSIBLE ON THE SERVER */
66
export const userProjectLocation = process.env.USER_PROJECT_LOCATION!;
77

8+
/** ONLY ACCESSIBLE ON THE SERVER */
9+
export const previewServerLocation = process.env.PREVIEW_SERVER_LOCATION!;
10+
811
/** ONLY ACCESSIBLE ON THE SERVER */
912
export const emailsDirectoryAbsolutePath =
1013
process.env.EMAILS_DIR_ABSOLUTE_PATH!;
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
'use client';
2+
import type { ErrorObject } from '../../../utils/types/error-object';
3+
4+
interface ErrorOverlayProps {
5+
error: ErrorObject;
6+
}
7+
8+
const Message = ({ children: content }: { children: string }) => {
9+
const match = content.match(
10+
/(Unexpected closing tag "[^"]+". It may happen when the tag has already been closed by another tag). (For more info see) (.+)/,
11+
);
12+
if (match) {
13+
const [_, errorMessage, moreInfo, link] = match;
14+
return (
15+
<>
16+
{errorMessage}.
17+
<p className="text-lg">
18+
{moreInfo}{' '}
19+
<a className="underline" rel="noreferrer" target="_blank" href={link}>
20+
{link}
21+
</a>
22+
</p>
23+
</>
24+
);
25+
}
26+
return content;
27+
};
28+
29+
export const ErrorOverlay = ({ error }: ErrorOverlayProps) => {
30+
return (
31+
<>
32+
<div className="absolute inset-0 z-50 bg-black/80" />
33+
<div
34+
className="
35+
min-h-[50vh] w-full max-w-lg sm:rounded-lg md:max-w-[568px] lg:max-w-[920px]
36+
absolute left-[50%] top-[50%] z-50 translate-x-[-50%] translate-y-[-50%]
37+
rounded-t-sm overflow-hidden bg-white text-black shadow-lg duration-200
38+
flex flex-col selection:!text-black
39+
"
40+
>
41+
<div className="bg-red-500 h-3" />
42+
<div className="flex flex-grow p-6 min-w-0 max-w-full flex-col space-y-1.5">
43+
<div className="flex-shrink pb-2 text-xl tracking-tight">
44+
<b>{error.name}</b>: <Message>{error.message}</Message>
45+
</div>
46+
{error.stack ? (
47+
<div className="flex-grow scroll-px-4 overflow-x-auto rounded-lg bg-black p-2 text-gray-100">
48+
<pre className="w-full min-w-0 font-mono leading-6 selection:!text-cyan-12 text-xs">
49+
{error.stack}
50+
</pre>
51+
</div>
52+
) : undefined}
53+
</div>
54+
</div>
55+
</>
56+
);
57+
};

packages/preview-server/src/app/preview/[...slug]/preview.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ import { ViewSizeControls } from '../../../components/topbar/view-size-controls'
1919
import { PreviewContext } from '../../../contexts/preview';
2020
import { useClampedState } from '../../../hooks/use-clamped-state';
2121
import { cn } from '../../../utils';
22-
import { RenderingError } from './rendering-error';
22+
import { ErrorOverlay } from './error-overlay';
2323

2424
interface PreviewProps extends React.ComponentProps<'div'> {
2525
emailTitle: string;
@@ -136,7 +136,7 @@ const Preview = ({ emailTitle, className, ...props }: PreviewProps) => {
136136
};
137137
}}
138138
>
139-
{hasErrors ? <RenderingError error={renderingResult.error} /> : null}
139+
{hasErrors ? <ErrorOverlay error={renderingResult.error} /> : null}
140140

141141
{hasRenderingMetadata ? (
142142
<>

packages/preview-server/src/app/preview/[...slug]/rendering-error.tsx

Lines changed: 0 additions & 40 deletions
This file was deleted.

packages/preview-server/src/utils/caniemail/tailwind/get-tailwind-config.ts

Lines changed: 4 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import * as esbuild from 'esbuild';
55
import type { RawSourceMap } from 'source-map-js';
66
import type { Config as TailwindOriginalConfig } from 'tailwindcss';
77
import type { AST } from '../../../actions/email-validation/check-compatibility';
8-
import { improveErrorWithSourceMap } from '../../improve-error-with-sourcemap';
8+
import { convertStackWithSourceMap } from '../../convert-stack-with-sourcemap';
99
import { isErr } from '../../result';
1010
import { runBundledCode } from '../../run-bundled-code';
1111

@@ -101,15 +101,9 @@ export { reactEmailTailwindConfigInternal };`,
101101
sourceMap.sources = sourceMap.sources.map((source) =>
102102
path.resolve(sourceMapFile.path, '..', source),
103103
);
104-
const errorObject = improveErrorWithSourceMap(
105-
configModule.error as Error,
106-
filepath,
107-
sourceMap,
108-
);
109-
const error = new Error();
110-
error.name = errorObject.name;
111-
error.message = errorObject.message;
112-
error.stack = errorObject.stack;
104+
105+
const error = configModule.error as Error;
106+
error.stack = convertStackWithSourceMap(error.stack, filepath, sourceMap);
113107
throw error;
114108
}
115109

0 commit comments

Comments
 (0)