From c13f8a8e1ace8cc3ad28e441146bdfc01cad88a9 Mon Sep 17 00:00:00 2001 From: Vitor Capretz Date: Fri, 30 Oct 2026 12:30:24 -0300 Subject: [PATCH] perf(editor): load the email code block renderer lazily The code block's email renderer read themes off `import * as` react-email by name, which keeps every react-email export alive, and imported CodeBlock eagerly, which carries all 371 Prism grammars. Both ended up in every bundle that mounts the editor. The renderer now lives in its own module, loaded with React.lazy when an email with a code block is rendered; render() already waits for it inside its Suspense boundary. --- .changeset/editor-lazy-code-block.md | 5 + .../src/extensions/code-block-email.tsx | 118 ++++++++++++++++++ .../editor/src/extensions/code-block.spec.tsx | 13 ++ packages/editor/src/extensions/code-block.tsx | 46 ++----- 4 files changed, 149 insertions(+), 33 deletions(-) create mode 100644 .changeset/editor-lazy-code-block.md create mode 100644 packages/editor/src/extensions/code-block-email.tsx diff --git a/.changeset/editor-lazy-code-block.md b/.changeset/editor-lazy-code-block.md new file mode 100644 index 0000000000..1fa8f89c6f --- /dev/null +++ b/.changeset/editor-lazy-code-block.md @@ -0,0 +1,5 @@ +--- +"@react-email/editor": patch +--- + +Load react-email's `CodeBlock`, with its Prism grammars and themes, only when an email with a code block is rendered, instead of in every bundle that mounts the editor. diff --git a/packages/editor/src/extensions/code-block-email.tsx b/packages/editor/src/extensions/code-block-email.tsx new file mode 100644 index 0000000000..59d903ac23 --- /dev/null +++ b/packages/editor/src/extensions/code-block-email.tsx @@ -0,0 +1,118 @@ +import { + a11yDark, + atomDark, + baseAteliersulphurpoolLight, + CodeBlock, + type CodeBlockProps, + cb, + coldarkCold, + coldarkDark, + coyWithoutShadows, + darcula, + dracula, + duotoneDark, + duotoneEarth, + duotoneForest, + duotoneLight, + duotoneSea, + duotoneSpace, + ghcolors, + gruvboxDark, + gruvboxLight, + holiTheme, + hopscotch, + laserwave, + lucario, + materialDark, + materialLight, + materialOceanic, + nightOwl, + nord, + oneDark, + oneLight, + pojoaque, + shadesOfPurple, + solarizedDarkAtom, + synthwave84, + vesper, + vs, + vscDarkPlus, + xonokai, + zTouch, +} from 'react-email'; + +export const codeBlockThemes: Record = { + a11yDark, + atomDark, + baseAteliersulphurpoolLight, + cb, + coldarkCold, + coldarkDark, + coyWithoutShadows, + darcula, + dracula, + duotoneDark, + duotoneEarth, + duotoneForest, + duotoneLight, + duotoneSea, + duotoneSpace, + ghcolors, + gruvboxDark, + gruvboxLight, + holiTheme, + hopscotch, + laserwave, + lucario, + materialDark, + materialLight, + materialOceanic, + nightOwl, + nord, + oneDark, + oneLight, + pojoaque, + shadesOfPurple, + solarizedDarkAtom, + synthwave84, + vesper, + vs, + vscDarkPlus, + xonokai, + zTouch, +}; + +export interface CodeBlockEmailProps extends Omit { + themeName?: string; +} + +export function CodeBlockEmail({ themeName, ...props }: CodeBlockEmailProps) { + const userTheme = + themeName && Object.hasOwn(codeBlockThemes, themeName) + ? codeBlockThemes[themeName] + : undefined; + + // Without theme, render a gray code block + const theme = userTheme + ? { + ...userTheme, + base: { + ...userTheme.base, + borderRadius: '0.125rem', + padding: '0.75rem 1rem', + }, + } + : { + base: { + color: '#1e293b', + background: '#f1f5f9', + lineHeight: '1.5', + fontFamily: + '"Fira Code", "Fira Mono", Menlo, Consolas, "DejaVu Sans Mono", monospace', + padding: '0.75rem 1rem', + borderRadius: '0.125rem', + }, + }; + + return ; +} diff --git a/packages/editor/src/extensions/code-block.spec.tsx b/packages/editor/src/extensions/code-block.spec.tsx index d0d9c6ab67..36aee93a38 100644 --- a/packages/editor/src/extensions/code-block.spec.tsx +++ b/packages/editor/src/extensions/code-block.spec.tsx @@ -1,9 +1,11 @@ import { generateJSON } from '@tiptap/html'; import StarterKit from '@tiptap/starter-kit'; +import * as ReactEmail from 'react-email'; import { dracula, render } from 'react-email'; import { describe, expect, it } from 'vitest'; import { DEFAULT_STYLES } from '../utils/default-styles'; import { CodeBlockPrism } from './code-block'; +import { codeBlockThemes } from './code-block-email'; import { StyleAttribute } from './style-attribute'; // Resolved style matching snapshot: codeBlock only, no reset (theme provides padding/margin) @@ -119,4 +121,15 @@ describe('CodeBlockPrism Node', () => { expect(recomposed).toBe(composed); }); + + it('offers every code block theme react-email exports', () => { + const exportedThemes = Object.entries(ReactEmail) + .filter( + ([, value]) => + typeof value === 'object' && value !== null && 'base' in value, + ) + .map(([name]) => name); + + expect(Object.keys(codeBlockThemes).sort()).toEqual(exportedThemes.sort()); + }); }); diff --git a/packages/editor/src/extensions/code-block.tsx b/packages/editor/src/extensions/code-block.tsx index d09c404435..e1a9fb444a 100644 --- a/packages/editor/src/extensions/code-block.tsx +++ b/packages/editor/src/extensions/code-block.tsx @@ -3,11 +3,8 @@ import type { CodeBlockOptions } from '@tiptap/extension-code-block'; import CodeBlock from '@tiptap/extension-code-block'; import { Fragment, type Schema } from '@tiptap/pm/model'; import { TextSelection } from '@tiptap/pm/state'; -import * as ReactEmailComponents from 'react-email'; -import { - type PrismLanguage, - CodeBlock as ReactEmailCodeBlock, -} from 'react-email'; +import { lazy } from 'react'; +import type { PrismLanguage } from 'react-email'; import { EmailNode } from '../core/serializer/email-node'; import { inlineCssToJs } from '../utils/styles'; import { PrismPlugin } from './prism-plugin'; @@ -21,6 +18,15 @@ export interface CodeBlockPrismOptions extends CodeBlockOptions { // space so Spark Mail keeps the indentation. const ENCODED_SPACE_REGEX = /\u00A0\u200D\u200B/g; +// react-email's CodeBlock carries every Prism grammar and theme, so it is only +// loaded once an email with a code block is rendered, not wherever the editor +// is mounted. `render()` waits for it inside its Suspense boundary. +const CodeBlockEmail = lazy(() => + import('./code-block-email.js').then((module) => ({ + default: module.CodeBlockEmail, + })), +); + export const CodeBlockPrism = EmailNode.from( CodeBlock.extend({ addOptions(): CodeBlockPrismOptions { @@ -190,41 +196,15 @@ export const CodeBlockPrism = EmailNode.from( ? `${node.attrs.language}` : 'javascript'; - // @ts-expect-error -- react-email does not export theme objects by name; dynamic access needed for user-selected themes - // biome-ignore lint/performance/noDynamicNamespaceImportAccess: dynamic access needed for user-selected themes - const userTheme = ReactEmailComponents[node.attrs?.theme]; - - // Without theme, render a gray code block - const theme = userTheme - ? { - ...userTheme, - base: { - ...userTheme.base, - borderRadius: '0.125rem', - padding: '0.75rem 1rem', - }, - } - : { - base: { - color: '#1e293b', - background: '#f1f5f9', - lineHeight: '1.5', - fontFamily: - '"Fira Code", "Fira Mono", Menlo, Consolas, "DejaVu Sans Mono", monospace', - padding: '0.75rem 1rem', - borderRadius: '0.125rem', - }, - }; - return ( -