Skip to content

Commit 7337d04

Browse files
authored
fix(react-email): Unwanted email re-renders when switching templates (#1846)
1 parent 25dafd9 commit 7337d04

File tree

9 files changed

+149
-140
lines changed

9 files changed

+149
-140
lines changed

.changeset/purple-feet-share.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"react-email": patch
3+
---
4+
5+
Fix emails being re-rendered each time there is navigation in the preview server

packages/react-email/src/actions/get-email-path-from-slug.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
'use server';
22
import path from 'node:path';
33
import fs from 'node:fs';
4+
import { cache } from 'react';
45
import { emailsDirectoryAbsolutePath } from '../utils/emails-directory-absolute-path';
56

67
// eslint-disable-next-line @typescript-eslint/require-await
7-
export const getEmailPathFromSlug = async (slug: string) => {
8+
export const getEmailPathFromSlug = cache(async (slug: string) => {
89
if (['.tsx', '.jsx', '.ts', '.js'].includes(path.extname(slug)))
910
return path.join(emailsDirectoryAbsolutePath, slug);
1011

@@ -25,4 +26,4 @@ export const getEmailPathFromSlug = async (slug: string) => {
2526
2627
This is most likely not an issue with the preview server. It most likely is that the email doesn't exist.`,
2728
);
28-
};
29+
});

packages/react-email/src/actions/get-emails-directory-metadata.ts

Lines changed: 62 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
/* eslint-disable @typescript-eslint/no-non-null-assertion */
33
import fs from 'node:fs';
44
import path from 'node:path';
5+
import { cache } from 'react';
56

67
const isFileAnEmail = (fullPath: string): boolean => {
78
const stat = fs.statSync(fullPath);
@@ -57,64 +58,66 @@ const mergeDirectoriesWithSubDirectories = (
5758
return currentResultingMergedDirectory;
5859
};
5960

60-
export const getEmailsDirectoryMetadata = async (
61-
absolutePathToEmailsDirectory: string,
62-
keepFileExtensions = false,
63-
isSubDirectory = false,
64-
65-
baseDirectoryPath = absolutePathToEmailsDirectory,
66-
): Promise<EmailsDirectory | undefined> => {
67-
if (!fs.existsSync(absolutePathToEmailsDirectory)) return;
68-
69-
const dirents = await fs.promises.readdir(absolutePathToEmailsDirectory, {
70-
withFileTypes: true,
71-
});
72-
73-
const emailFilenames = dirents
74-
.filter((dirent) =>
75-
isFileAnEmail(path.join(absolutePathToEmailsDirectory, dirent.name)),
76-
)
77-
.map((dirent) =>
78-
keepFileExtensions
79-
? dirent.name
80-
: dirent.name.replace(path.extname(dirent.name), ''),
81-
);
61+
export const getEmailsDirectoryMetadata = cache(
62+
async (
63+
absolutePathToEmailsDirectory: string,
64+
keepFileExtensions = false,
65+
isSubDirectory = false,
66+
67+
baseDirectoryPath = absolutePathToEmailsDirectory,
68+
): Promise<EmailsDirectory | undefined> => {
69+
if (!fs.existsSync(absolutePathToEmailsDirectory)) return;
8270

83-
const subDirectories = await Promise.all(
84-
dirents
85-
.filter(
86-
(dirent) =>
87-
dirent.isDirectory() &&
88-
!dirent.name.startsWith('_') &&
89-
dirent.name !== 'static',
71+
const dirents = await fs.promises.readdir(absolutePathToEmailsDirectory, {
72+
withFileTypes: true,
73+
});
74+
75+
const emailFilenames = dirents
76+
.filter((dirent) =>
77+
isFileAnEmail(path.join(absolutePathToEmailsDirectory, dirent.name)),
9078
)
91-
.map((dirent) => {
92-
const direntAbsolutePath = path.join(
93-
absolutePathToEmailsDirectory,
94-
dirent.name,
95-
);
96-
97-
return getEmailsDirectoryMetadata(
98-
direntAbsolutePath,
99-
keepFileExtensions,
100-
true,
101-
baseDirectoryPath,
102-
) as Promise<EmailsDirectory>;
103-
}),
104-
);
105-
106-
const emailsMetadata = {
107-
absolutePath: absolutePathToEmailsDirectory,
108-
relativePath: path.relative(
109-
baseDirectoryPath,
110-
absolutePathToEmailsDirectory,
111-
),
112-
directoryName: absolutePathToEmailsDirectory.split(path.sep).pop()!,
113-
emailFilenames,
114-
subDirectories,
115-
} satisfies EmailsDirectory;
116-
117-
return isSubDirectory
118-
? mergeDirectoriesWithSubDirectories(emailsMetadata)
119-
: emailsMetadata;
120-
};
79+
.map((dirent) =>
80+
keepFileExtensions
81+
? dirent.name
82+
: dirent.name.replace(path.extname(dirent.name), ''),
83+
);
84+
85+
const subDirectories = await Promise.all(
86+
dirents
87+
.filter(
88+
(dirent) =>
89+
dirent.isDirectory() &&
90+
!dirent.name.startsWith('_') &&
91+
dirent.name !== 'static',
92+
)
93+
.map((dirent) => {
94+
const direntAbsolutePath = path.join(
95+
absolutePathToEmailsDirectory,
96+
dirent.name,
97+
);
98+
99+
return getEmailsDirectoryMetadata(
100+
direntAbsolutePath,
101+
keepFileExtensions,
102+
true,
103+
baseDirectoryPath,
104+
) as Promise<EmailsDirectory>;
105+
}),
106+
);
107+
108+
const emailsMetadata = {
109+
absolutePath: absolutePathToEmailsDirectory,
110+
relativePath: path.relative(
111+
baseDirectoryPath,
112+
absolutePathToEmailsDirectory,
113+
),
114+
directoryName: absolutePathToEmailsDirectory.split(path.sep).pop()!,
115+
emailFilenames,
116+
subDirectories,
117+
} satisfies EmailsDirectory;
118+
119+
return isSubDirectory
120+
? mergeDirectoriesWithSubDirectories(emailsMetadata)
121+
: emailsMetadata;
122+
},
123+
);

packages/react-email/src/actions/render-email-by-path.tsx

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -21,9 +21,16 @@ export type EmailRenderingResult =
2121
error: ErrorObject;
2222
};
2323

24+
const cache = new Map<string, EmailRenderingResult>();
25+
2426
export const renderEmailByPath = async (
2527
emailPath: string,
28+
invalidatingCache = false,
2629
): Promise<EmailRenderingResult> => {
30+
if (invalidatingCache) cache.delete(emailPath);
31+
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
32+
if (cache.has(emailPath)) return cache.get(emailPath)!;
33+
2734
const timeBeforeEmailRendered = performance.now();
2835

2936
const emailFilename = path.basename(emailPath);
@@ -36,22 +43,22 @@ export const renderEmailByPath = async (
3643
registerSpinnerAutostopping(spinner);
3744
}
3845

39-
const result = await getEmailComponent(emailPath);
46+
const componentResult = await getEmailComponent(emailPath);
4047

41-
if ('error' in result) {
48+
if ('error' in componentResult) {
4249
spinner?.stopAndPersist({
4350
symbol: logSymbols.error,
4451
text: `Failed while rendering ${emailFilename}`,
4552
});
46-
return { error: result.error };
53+
return { error: componentResult.error };
4754
}
4855

4956
const {
5057
emailComponent: Email,
5158
createElement,
5259
render,
5360
sourceMapToOriginalFile,
54-
} = result;
61+
} = componentResult;
5562

5663
const previewProps = Email.PreviewProps || {};
5764
const EmailComponent = Email as React.FC;
@@ -82,14 +89,18 @@ export const renderEmailByPath = async (
8289
text: `Successfully rendered ${emailFilename} in ${timeForConsole}`,
8390
});
8491

85-
return {
92+
const renderingResult = {
8693
// This ensures that no null byte character ends up in the rendered
8794
// markup making users suspect of any issues. These null byte characters
8895
// only seem to happen with React 18, as it has no similar incident with React 19.
8996
markup: markup.replaceAll('\0', ''),
9097
plainText,
9198
reactMarkup,
9299
};
100+
101+
cache.set(emailPath, renderingResult);
102+
103+
return renderingResult;
93104
} catch (exception) {
94105
const error = exception as Error;
95106

packages/react-email/src/app/preview/[...slug]/page.tsx

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -48,14 +48,14 @@ This is most likely not an issue with the preview server. Maybe there was a typo
4848
throw exception;
4949
}
5050

51-
const emailRenderingResult = await renderEmailByPath(emailPath);
51+
const serverEmailRenderingResult = await renderEmailByPath(emailPath);
5252

5353
if (
54-
'error' in emailRenderingResult &&
55-
process.env.NEXT_PUBLIC_IS_BUILDING === 'true'
54+
process.env.NEXT_PUBLIC_IS_BUILDING === 'true' &&
55+
'error' in serverEmailRenderingResult
5656
) {
57-
throw new Error(emailRenderingResult.error.message, {
58-
cause: emailRenderingResult.error,
57+
throw new Error(serverEmailRenderingResult.error.message, {
58+
cause: serverEmailRenderingResult.error,
5959
});
6060
}
6161

@@ -67,7 +67,7 @@ This is most likely not an issue with the preview server. Maybe there was a typo
6767
<Preview
6868
emailPath={emailPath}
6969
pathSeparator={path.sep}
70-
renderingResult={emailRenderingResult}
70+
serverRenderingResult={serverEmailRenderingResult}
7171
slug={slug}
7272
/>
7373
</Suspense>

packages/react-email/src/app/preview/[...slug]/preview.tsx

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8,40 +8,39 @@ import type { EmailRenderingResult } from '../../../actions/render-email-by-path
88
import { CodeContainer } from '../../../components/code-container';
99
import { Shell } from '../../../components/shell';
1010
import { Tooltip } from '../../../components/tooltip';
11-
import { useEmails } from '../../../contexts/emails';
1211
import { useRenderingMetadata } from '../../../hooks/use-rendering-metadata';
12+
import { useEmailRenderingResult } from '../../../hooks/use-email-rendering-result';
1313
import { RenderingError } from './rendering-error';
1414

1515
interface PreviewProps {
1616
slug: string;
1717
emailPath: string;
1818
pathSeparator: string;
19-
renderingResult: EmailRenderingResult;
19+
serverRenderingResult: EmailRenderingResult;
2020
}
2121

2222
const Preview = ({
2323
slug,
2424
emailPath,
2525
pathSeparator,
26-
renderingResult: initialRenderingResult,
26+
serverRenderingResult,
2727
}: PreviewProps) => {
2828
const router = useRouter();
2929
const pathname = usePathname();
3030
const searchParams = useSearchParams();
3131

3232
const activeView = searchParams.get('view') ?? 'desktop';
3333
const activeLang = searchParams.get('lang') ?? 'jsx';
34-
const { useEmailRenderingResult } = useEmails();
3534

3635
const renderingResult = useEmailRenderingResult(
3736
emailPath,
38-
initialRenderingResult,
37+
serverRenderingResult,
3938
);
4039

4140
const renderedEmailMetadata = useRenderingMetadata(
4241
emailPath,
4342
renderingResult,
44-
initialRenderingResult,
43+
serverRenderingResult,
4544
);
4645

4746
if (process.env.NEXT_PUBLIC_IS_BUILDING !== 'true') {
@@ -90,7 +89,6 @@ const Preview = ({
9089
<RenderingError error={renderingResult.error} />
9190
) : null}
9291

93-
{/* If this is undefined means that the initial server render of the email had errors */}
9492
{hasNoErrors ? (
9593
<>
9694
{activeView === 'desktop' && (
@@ -135,6 +133,7 @@ const Preview = ({
135133
)}
136134
</>
137135
) : null}
136+
138137
<Toaster />
139138
</div>
140139
</Shell>

0 commit comments

Comments
 (0)