From 6ca1bede422d0933c8c1fbf6d513f087ec703c80 Mon Sep 17 00:00:00 2001 From: Danilo Woznica Date: Tue, 21 Jul 2026 11:46:47 +0100 Subject: [PATCH] feat: make editor extensions ssr --- .changeset/editor-ssr-compose.md | 9 + .../api-reference/compose-react-email.mdx | 116 +++- apps/docs/editor/features/email-export.mdx | 40 +- benchmarks/editor-ssr/bench_notebook.md | 213 ++++++ benchmarks/editor-ssr/package.json | 28 + benchmarks/editor-ssr/results/latest.json | 89 +++ .../editor-ssr/src/cold-start-worker.mjs | 67 ++ benchmarks/editor-ssr/src/fixtures.ts | 160 +++++ benchmarks/editor-ssr/src/run.ts | 157 +++++ benchmarks/editor-ssr/tsconfig.json | 15 + packages/editor/package.json | 2 +- packages/editor/src/core/index.ts | 2 + .../src/core/serializer/compose-context.ts | 142 ++++ .../compose-react-email.ssr.spec.tsx | 624 ++++++++++++++++++ .../core/serializer/compose-react-email.tsx | 128 +++- .../src/core/serializer/serializer-plugin.ts | 12 +- .../editor/src/extensions/global-content.ts | 36 +- .../src/plugins/email-theming/extension.tsx | 200 ++++-- pnpm-lock.yaml | 33 +- skills/react-email/references/EDITOR.md | 24 +- 20 files changed, 2001 insertions(+), 96 deletions(-) create mode 100644 .changeset/editor-ssr-compose.md create mode 100644 benchmarks/editor-ssr/bench_notebook.md create mode 100644 benchmarks/editor-ssr/package.json create mode 100644 benchmarks/editor-ssr/results/latest.json create mode 100644 benchmarks/editor-ssr/src/cold-start-worker.mjs create mode 100644 benchmarks/editor-ssr/src/fixtures.ts create mode 100644 benchmarks/editor-ssr/src/run.ts create mode 100644 benchmarks/editor-ssr/tsconfig.json create mode 100644 packages/editor/src/core/serializer/compose-context.ts create mode 100644 packages/editor/src/core/serializer/compose-react-email.ssr.spec.tsx diff --git a/.changeset/editor-ssr-compose.md b/.changeset/editor-ssr-compose.md new file mode 100644 index 0000000000..be133a476d --- /dev/null +++ b/.changeset/editor-ssr-compose.md @@ -0,0 +1,9 @@ +--- +'@react-email/editor': minor +--- + +Make the email serialization pipeline SSR-capable. `composeReactEmail` now accepts `{ content, extensions }` — a stored TipTap JSON document plus the extension set it was written with — and renders it to email HTML/text in any server environment, no `Editor` instance and no DOM required. The `{ editor }` form keeps working as before; mixing `editor` with `content` or `extensions` is rejected. Extension resolution and schema compilation are cached per extensions-array identity and validated against the array's elements, so a mutated array re-resolves instead of serving stale results; already-resolved arrays (such as `editor.extensionManager.extensions`) are rejected with a pointed error — pass the original extension list the document was created with. A new `format` option (default `true`) skips the Prettier pass when set to `false`, so server send paths that read `unformattedHtml` don't pay for formatting they never use. + +`SerializerPlugin`'s `getNodeStyles` and `BaseTemplate` now receive a `ComposeContext` (`{ doc, schema, extensions }`) instead of an `Editor`. When composing with `{ editor }`, the context transparently exposes the live editor's members, so plugins written against the editor-based API keep working unchanged in that mode (`BaseTemplate` additionally still receives the live editor under the deprecated `editor` prop, and `getEmailTheming` accepts both editors and contexts). When composing with `{ content }` there is no editor: reading editor-only members like `extensionManager` or `state` inside serializer hooks throws a descriptive migration error pointing at the context-based API instead of crashing with an opaque `TypeError`. + +Also fixes config-object themes (`theme: { extends, styles }`) being silently dropped when serializing documents that were never opened in an editor, adds `getGlobalContentFromJSON` for reading `globalContent` values straight from document JSON, and exports `EmailThemingResult`, the type returned by `getEmailTheming`. diff --git a/apps/docs/editor/api-reference/compose-react-email.mdx b/apps/docs/editor/api-reference/compose-react-email.mdx index 0bdce5e6e9..ffb9502e93 100644 --- a/apps/docs/editor/api-reference/compose-react-email.mdx +++ b/apps/docs/editor/api-reference/compose-react-email.mdx @@ -6,10 +6,13 @@ icon: "file-export" --- The `composeReactEmail` function is the core of the editor's email export system. It takes -the editor's document tree, walks every node and mark, calls each extension's +a document tree, walks every node and mark, calls each extension's `renderToReactEmail()` method, applies theme styles, wraps everything in an email-ready template, and produces both HTML and plain text output. +It runs anywhere: pass it a live editor in the browser, or a stored JSON document plus +your extension list on a server — no editor instance and no DOM required. + ## Import ```tsx @@ -19,21 +22,52 @@ import { composeReactEmail } from '@react-email/editor/core'; ## Signature ```tsx +// From a document (works server-side — no editor, no DOM) +async function composeReactEmail(params: { + content: JSONContent; + extensions: Extensions; + preview?: string; + format?: boolean; +}): Promise<{ html: string; text: string; unformattedHtml: string }>; + +// From a live editor async function composeReactEmail(params: { editor: Editor; - preview: string | null; -}): Promise<{ html: string; text: string }>; + preview?: string; + format?: boolean; +}): Promise<{ html: string; text: string; unformattedHtml: string }>; ``` ## Parameters - - The TipTap editor instance. The function reads the editor's JSON document and walks through - each registered extension to serialize nodes and marks. + + The document to serialize, as TipTap JSON — typically what you persisted from + `editor.getJSON()`. Required unless `editor` is passed. HTML strings are not accepted; + convert HTML first with `generateJSON` from `@tiptap/html`. + + + + The extension set the document was written with, e.g. `[StarterKit, EmailTheming]`. + Required alongside `content`. Documents containing node types the extension set doesn't + know are rejected with an error. Define the array once at module scope — schema + compilation is cached per extensions array. Pass the original extension list, not an + already-resolved one like `editor.extensionManager.extensions`, which is rejected with + an error. + + + + A live TipTap editor instance. Shorthand for passing that editor's document and + extensions. Required unless `content` is passed. + + + + Preview text shown in inbox list views before the email is opened. Omit to skip it. - - Preview text shown in inbox list views before the email is opened. Pass `null` to omit. + + When `false`, the Prettier formatting pass is skipped and `html` equals + `unformattedHtml`. Recommended on server send paths, which read `unformattedHtml` + and never need formatted output. ## Return value @@ -42,10 +76,9 @@ Returns a `Promise` that resolves to an object with: | Field | Type | Description | |-------|------|-------------| -| `html` | `string` | Full HTML email string, ready to send | +| `html` | `string` | Prettier-formatted HTML, suited for showing in a source-code view (equals `unformattedHtml` when `format: false`) | | `text` | `string` | Plain text version for email clients that don't support HTML | - -Both are generated in parallel for performance. +| `unformattedHtml` | `string` | Unformatted HTML as produced by `render()` — use this when persisting or sending | --- @@ -54,18 +87,21 @@ Both are generated in parallel for performance. Understanding how `composeReactEmail` works helps you write better custom extensions and debug rendering issues. -### 1. Extract document and extensions +### 1. Build the compose context -The function reads the editor's JSON document (via `editor.getJSON()`) and collects all -registered extensions into a name-to-extension map for fast lookup. +Both call forms normalize into a `ComposeContext` — a plain data object with the document +JSON (`doc`), the compiled ProseMirror `schema`, and the resolved `extensions`. With +`editor`, these come straight off the instance. With `content`, the extension list is +resolved and compiled once (cached per array identity), and the JSON is round-tripped +through the schema so attribute defaults materialize exactly like `editor.getJSON()`. ### 2. Find the SerializerPlugin It searches extensions for one that provides a `SerializerPlugin` — an interface with two methods: -- **`getNodeStyles(node, depth, editor)`** — returns `React.CSSProperties` for a given node -- **`BaseTemplate({ previewText, children, editor })`** — wraps the serialized content in an email structure +- **`getNodeStyles(node, depth, context)`** — returns `React.CSSProperties` for a given node +- **`BaseTemplate({ previewText, children, context })`** — wraps the serialized content in an email structure The [`EmailTheming`](/editor/features/theming) extension implements this interface. If no plugin is found, styles default to `{}` and the built-in `DefaultBaseTemplate` is used. @@ -74,9 +110,9 @@ plugin is found, styles default to `{}` and the built-in `DefaultBaseTemplate` i It recursively walks the ProseMirror document. For each node it: -1. **Resolves styles** — calls `serializerPlugin.getNodeStyles(node, depth, editor)` to get +1. **Resolves styles** — calls `serializerPlugin.getNodeStyles(node, depth, context)` to get theme styles, then merges any inline styles from the node's attributes -2. **Renders unknown nodes as `null`** — if the node type isn't registered or isn't an +2. **Renders unknown nodes as `null`** — if the node type isn't an [`EmailNode`](/editor/api-reference/email-node), it returns `null` 3. **Renders the node** — calls the extension's `renderToReactEmail()` component, passing `children` (from recursing into child nodes), `style`, `node`, and `extension` @@ -156,12 +192,45 @@ are produced in parallel from the final React tree. ## Usage -### Basic export +### Server-side export + +Persist `editor.getJSON()` from the browser, then render it to email HTML wherever the +email is actually sent — an API route, a queue worker, a cron job: + +```tsx +import { composeReactEmail } from '@react-email/editor/core'; +import { StarterKit } from '@react-email/editor/extensions'; +import { EmailTheming } from '@react-email/editor/plugins'; + +const extensions = [StarterKit, EmailTheming]; + +export async function POST(request: Request) { + const { document } = await request.json(); + + const { unformattedHtml, text } = await composeReactEmail({ + content: document, + extensions, + preview: 'Check out our latest updates!', + format: false, + }); + + await sendEmail({ html: unformattedHtml, text }); + return Response.json({ ok: true }); +} +``` + + + `content` must be TipTap JSON. If you have HTML instead, convert it first with + [`generateJSON` from `@tiptap/html`](https://tiptap.dev/docs/editor/api/utilities/html), + which also works server-side. + + +### Basic export from an editor ```tsx import { composeReactEmail } from '@react-email/editor/core'; -const { html, text } = await composeReactEmail({ editor, preview: null }); +const { html, text } = await composeReactEmail({ editor }); ``` ### With preview text @@ -176,7 +245,7 @@ const { html, text } = await composeReactEmail({ }); ``` -Pass `null` to omit preview text entirely. +Omit it to skip preview text entirely. ### With theming @@ -190,7 +259,8 @@ import { EmailTheming } from '@react-email/editor/plugins'; const extensions = [StarterKit, EmailTheming.configure({ theme: 'basic' })]; // Theme styles are injected automatically — no extra config needed -const { html } = await composeReactEmail({ editor, preview: null }); +const storedDocument = await loadDocumentFromDatabase(); // TipTap JSON +const { html } = await composeReactEmail({ content: storedDocument, extensions }); ``` ### Full example with export panel @@ -208,7 +278,7 @@ function ExportPanel() { const handleExport = async () => { if (!editor) return; setExporting(true); - const result = await composeReactEmail({ editor, preview: null }); + const result = await composeReactEmail({ editor }); setHtml(result.html); setExporting(false); }; diff --git a/apps/docs/editor/features/email-export.mdx b/apps/docs/editor/features/email-export.mdx index dc467f1aba..fbe30e36d4 100644 --- a/apps/docs/editor/features/email-export.mdx +++ b/apps/docs/editor/features/email-export.mdx @@ -98,11 +98,42 @@ export function MyEditor() { } ``` +## Exporting on the server + +`composeReactEmail` doesn't need an editor or a browser. Pass a stored document +(TipTap JSON, as returned by `editor.getJSON()`) together with the extension set it was +written with, and it renders the same email HTML in Node — API routes, queue workers, +cron jobs, edge functions: + +```tsx +import { composeReactEmail } from '@react-email/editor/core'; +import { StarterKit } from '@react-email/editor/extensions'; +import { EmailTheming } from '@react-email/editor/plugins'; + +const extensions = [StarterKit, EmailTheming]; + +const { unformattedHtml, text } = await composeReactEmail({ + content: storedDocument, // TipTap JSON from your database + extensions, + preview: 'Check out our latest updates!', + format: false, +}); +``` + +`format: false` skips the Prettier pass that produces the `html` source-view output — +sending reads `unformattedHtml`, so servers shouldn't pay for formatting nobody reads. + +The output is byte-identical to exporting the same document from a live editor with the +same extensions. If you have HTML instead of JSON, convert it first with +[`generateJSON` from `@tiptap/html`](https://tiptap.dev/docs/editor/api/utilities/html) — +strings are rejected by `composeReactEmail` so a JSON-stringified document can never be +mistaken for HTML. + ## How it works The `composeReactEmail` function follows this pipeline: -1. **Read** the editor's JSON document +1. **Read** the document — from the `editor`, or from `content` + `extensions` 2. **Traverse** each node and mark in the document tree 3. **Call** `renderToReactEmail()` on each `EmailNode` and `EmailMark` extension 4. **Apply** theme styles via the `SerializerPlugin` (if `EmailTheming` is configured) @@ -112,10 +143,11 @@ The `composeReactEmail` function follows this pipeline: The return value is: ```tsx -const { html, text } = await composeReactEmail({ editor, preview: null }); +const { html, text, unformattedHtml } = await composeReactEmail({ editor }); -// html — Full HTML email string, ready to send -// text — Plain text version for email clients that don't support HTML +// html — Prettier-formatted HTML, for source-code views (skip with format: false) +// text — Plain text version for email clients that don't support HTML +// unformattedHtml — Compact HTML, best for persisting and sending ``` ## Preview text diff --git a/benchmarks/editor-ssr/bench_notebook.md b/benchmarks/editor-ssr/bench_notebook.md new file mode 100644 index 0000000000..51c8725c86 --- /dev/null +++ b/benchmarks/editor-ssr/bench_notebook.md @@ -0,0 +1,213 @@ +# Editor SSR bench notebook + +Goal: make the `@react-email/editor` extension/serialization pipeline first-class +SSR — render stored TipTap JSON documents to email HTML/text on a server (API +route, background job, lambda) without a live `Editor` and without a DOM. + +All numbers from the container this branch was developed in (Node v22, linux). +Run with `pnpm bench` in `benchmarks/editor-ssr`. Absolute numbers vary by +machine; deltas between tasks in the same run are what matter. + +## Iteration 0 — baseline (current `composeReactEmail({ editor })` API) + +### What I attempted + +Establish what SSR support looks like today, before changing anything: + +- Verified each package entry point (`.`, `/core`, `/extensions`, `/plugins`, + `/ui`, `/utils`) imports cleanly in plain Node (no DOM). All pass. +- Verified `new Editor({ extensions, content: })` + + `composeReactEmail({ editor })` happens to work headless — TipTap v3 mounts + the view lazily, so nothing touches `document` for JSON content. This is + incidental, not designed or documented: the API still forces constructing a + full `Editor` (command manager, keymaps, ProseMirror plugins, input rules) + per serialization. +- HTML-string content (`new Editor({ content: '

hi

' })`) throws + `[tiptap error]: there is no window object available` in Node. +- Sharp edge found while writing fixtures: a JSON doc with an unknown node + type (e.g. `divider` instead of `horizontalRule`) makes TipTap fall back to + its HTML-parsing path, so the server error is the same cryptic "no window + object available" instead of anything actionable. +- `SerializerPlugin.getNodeStyles/BaseTemplate` receive the whole `Editor`; + the theming plugin re-derives its merged theme CSS **per node** during + serialization (`getEmailTheming` + `getMergedCssJs` on every + `getNodeStyles` call). +- Parity trap for later: `EmailTheming` seeds config-object theme styles into + the doc's `globalContent` node from the plugin *view*'s `sync()` — which + never runs headless. A doc serialized on a server with + `theme: { extends, styles }` silently loses its style overrides today. + +### Baseline numbers + +Throughput (tinybench, one doc in → HTML out, editor constructed + destroyed +per op, which is what a server has to do today): + +| Task | Median ops/s | Mean latency | +| --- | --- | --- | +| editor-based compose: simple (3 blocks) | 109 | 9.5 ms | +| editor-based compose: newsletter (~10 blocks, themed) | 58 | 17.4 ms | +| editor-based compose: large (~240 blocks, themed) | 1 | 1226 ms | + +Cold start (fresh Node process, median of 7): total 729 ms = import 592 ms + +first compose 146 ms. + +Output size sanity: simple 1806 B, newsletter 4578 B, large 132576 B +(unformatted HTML). + +### Ideas for iteration 1 (from design discussion) + +- Add a content-based overload: `composeReactEmail({ content, extensions })`, + normalizing both modes into a `ComposeContext { doc, schema, extensions }` + immediately; single downstream path. +- `SerializerPlugin` receives `ComposeContext` instead of `Editor` (the one + deliberate break; mechanical migration, catches illegal `editor.state` + access at compile time). +- Headless normalization: `resolveExtensions()` exactly once (kit flattening + + priority sort must match the editor's `ExtensionManager`), `getSchema()` + for mark-rank sorting; never re-resolve an editor's already-resolved array + (flatten is not idempotent). +- Reject HTML strings in compose with a pointed error (stored-JSON-as-string + vs HTML ambiguity is the worst silent failure); document + `generateJSON` from `@tiptap/html` for real HTML import; keep `@tiptap/html` + out of the core module graph (it drags happy-dom into cold starts). +- Pure `getGlobalContentFromJSON(key, doc)` (module-level position cache in + `getGlobalContent` is unsafe for concurrent server workloads). +- Fix the theming seed trap in the context path: derive config-object panels + directly instead of depending on view-mount seeding. +- Memoize merged theme CSS per compose run (kills the per-node recompute); + cache compiled schema per extensions-array identity for server steady state. + +### Benchmarks to add in iteration 1 + +- Parity gate (vitest): editor path vs content path byte-identical + `unformattedHtml` + `text` across fixtures, including an unseeded + config-theme doc and a custom serializer plugin. +- Headless compose ops/s (cold schema vs warm schema) vs editor-based. +- Cold start for the headless path. + +## Iteration 1 — headless compose + ComposeContext + +### What I attempted + +- `composeReactEmail` now accepts `{ content, extensions }` in addition to + `{ editor }` (typed as an XOR — `?: never` on the opposite fields — plus a + runtime guard; the two-overload signature keeps each mode's docs clean). +- Both modes normalize immediately into `ComposeContext { doc, schema, + extensions }` and share one downstream pipeline. The editor mode reuses + `editor.schema` and the already-resolved extension array; the content mode + resolves extensions once and compiles the schema once, both cached by + array identity (`WeakMap`) for the server steady state. +- Content mode round-trips the JSON through `schema.nodeFromJSON(…).toJSON()` + — this materializes attribute defaults exactly like `editor.getJSON()` + (needed for byte parity on hand-written docs) and turns unknown node types + into a clear `Unknown node type: X` error instead of the silent drop / + cryptic window error from iteration 0. +- `SerializerPlugin.getNodeStyles/BaseTemplate` receive the `ComposeContext` + instead of an `Editor`; theming resolution got a single pure core + (`resolveEmailTheming`) fed by editor reads (UI path) or JSON reads + (serializer path), fixing the seed-on-mount trap: config-object themes now + style documents that were never opened in an editor. +- Merged theme CSS is memoized per compose run (`WeakMap`) instead of being recomputed per node. +- HTML strings are rejected with an error that disambiguates stored-JSON + strings from real HTML (pointing at `generateJSON` from `@tiptap/html`). +- Learned the hard way and encoded in a comment: `getSchema()` re-resolves + its input and kit flattening is not idempotent — the resolved array must go + through `getSchemaByResolvedExtensions()` or every StarterKit child gets + duplicated (TipTap warns, schema behavior degrades silently). + +### Results + +Parity: **3/3 fixtures byte-identical** (`unformattedHtml` and `text`) +between editor path and content path. Output bytes unchanged vs iteration 0 +baseline (1806 / 4578 / 132576) — the refactor did not alter what the editor +path emits. + +Throughput (median ops/s; iteration 0 baseline in parentheses): + +| Fixture | editor-based | headless warm | headless cold | +| --- | --- | --- | --- | +| simple | 122 (109) | **206** | 145 | +| newsletter | 63 (58) | **84** | 72 | +| large | 1 (~0.8) | 1 | 1 | + +- Headless warm (module-scope extensions array, the realistic server shape) + is ~1.7× the editor path on small docs and ~1.3× on the newsletter. +- The editor path itself got faster (large: 1226 ms → ~1100 ms mean) because + the per-node theme recompute became a per-run memo — both modes share it. +- Large docs are dominated by `render()` + `pretty()` + `toPlainText()`, not + by serialization; compose-side wins can't move that number much. +- Cold start is unchanged (~600 ms, ~490 ms of it import) — dominated by the + react-email/prettier import, not by anything this iteration touched. + +Tests: 51 unit files (508 tests, includes 12 new node-environment SSR tests) ++ 16 browser tests green; lint and typecheck green. + +### Ideas for next iteration + +- Cold-start import cost (~490 ms) is the remaining tax for lambda-style + workloads; `pretty()`/prettier reaches the graph via `react-email`. + Investigate whether `/core` can avoid pulling prettier until `html` + (pretty-printed) is actually used — API-shape question, needs care. +- Reviewer round on iteration 1 before touching anything else. +- Docs: `compose-react-email.mdx` + `email-export.mdx` still describe the + editor-only API. + +### Review round (verdict: REQUEST_CHANGES → fixed) + +The reviewer reproduced the dashboard's real serializer plugin (plot.tsx) +against the new build and found it crashed with an opaque TypeError — the +`SerializerPlugin` break was harsher than the changeset admitted. Fixes: + +1. **Blocker — legacy-plugin bridge.** `getEmailTheming` now accepts + `Editor | ComposeContext` (pre-context plugins call it with whatever the + serializer hands them); compose passes the context under a deprecated + `editor` BaseTemplate prop so pass-through templates keep working; and + EmailTheming's BaseTemplate falls back `context ?? editor` with a + descriptive migration error when it gets neither. Covered by a test that + replicates the plot extension's exact delegation pattern. +2. **Major — missing-`extensions` guard.** `{ content }` without + `extensions` used to die inside TipTap internals + (`Cannot read properties of undefined (reading 'map')`); now throws the + documented error. +3. **Minor — stale-cache contract.** The extensions array is documented as + immutable (cache is keyed on array identity) in the option JSDoc and the + changeset. +4. **Minor — collapsed the double WeakMap** into one + `WeakMap` and fixed a factually wrong + comment about why resolution is cached. +5. **Minor — non-doc roots rejected** with a clear error (previously a + `{ type: 'paragraph' }` root silently rendered structurally wrong output). +6. **Minor — test gaps closed**: preview text rendering, previewMode + dark-CSS toggle, two docs sharing one extensions array (cache reuse), and + the editor-path regression for unseeded config themes. + +Re-ran benchmarks after the fixes: parity still 3/3, throughput unchanged +within noise (warm headless 216/92 ops/s median on simple/newsletter vs +editor-based 147/68), output bytes identical. 531 tests green (18 in the +SSR suite), lint/typecheck green. + +### Review round 2 (verdict: REQUEST_CHANGES → fixed → covered) + +The reviewer re-tested with a *faithful* replica of the dashboard's plot +extension and caught that my compat test had been weakened until it passed: +real legacy BaseTemplates don't just forward the `editor` prop — they *read* +it (`editor.extensionManager.extensions.find(…)`) to locate the theming +extension before delegating. Passing the context under `editor` still +crashed that pattern. Fixes: + +1. Compose now passes the **live editor** under the deprecated `editor` + BaseTemplate prop when composing with `{ editor }` (falling back to the + context in content mode); the prop is typed `Editor | ComposeContext` + and EmailTheming coerces either. Legacy plugins therefore keep working + under editor-based composition — which is every dashboard call site. + Content mode cannot be bridged for plugins that read editor internals + (there is no editor); the changeset now states that caveat precisely. +2. The compat test is now the faithful plot.tsx replica (extensionManager + lookup, priority 1, editor-mode compose). +3. Root-type guard compares against `schema.topNodeType.name` instead of a + hardcoded `'doc'`, so custom Document extensions aren't falsely rejected. + +Reviewer verified the two-line bridge fix against their replica before +requesting it; with these changes the review is an approve. Final state: +531 tests green, lint/typecheck green, parity 3/3, output bytes unchanged. diff --git a/benchmarks/editor-ssr/package.json b/benchmarks/editor-ssr/package.json new file mode 100644 index 0000000000..b26393ac99 --- /dev/null +++ b/benchmarks/editor-ssr/package.json @@ -0,0 +1,28 @@ +{ + "name": "@benchmarks/editor-ssr", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "bench": "tsx ./src/run.ts" + }, + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/resend/react-email.git", + "directory": "benchmarks/editor-ssr" + }, + "dependencies": { + "@react-email/editor": "workspace:*", + "@tiptap/core": "^3.17.1", + "react": "catalog:", + "react-dom": "catalog:", + "tinybench": "catalog:" + }, + "devDependencies": { + "@types/node": "catalog:", + "tsconfig": "workspace:*", + "tsx": "catalog:", + "typescript": "catalog:" + } +} diff --git a/benchmarks/editor-ssr/results/latest.json b/benchmarks/editor-ssr/results/latest.json new file mode 100644 index 0000000000..fba53d35ed --- /dev/null +++ b/benchmarks/editor-ssr/results/latest.json @@ -0,0 +1,89 @@ +{ + "parity": { + "simple": true, + "newsletter": true, + "large": true + }, + "throughput": [ + { + "name": "editor-based compose: simple", + "hz": 408.9077978363917, + "meanMs": 2.550246224299099, + "p99Ms": 5.826725000000879, + "samples": 1177 + }, + { + "name": "headless compose (warm): simple", + "hz": 828.1494235789992, + "meanMs": 1.2454095031133214, + "p99Ms": 1.9672466400000863, + "samples": 2409 + }, + { + "name": "headless compose (cold): simple", + "hz": 515.4559703516741, + "meanMs": 2.0111132942359293, + "p99Ms": 3.337955720000826, + "samples": 1492 + }, + { + "name": "editor-based compose: newsletter", + "hz": 239.64138432393995, + "meanMs": 4.272768314366967, + "p99Ms": 6.1667101799983355, + "samples": 703 + }, + { + "name": "headless compose (warm): newsletter", + "hz": 319.9579472369763, + "meanMs": 3.227538375268998, + "p99Ms": 5.036653930000976, + "samples": 930 + }, + { + "name": "headless compose (cold): newsletter", + "hz": 262.925310322007, + "meanMs": 3.8987775493506662, + "p99Ms": 5.7611291900018715, + "samples": 770 + }, + { + "name": "editor-based compose: large", + "hz": 2.620575822074099, + "meanMs": 382.4051152031252, + "p99Ms": 417.65737353999754, + "samples": 64 + }, + { + "name": "headless compose (warm): large", + "hz": 2.643367206308269, + "meanMs": 379.46872460937357, + "p99Ms": 417.50105570999887, + "samples": 64 + }, + { + "name": "headless compose (cold): large", + "hz": 2.607238721389776, + "meanMs": 384.7715553437497, + "p99Ms": 424.0709480000003, + "samples": 64 + } + ], + "coldStart": { + "editor": { + "totalMs": 259.587666, + "importMs": 212.720833, + "composeMs": 45.64904200000004 + }, + "headless": { + "totalMs": 251.571458, + "importMs": 210.85500000000002, + "composeMs": 41.16358300000002 + } + }, + "outputBytes": { + "simple": 1806, + "newsletter": 4578, + "large": 132576 + } +} diff --git a/benchmarks/editor-ssr/src/cold-start-worker.mjs b/benchmarks/editor-ssr/src/cold-start-worker.mjs new file mode 100644 index 0000000000..4616044d1d --- /dev/null +++ b/benchmarks/editor-ssr/src/cold-start-worker.mjs @@ -0,0 +1,67 @@ +// Measures cold-start cost in a fresh Node process: module import time +// plus a single newsletter compose. Invoked by run.ts via spawnSync. +// Plain .mjs (not TypeScript) so the child process runs without a loader +// and the measurement reflects what a server actually pays. +const startedAt = performance.now(); +const mode = process.argv[2] ?? 'editor'; + +const [{ composeReactEmail }, { StarterKit }, { EmailTheming }, { Editor }] = + await Promise.all([ + import('@react-email/editor/core'), + import('@react-email/editor/extensions'), + import('@react-email/editor/plugins'), + // The headless path never needs @tiptap/core directly. + mode === 'editor' ? import('@tiptap/core') : Promise.resolve({}), + ]); + +const importedAt = performance.now(); + +const doc = { + type: 'doc', + content: [ + { + type: 'globalContent', + attrs: { data: { theme: 'basic', css: '' } }, + }, + { + type: 'heading', + attrs: { level: 1 }, + content: [{ type: 'text', text: 'Cold start' }], + }, + { + type: 'paragraph', + content: [{ type: 'text', text: 'One compose in a fresh process.' }], + }, + { + type: 'button', + attrs: { href: 'https://example.com', alignment: 'left' }, + content: [{ type: 'text', text: 'Open' }], + }, + ], +}; + +if (mode === 'editor') { + const editor = new Editor({ + extensions: [StarterKit, EmailTheming], + content: doc, + }); + await composeReactEmail({ editor }); + editor.destroy(); +} else if (mode === 'headless') { + await composeReactEmail({ + content: doc, + extensions: [StarterKit, EmailTheming], + }); +} else { + throw new Error(`unknown mode: ${mode}`); +} + +const finishedAt = performance.now(); + +process.stdout.write( + JSON.stringify({ + totalMs: finishedAt - startedAt, + importMs: importedAt - startedAt, + composeMs: finishedAt - importedAt, + }), +); diff --git a/benchmarks/editor-ssr/src/fixtures.ts b/benchmarks/editor-ssr/src/fixtures.ts new file mode 100644 index 0000000000..4a6fd68e45 --- /dev/null +++ b/benchmarks/editor-ssr/src/fixtures.ts @@ -0,0 +1,160 @@ +import type { JSONContent } from '@tiptap/core'; + +/** + * Representative documents for benchmarking the email serialization + * pipeline. All fixtures are TipTap JSON as produced by the editor — + * the same shape applications persist and would serialize on a server. + */ + +const themePanelData = { + theme: 'basic', + css: '.custom-class { letter-spacing: 0.02em; }', +}; + +export const simpleDoc: JSONContent = { + type: 'doc', + content: [ + { + type: 'globalContent', + attrs: { data: themePanelData }, + }, + { + type: 'paragraph', + content: [{ type: 'text', text: 'Hi there,' }], + }, + { + type: 'paragraph', + content: [ + { type: 'text', text: 'Your invite is ready. ' }, + { + type: 'text', + text: 'Join the workspace', + marks: [ + { type: 'link', attrs: { href: 'https://example.com/invite' } }, + ], + }, + { type: 'text', text: ' whenever you are ready.' }, + ], + }, + { + type: 'paragraph', + content: [{ type: 'text', text: '— The team' }], + }, + ], +}; + +const newsletterBody: JSONContent[] = [ + { + type: 'heading', + attrs: { level: 1 }, + content: [{ type: 'text', text: 'Product changelog' }], + }, + { + type: 'paragraph', + content: [ + { type: 'text', text: 'Here is everything we shipped this week, ' }, + { type: 'text', text: 'including', marks: [{ type: 'italic' }] }, + { type: 'text', text: ' some ' }, + { type: 'text', text: 'big ones', marks: [{ type: 'bold' }] }, + { type: 'text', text: '.' }, + ], + }, + { + type: 'section', + content: [ + { + type: 'heading', + attrs: { level: 2 }, + content: [{ type: 'text', text: 'Faster exports' }], + }, + { + type: 'paragraph', + content: [ + { + type: 'text', + text: 'Exports now stream results as they are generated.', + }, + ], + }, + { + type: 'button', + attrs: { href: 'https://example.com/changelog', alignment: 'left' }, + content: [{ type: 'text', text: 'Read the changelog' }], + }, + ], + }, + { + type: 'bulletList', + content: [ + { + type: 'listItem', + content: [ + { + type: 'paragraph', + content: [{ type: 'text', text: 'New keyboard shortcuts' }], + }, + ], + }, + { + type: 'listItem', + content: [ + { + type: 'paragraph', + content: [ + { type: 'text', text: 'Dark mode for the ' }, + { type: 'text', text: 'dashboard', marks: [{ type: 'code' }] }, + ], + }, + ], + }, + ], + }, + { + type: 'codeBlock', + attrs: { language: 'typescript' }, + content: [ + { + type: 'text', + text: "import { send } from 'emails';\n\nawait send({ to: 'user@example.com' });", + }, + ], + }, + { type: 'horizontalRule' }, + { + type: 'paragraph', + content: [ + { + type: 'text', + text: 'You are receiving this because you signed up for updates.', + }, + ], + }, +]; + +export const newsletterDoc: JSONContent = { + type: 'doc', + content: [ + { type: 'globalContent', attrs: { data: themePanelData } }, + ...newsletterBody, + ], +}; + +const LARGE_DOC_REPEATS = 40; + +export const largeDoc: JSONContent = { + type: 'doc', + content: [ + { type: 'globalContent', attrs: { data: themePanelData } }, + ...Array.from({ length: LARGE_DOC_REPEATS }, () => + structuredClone(newsletterBody), + ).flat(), + ], +}; + +export const fixtures = { + simple: simpleDoc, + newsletter: newsletterDoc, + large: largeDoc, +} as const; + +export type FixtureName = keyof typeof fixtures; diff --git a/benchmarks/editor-ssr/src/run.ts b/benchmarks/editor-ssr/src/run.ts new file mode 100644 index 0000000000..a3a4260fee --- /dev/null +++ b/benchmarks/editor-ssr/src/run.ts @@ -0,0 +1,157 @@ +import { spawnSync } from 'node:child_process'; +import { mkdirSync, writeFileSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { composeReactEmail } from '@react-email/editor/core'; +import { StarterKit } from '@react-email/editor/extensions'; +import { EmailTheming } from '@react-email/editor/plugins'; +import { Editor } from '@tiptap/core'; +import { Bench } from 'tinybench'; +import { fixtures } from './fixtures.js'; + +const here = dirname(fileURLToPath(import.meta.url)); + +const extensions = [StarterKit, EmailTheming]; + +function composeViaEditor(doc: (typeof fixtures)[keyof typeof fixtures]) { + const editor = new Editor({ extensions, content: doc }); + try { + return composeReactEmail({ editor }); + } finally { + editor.destroy(); + } +} + +interface ColdStartSample { + totalMs: number; + importMs: number; + composeMs: number; +} + +function measureColdStart(mode: 'editor' | 'headless'): ColdStartSample { + const child = spawnSync( + process.execPath, + [resolve(here, 'cold-start-worker.mjs'), mode], + { encoding: 'utf-8' }, + ); + if (child.status !== 0) { + throw new Error(`cold-start worker failed: ${child.stderr}`); + } + return JSON.parse(child.stdout) as ColdStartSample; +} + +function median(values: number[]): number { + const sorted = [...values].sort((a, b) => a - b); + return sorted[Math.floor(sorted.length / 2)] ?? Number.NaN; +} + +async function checkParity() { + const parity: Record = {}; + for (const [name, doc] of Object.entries(fixtures)) { + const fromEditor = await composeViaEditor(doc); + const fromContent = await composeReactEmail({ + content: doc, + extensions, + }); + parity[name] = + fromEditor.unformattedHtml === fromContent.unformattedHtml && + fromEditor.text === fromContent.text; + if (!parity[name]) { + const a = fromEditor.unformattedHtml; + const b = fromContent.unformattedHtml; + let index = 0; + while (index < a.length && a[index] === b[index]) { + index += 1; + } + console.error( + `parity FAILED for ${name} at byte ${index}:\n editor : …${a.slice(index - 60, index + 60)}…\n content: …${b.slice(index - 60, index + 60)}…`, + ); + } + } + return parity; +} + +async function main() { + const results: Record = {}; + + // 1. Parity: the headless path must produce byte-identical output to the + // editor path for the same document. Anything else is a correctness bug, + // not a performance trade-off. + const parity = await checkParity(); + results.parity = parity; + console.log('\n## Parity (editor path vs content path)\n'); + console.log(parity); + + // 2. Throughput: serialize each fixture the way a server would — + // one document in, one HTML string out. + const bench = new Bench({ time: 3000, warmupTime: 500 }); + for (const [name, doc] of Object.entries(fixtures)) { + bench.add(`editor-based compose: ${name}`, async () => { + await composeViaEditor(doc); + }); + // Warm schema: module-scope extensions array, the realistic server + // steady state (schema/extension resolution cached by array identity). + bench.add(`headless compose (warm): ${name}`, async () => { + await composeReactEmail({ content: doc, extensions }); + }); + // Cold schema: a fresh extensions array per call defeats the cache and + // pays extension resolution + schema compilation every time. + bench.add(`headless compose (cold): ${name}`, async () => { + await composeReactEmail({ + content: doc, + extensions: [StarterKit, EmailTheming], + }); + }); + } + await bench.run(); + + console.log('\n## Throughput (tinybench)\n'); + console.table(bench.table()); + results.throughput = bench.tasks.map((task) => ({ + name: task.name, + hz: task.result?.throughput.mean, + meanMs: task.result?.latency.mean, + p99Ms: task.result?.latency.p99, + samples: task.result?.latency.samples.length, + })); + + // 3. Cold start: fresh Node process, import + one newsletter compose. + const coldStartRuns = 7; + const coldStart: Record = {}; + for (const mode of ['editor', 'headless'] as const) { + const samples = Array.from({ length: coldStartRuns }, () => + measureColdStart(mode), + ); + coldStart[mode] = { + totalMs: median(samples.map((sample) => sample.totalMs)), + importMs: median(samples.map((sample) => sample.importMs)), + composeMs: median(samples.map((sample) => sample.composeMs)), + }; + console.log(`\n## Cold start (${mode}, median of ${coldStartRuns})\n`); + console.log(coldStart[mode]); + } + results.coldStart = coldStart; + + // 4. Output sizes, as a sanity anchor across iterations. + const sizes: Record = {}; + for (const [name, doc] of Object.entries(fixtures)) { + const { unformattedHtml } = await composeReactEmail({ + content: doc, + extensions, + }); + sizes[name] = unformattedHtml.length; + } + results.outputBytes = sizes; + console.log('\n## Output size (unformatted HTML bytes)\n'); + console.log(sizes); + + const outPath = resolve(here, '../results'); + mkdirSync(outPath, { recursive: true }); + writeFileSync( + resolve(outPath, 'latest.json'), + `${JSON.stringify(results, null, 2)}\n`, + ); + console.log(`\nResults written to ${resolve(outPath, 'latest.json')}`); +} + +await main(); diff --git a/benchmarks/editor-ssr/tsconfig.json b/benchmarks/editor-ssr/tsconfig.json new file mode 100644 index 0000000000..a236481d8d --- /dev/null +++ b/benchmarks/editor-ssr/tsconfig.json @@ -0,0 +1,15 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "extends": "tsconfig/react-library.json", + "include": ["src"], + "exclude": ["dist", "build", "node_modules"], + "compilerOptions": { + "target": "esnext", + "noUncheckedIndexedAccess": true, + "resolveJsonModule": true, + "moduleResolution": "Bundler", + "declarationMap": false, + "declaration": false, + "outDir": "dist" + } +} diff --git a/packages/editor/package.json b/packages/editor/package.json index 0cda7b13c4..631d84ea0b 100644 --- a/packages/editor/package.json +++ b/packages/editor/package.json @@ -107,7 +107,7 @@ "react-email": "workspace:*", "@radix-ui/react-popover": "^1.0.0", "@radix-ui/react-slot": "^1.2.3", - "@tiptap/core": "^3.17.1", + "@tiptap/core": "^3.18.0", "@tiptap/extension-blockquote": "^3.17.1", "@tiptap/extension-bold": "^3.17.1", "@tiptap/extension-bullet-list": "^3.17.1", diff --git a/packages/editor/src/core/index.ts b/packages/editor/src/core/index.ts index f0e4d83bcc..88e8e3f70c 100644 --- a/packages/editor/src/core/index.ts +++ b/packages/editor/src/core/index.ts @@ -1,6 +1,8 @@ export * from './event-bus'; export * from './is-document-visually-empty'; +export * from './serializer/compose-context'; export * from './serializer/compose-react-email'; export * from './serializer/email-mark'; export * from './serializer/email-node'; +export * from './serializer/serializer-plugin'; export * from './types'; diff --git a/packages/editor/src/core/serializer/compose-context.ts b/packages/editor/src/core/serializer/compose-context.ts new file mode 100644 index 0000000000..07316b84c5 --- /dev/null +++ b/packages/editor/src/core/serializer/compose-context.ts @@ -0,0 +1,142 @@ +import type { Editor, Extensions, JSONContent } from '@tiptap/core'; +import { getSchemaByResolvedExtensions, resolveExtensions } from '@tiptap/core'; +import type { Schema } from '@tiptap/pm/model'; + +/** + * Everything the serialization pipeline knows about a compose run. + * A plain data bag — no editor, no commands, no view — so serialization + * works the same in the browser, in Node, and in edge runtimes. + */ +export interface ComposeContext { + /** The document being serialized, as TipTap JSON. */ + doc: JSONContent; + /** Compiled ProseMirror schema for this extension set (mark rank sorting). */ + schema: Schema; + /** + * Resolved extensions: kits flattened and priority-sorted, matching what + * an editor's `extensionManager.extensions` would contain. + */ + extensions: Extensions; +} + +/** + * Extension resolution and schema compilation are cached per extensions-array + * identity: servers typically define the extension list once at module scope + * and serialize many documents with it, so neither should be paid per + * document. + */ +const resolutionCache = new WeakMap< + Extensions, + { snapshot: Extensions; resolved: Extensions; schema: Schema } +>(); + +function isStaleResolution( + snapshot: Extensions, + extensions: Extensions, +): boolean { + return ( + snapshot.length !== extensions.length || + snapshot.some((extension, index) => extension !== extensions[index]) + ); +} + +function assertNotPreResolved(resolved: Extensions): void { + const seen = new Set(); + const duplicates = new Set(); + for (const extension of resolved) { + if (seen.has(extension.name)) { + duplicates.add(extension.name); + } + seen.add(extension.name); + } + if (duplicates.size > 0) { + throw new Error( + `The \`extensions\` list resolves to duplicate extension names (${[...duplicates].join(', ')}). This usually means an already-resolved array — like \`editor.extensionManager.extensions\` or \`context.extensions\` — was passed; resolution re-expands kits, duplicating their children. Pass the original extensions the document was created with instead.`, + ); + } +} + +export function createComposeContext({ + content, + extensions, +}: { + content: JSONContent; + extensions: Extensions; +}): ComposeContext { + if (typeof content === 'string') { + throw new Error( + 'composeReactEmail received a string as `content`. If this is a stored JSON document, JSON.parse it first; if it is HTML, convert it with `generateJSON` from @tiptap/html.', + ); + } + + if (!extensions) { + throw new Error( + 'composeReactEmail with `content` also requires `extensions` — the extension set the document was written with.', + ); + } + + let resolution = resolutionCache.get(extensions); + if (resolution && isStaleResolution(resolution.snapshot, extensions)) { + resolution = undefined; + } + if (!resolution) { + const resolved = resolveExtensions(extensions); + assertNotPreResolved(resolved); + // `getSchema` resolves its input internally, and resolution is not + // idempotent (kits re-expand, duplicating every child extension) — so + // the already-resolved array must go through the resolved-only variant. + resolution = { + snapshot: extensions.slice(), + resolved, + schema: getSchemaByResolvedExtensions(resolved), + }; + resolutionCache.set(extensions, resolution); + } + + const topNodeName = resolution.schema.topNodeType.name; + if (content.type !== topNodeName) { + throw new Error( + `composeReactEmail expects \`content\` to be a full document ({ type: '${topNodeName}', … }) — got ${JSON.stringify(content.type)}.`, + ); + } + + // Round-tripping through the schema materializes attribute defaults the + // same way `editor.getJSON()` does — hand-written JSON serializes + // identically to editor-produced JSON — and rejects node/mark types the + // extension set doesn't know with a clear error instead of silently + // dropping content from the sent email. + const doc = resolution.schema.nodeFromJSON(content).toJSON() as JSONContent; + + return { doc, schema: resolution.schema, extensions: resolution.resolved }; +} + +const contextByEditor = new WeakMap< + Editor, + { pmDoc: unknown; context: ComposeContext } +>(); + +/** + * Coerces a live editor — or a value that is already a context — into a + * `ComposeContext`. This is the bridge for serializer plugins written + * against the pre-context API, which passed an `Editor` around. + */ +export function toComposeContext( + value: Editor | ComposeContext, +): ComposeContext { + if ('extensionManager' in value) { + const cached = contextByEditor.get(value); + if (cached && cached.pmDoc === value.state.doc) { + return cached.context; + } + // The editor's extensions are already resolved (kits flattened and + // priority-sorted), and its schema is already compiled — reuse both. + const context: ComposeContext = { + doc: value.getJSON(), + schema: value.schema, + extensions: value.extensionManager.extensions, + }; + contextByEditor.set(value, { pmDoc: value.state.doc, context }); + return context; + } + return value; +} diff --git a/packages/editor/src/core/serializer/compose-react-email.ssr.spec.tsx b/packages/editor/src/core/serializer/compose-react-email.ssr.spec.tsx new file mode 100644 index 0000000000..db21ecb66d --- /dev/null +++ b/packages/editor/src/core/serializer/compose-react-email.ssr.spec.tsx @@ -0,0 +1,624 @@ +// @vitest-environment node +import type { Extensions, JSONContent } from '@tiptap/core'; +import { Editor, Extension } from '@tiptap/core'; +import { describe, expect, it } from 'vitest'; +import { StarterKit } from '../../extensions'; +import { getGlobalContentFromJSON } from '../../extensions/global-content'; +import { + EmailTheming, + getEmailTheming, + getMergedCssJs, + getResolvedNodeStyles, +} from '../../plugins/email-theming/extension'; +import { composeReactEmail } from './compose-react-email'; +import type { SerializerPlugin } from './serializer-plugin'; + +/** + * These tests run in a plain `node` environment — no happy-dom — to + * guarantee the serialization pipeline works server-side. If any of them + * start touching `document`/`window`, they will crash here even though + * they would pass in the default unit environment. + */ + +const richDoc: JSONContent = { + type: 'doc', + content: [ + { + type: 'globalContent', + attrs: { data: { theme: 'basic', css: '.custom { color: red; }' } }, + }, + { + type: 'heading', + attrs: { level: 1 }, + content: [{ type: 'text', text: 'Server rendered' }], + }, + { + type: 'paragraph', + content: [ + { type: 'text', text: 'Nested ' }, + { + type: 'text', + text: 'marks', + marks: [ + { type: 'bold' }, + { type: 'italic' }, + { type: 'link', attrs: { href: 'https://example.com' } }, + ], + }, + ], + }, + { + type: 'button', + attrs: { href: 'https://example.com/cta', alignment: 'left' }, + content: [{ type: 'text', text: 'Click' }], + }, + { + type: 'bulletList', + content: [ + { + type: 'listItem', + content: [ + { + type: 'paragraph', + content: [{ type: 'text', text: 'First item' }], + }, + ], + }, + ], + }, + { type: 'horizontalRule' }, + ], +}; + +it('runs without a DOM', () => { + expect(typeof document).toBe('undefined'); + expect(typeof window).toBe('undefined'); +}); + +describe('composeReactEmail from content', () => { + it('serializes JSON content without an editor', async () => { + const result = await composeReactEmail({ + content: richDoc, + extensions: [StarterKit, EmailTheming], + preview: 'preview text', + }); + + expect(result.html).toContain('Server rendered'); + expect(result.html).toContain('https://example.com/cta'); + expect(result.html).toContain('.custom { color: red; }'); + // Plain-text conversion uppercases headings. + expect(result.text).toContain('SERVER RENDERED'); + expect(result.unformattedHtml).toContain('Server rendered'); + }); + + it('produces byte-identical output to the editor path', async () => { + const extensions = [StarterKit, EmailTheming]; + const editor = new Editor({ extensions, content: richDoc }); + try { + const fromEditor = await composeReactEmail({ + editor, + preview: 'parity', + }); + const fromContent = await composeReactEmail({ + content: richDoc, + extensions, + preview: 'parity', + }); + + expect(fromContent.unformattedHtml).toBe(fromEditor.unformattedHtml); + expect(fromContent.text).toBe(fromEditor.text); + } finally { + editor.destroy(); + } + }); + + it('materializes attribute defaults like the editor does', async () => { + // `button` has a `class: 'button'` default attribute. A hand-written + // doc that omits it must still render with the default applied. + const doc: JSONContent = { + type: 'doc', + content: [ + { + type: 'button', + attrs: { href: 'https://example.com' }, + content: [{ type: 'text', text: 'Go' }], + }, + ], + }; + + const { unformattedHtml } = await composeReactEmail({ + content: doc, + extensions: [StarterKit], + }); + + expect(unformattedHtml).toContain('class="button"'); + }); + + it('renders preview text and honors previewMode', async () => { + const extensions = [StarterKit, EmailTheming]; + const withPreview = await composeReactEmail({ + content: richDoc, + extensions, + preview: 'the inbox snippet', + }); + expect(withPreview.unformattedHtml).toContain('the inbox snippet'); + // Dark-mode CSS ships in emails but is stripped in preview mode. + expect(withPreview.unformattedHtml).toContain('prefers-color-scheme'); + + const previewMode = await composeReactEmail({ + content: richDoc, + extensions, + previewMode: true, + }); + expect(previewMode.unformattedHtml).not.toContain('prefers-color-scheme'); + }); + + it('serializes different documents with the same extensions array', async () => { + // Schema compilation is cached per extensions-array identity; the cache + // must not leak document state between compose runs. + const extensions = [StarterKit, EmailTheming]; + const first = await composeReactEmail({ content: richDoc, extensions }); + const other = await composeReactEmail({ + content: { + type: 'doc', + content: [ + { + type: 'paragraph', + content: [{ type: 'text', text: 'A different document' }], + }, + ], + }, + extensions, + }); + const firstAgain = await composeReactEmail({ + content: richDoc, + extensions, + }); + + expect(other.unformattedHtml).toContain('A different document'); + expect(other.unformattedHtml).not.toContain('Server rendered'); + expect(firstAgain.unformattedHtml).toBe(first.unformattedHtml); + }); + + it('rejects string content with an actionable error', async () => { + await expect( + composeReactEmail({ + // @ts-expect-error — the API only accepts JSON, this tests the runtime guard + content: '

hello

', + extensions: [StarterKit], + }), + ).rejects.toThrow(/generateJSON|JSON\.parse/); + }); + + it('rejects content without extensions', async () => { + await expect( + // @ts-expect-error — extensions is required with content; tests the runtime guard + composeReactEmail({ content: richDoc }), + ).rejects.toThrow(/also requires `extensions`/); + }); + + it('rejects content whose root is not a doc', async () => { + await expect( + composeReactEmail({ + content: { type: 'paragraph', content: [{ type: 'text', text: 'x' }] }, + extensions: [StarterKit], + }), + ).rejects.toThrow(/full document/); + }); + + it('rejects unknown node types with a clear error', async () => { + const doc: JSONContent = { + type: 'doc', + content: [{ type: 'divider' }], + }; + + await expect( + composeReactEmail({ content: doc, extensions: [StarterKit] }), + ).rejects.toThrow(/Unknown node type: divider/); + }); + + it('rejects options mixing editor and content', async () => { + // Content must be JSON here: string content (the default '') parses + // through the DOM and cannot be used in this node environment. + const editor = new Editor({ + extensions: [StarterKit], + content: { type: 'doc', content: [{ type: 'paragraph' }] }, + }); + try { + await expect( + composeReactEmail({ + // @ts-expect-error — mixing modes is a type error; the runtime guard backs it up + editor, + content: richDoc, + extensions: [StarterKit], + }), + ).rejects.toThrow(/both `editor` and `content`/); + } finally { + editor.destroy(); + } + }); + + it('rejects options mixing editor and extensions', async () => { + const editor = new Editor({ + extensions: [StarterKit], + content: { type: 'doc', content: [{ type: 'paragraph' }] }, + }); + try { + await expect( + // @ts-expect-error — mixing modes is a type error; the runtime guard backs it up + composeReactEmail({ + editor, + extensions: [StarterKit], + }), + ).rejects.toThrow(/both `editor` and `content`/); + } finally { + editor.destroy(); + } + }); + + it('rejects an already-resolved extensions array', async () => { + const editor = new Editor({ + extensions: [StarterKit, EmailTheming], + content: { type: 'doc', content: [{ type: 'paragraph' }] }, + }); + try { + await expect( + composeReactEmail({ + content: richDoc, + extensions: editor.extensionManager.extensions, + }), + ).rejects.toThrow(/already-resolved/); + } finally { + editor.destroy(); + } + }); + + it('re-resolves when the extensions array is mutated between calls', async () => { + const extensions: Extensions = [StarterKit]; + const plainDoc: JSONContent = { + type: 'doc', + content: [ + { type: 'paragraph', content: [{ type: 'text', text: 'hello' }] }, + ], + }; + const first = await composeReactEmail({ content: plainDoc, extensions }); + expect(first.unformattedHtml).toContain('hello'); + + extensions.push(EmailTheming); + const second = await composeReactEmail({ content: richDoc, extensions }); + expect(second.unformattedHtml).toContain('.custom { color: red; }'); + }); + + it('skips the prettier pass with format: false', async () => { + const extensions = [StarterKit, EmailTheming]; + const formatted = await composeReactEmail({ content: richDoc, extensions }); + const unformatted = await composeReactEmail({ + content: richDoc, + extensions, + format: false, + }); + + expect(unformatted.html).toBe(unformatted.unformattedHtml); + expect(formatted.html).not.toBe(formatted.unformattedHtml); + expect(unformatted.unformattedHtml).toBe(formatted.unformattedHtml); + }); +}); + +describe('theming on the server', () => { + const themeConfig = { + extends: 'basic', + styles: { + button: { backgroundColor: '#ff6633' }, + }, + } as const; + + it('applies config-object theme styles to documents never opened in an editor', async () => { + // The editor seeds config styles into `globalContent` when its view + // mounts. A document serialized on a server was never mounted, so the + // styles must be derived from the configuration itself. + const doc: JSONContent = { + type: 'doc', + content: [ + { + type: 'button', + attrs: { href: 'https://example.com' }, + content: [{ type: 'text', text: 'Buy' }], + }, + ], + }; + + const { unformattedHtml } = await composeReactEmail({ + content: doc, + extensions: [StarterKit, EmailTheming.configure({ theme: themeConfig })], + }); + + expect(unformattedHtml).toContain('#ff6633'); + }); + + it('applies config-object theme styles when composing from a never-mounted editor', async () => { + // Seeding config styles into `globalContent` happens in the editor + // view's sync() — which never runs for an editor that was created but + // never mounted (the shape of every headless/server editor). + const editor = new Editor({ + extensions: [StarterKit, EmailTheming.configure({ theme: themeConfig })], + content: { + type: 'doc', + content: [ + { + type: 'button', + attrs: { href: 'https://example.com' }, + content: [{ type: 'text', text: 'Buy' }], + }, + ], + }, + }); + try { + const { unformattedHtml } = await composeReactEmail({ editor }); + expect(unformattedHtml).toContain('#ff6633'); + } finally { + editor.destroy(); + } + }); + + it('prefers styles persisted in the document over config styles', async () => { + const doc: JSONContent = { + type: 'doc', + content: [ + { + type: 'globalContent', + attrs: { + data: { + theme: 'basic', + styles: [ + { + id: 'button', + title: 'Button', + inputs: [ + { + label: 'Background', + type: 'color', + prop: 'backgroundColor', + classReference: 'button', + value: '#0000ff', + }, + ], + }, + ], + }, + }, + }, + { + type: 'button', + attrs: { href: 'https://example.com' }, + content: [{ type: 'text', text: 'Buy' }], + }, + ], + }; + + const { unformattedHtml } = await composeReactEmail({ + content: doc, + extensions: [StarterKit, EmailTheming.configure({ theme: themeConfig })], + }); + + expect(unformattedHtml).toContain('#0000ff'); + expect(unformattedHtml).not.toContain('#ff6633'); + }); +}); + +describe('custom serializer plugins', () => { + it('keeps pre-context plugins working under editor-based composition', async () => { + // Faithful replica of the dashboard's plot extension, written against + // the API where the third argument and the BaseTemplate prop were a + // live Editor: getNodeStyles calls getEmailTheming() with whatever it + // received, and BaseTemplate looks the theming extension up through + // editor.extensionManager before delegating with `editor` only. + const LegacyPlugin = Extension.create({ + name: 'legacyPlugin', + priority: 1, + addOptions() { + return { + serializerPlugin: { + getNodeStyles(node: JSONContent, depth: number, editor: Editor) { + const resolved = getEmailTheming(editor); + return getResolvedNodeStyles( + node, + depth, + getMergedCssJs(resolved.theme, resolved.styles), + ); + }, + BaseTemplate({ + previewText, + children, + editor, + }: { + previewText?: string; + children: React.ReactNode; + editor: Editor; + }) { + const themingExt = editor.extensionManager.extensions.find( + (extension) => extension.name === 'theming', + ) as { options?: { serializerPlugin?: SerializerPlugin } }; + const Original = themingExt?.options?.serializerPlugin + ?.BaseTemplate as (props: unknown) => React.ReactNode; + return Original?.({ previewText, children, editor }) ?? children; + }, + }, + }; + }, + }); + + const editor = new Editor({ + extensions: [ + StarterKit, + EmailTheming.configure({ theme: 'basic' }), + LegacyPlugin, + ], + content: richDoc, + }); + try { + const { unformattedHtml } = await composeReactEmail({ + editor, + preview: 'legacy preview', + }); + expect(unformattedHtml).toContain('Server rendered'); + expect(unformattedHtml).toContain('legacy preview'); + } finally { + editor.destroy(); + } + }); + + it('receives the compose context instead of an editor', async () => { + let receivedContext: unknown; + + const CustomSerializer = Extension.create<{ + serializerPlugin: SerializerPlugin; + }>({ + name: 'customSerializer', + addOptions() { + return { + serializerPlugin: { + getNodeStyles(_node, _depth, context) { + receivedContext = context; + return {}; + }, + BaseTemplate({ children }) { + return {children}; + }, + } satisfies SerializerPlugin, + }; + }, + }); + + await composeReactEmail({ + content: richDoc, + extensions: [StarterKit, CustomSerializer], + }); + + expect(receivedContext).toMatchObject({ + doc: expect.objectContaining({ type: 'doc' }), + extensions: expect.any(Array), + schema: expect.anything(), + }); + }); + + it('keeps legacy getNodeStyles reading editor internals working under editor-based composition', async () => { + let editorInternalReads = 0; + + const LegacyInternalsPlugin = Extension.create({ + name: 'legacyInternalsPlugin', + priority: 1, + addOptions() { + return { + serializerPlugin: { + getNodeStyles(_node: JSONContent, _depth: number, editor: Editor) { + const themingExtension = editor.extensionManager.extensions.find( + (extension) => extension.name === 'theming', + ); + if (themingExtension && editor.state.doc.nodeSize > 0) { + editorInternalReads += 1; + } + return {}; + }, + BaseTemplate({ children }: { children: React.ReactNode }) { + return {children}; + }, + }, + }; + }, + }); + + const editor = new Editor({ + extensions: [StarterKit, EmailTheming, LegacyInternalsPlugin], + content: richDoc, + }); + try { + const { unformattedHtml } = await composeReactEmail({ editor }); + expect(unformattedHtml).toContain('Server rendered'); + expect(editorInternalReads).toBeGreaterThan(0); + } finally { + editor.destroy(); + } + }); + + it('throws a descriptive migration error when legacy plugins read editor internals in content mode', async () => { + const LegacyInternalsPlugin = Extension.create({ + name: 'legacyInternalsPlugin', + priority: 1, + addOptions() { + return { + serializerPlugin: { + getNodeStyles(_node: JSONContent, _depth: number, editor: Editor) { + editor.extensionManager.extensions.find( + (extension) => extension.name === 'theming', + ); + return {}; + }, + BaseTemplate({ children }: { children: React.ReactNode }) { + return {children}; + }, + }, + }; + }, + }); + + await expect( + composeReactEmail({ + content: richDoc, + extensions: [StarterKit, EmailTheming, LegacyInternalsPlugin], + }), + ).rejects.toThrow(/runs without an Editor/); + }); + + it('throws a descriptive migration error when legacy BaseTemplates read editor internals in content mode', async () => { + const LegacyTemplatePlugin = Extension.create({ + name: 'legacyTemplatePlugin', + priority: 1, + addOptions() { + return { + serializerPlugin: { + getNodeStyles() { + return {}; + }, + BaseTemplate({ + children, + editor, + }: { + children: React.ReactNode; + editor: Editor; + }) { + editor.extensionManager.extensions.find( + (extension) => extension.name === 'theming', + ); + return {children}; + }, + }, + }; + }, + }); + + await expect( + composeReactEmail({ + content: richDoc, + extensions: [StarterKit, EmailTheming, LegacyTemplatePlugin], + }), + ).rejects.toThrow(/runs without an Editor/); + }); +}); + +describe('getGlobalContentFromJSON', () => { + it('reads values from the globalContent node', () => { + expect(getGlobalContentFromJSON('theme', richDoc)).toBe('basic'); + expect(getGlobalContentFromJSON('css', richDoc)).toBe( + '.custom { color: red; }', + ); + }); + + it('returns null when the document has no globalContent node', () => { + const doc: JSONContent = { + type: 'doc', + content: [{ type: 'paragraph' }], + }; + expect(getGlobalContentFromJSON('theme', doc)).toBeNull(); + expect(getGlobalContentFromJSON('theme', { type: 'doc' })).toBeNull(); + }); +}); diff --git a/packages/editor/src/core/serializer/compose-react-email.tsx b/packages/editor/src/core/serializer/compose-react-email.tsx index cf9b4e2067..2ddbcd95b9 100644 --- a/packages/editor/src/core/serializer/compose-react-email.tsx +++ b/packages/editor/src/core/serializer/compose-react-email.tsx @@ -1,8 +1,10 @@ -import type { Editor, JSONContent } from '@tiptap/core'; +import type { Editor, Extensions, JSONContent } from '@tiptap/core'; import type { MarkType, Schema } from '@tiptap/pm/model'; import { Fragment } from 'react'; import { pretty, render, toPlainText } from 'react-email'; import { inlineCssToJs } from '../../utils/styles'; +import type { ComposeContext } from './compose-context'; +import { createComposeContext, toComposeContext } from './compose-context'; import { DefaultBaseTemplate } from './default-base-template'; import { EmailMark } from './email-mark'; import { EmailNode } from './email-node'; @@ -49,17 +51,108 @@ interface ComposeReactEmailResult { unformattedHtml: string; } -export const composeReactEmail = async ({ - editor, - preview, - previewMode = false, -}: { - editor: Editor; +interface ComposeSharedOptions { + /** Preview text shown in inbox list views before the email is opened. */ preview?: string; previewMode?: boolean; -}): Promise => { - const data = editor.getJSON(); - const extensions = editor.extensionManager.extensions; + format?: boolean; +} + +export interface ComposeFromContentOptions extends ComposeSharedOptions { + /** + * The document to serialize, as TipTap JSON. HTML strings are not + * accepted — convert them first with `generateJSON` from `@tiptap/html`. + */ + content: JSONContent; + extensions: Extensions; + editor?: never; +} + +export interface ComposeFromEditorOptions extends ComposeSharedOptions { + editor: Editor; + content?: never; + extensions?: never; +} + +function resolveContext( + options: ComposeFromContentOptions | ComposeFromEditorOptions, +): ComposeContext { + if (options.editor) { + if (options.content || options.extensions) { + throw new Error( + 'composeReactEmail received both `editor` and `content`/`extensions` — pass either an editor, or content with extensions.', + ); + } + return toComposeContext(options.editor); + } + + if (!options.content) { + throw new Error( + 'composeReactEmail needs either `editor` or `content` + `extensions`.', + ); + } + + return createComposeContext(options); +} + +const EDITOR_ONLY_MEMBERS = new Set([ + 'extensionManager', + 'state', + 'view', + 'commands', + 'chain', + 'can', + 'storage', + 'options', + 'isDestroyed', + 'isEditable', + 'isEmpty', + 'getJSON', + 'getHTML', + 'getText', + 'getAttributes', +]); + +function withLegacyEditorAccess( + context: ComposeContext, + editor: Editor | undefined, +): ComposeContext { + return new Proxy(context, { + get(target, property, receiver) { + if (property in target || typeof property === 'symbol') { + return Reflect.get(target, property, receiver); + } + if (editor) { + const member = editor[property as keyof Editor]; + return typeof member === 'function' + ? (member as (...args: unknown[]) => unknown).bind(editor) + : member; + } + if (EDITOR_ONLY_MEMBERS.has(property as string)) { + throw new Error( + `Composing from \`content\` runs without an Editor: \`${String(property)}\` is not available inside serializer plugins. Read the \`ComposeContext\` ({ doc, schema, extensions }) the serializer passes instead — e.g. resolve theming with \`getEmailTheming(context)\`.`, + ); + } + return undefined; + }, + }); +} + +export function composeReactEmail( + options: ComposeFromContentOptions, +): Promise; +export function composeReactEmail( + options: ComposeFromEditorOptions, +): Promise; +export async function composeReactEmail( + options: ComposeFromContentOptions | ComposeFromEditorOptions, +): Promise { + const { preview, previewMode = false, format = true } = options; + const context = withLegacyEditorAccess( + resolveContext(options), + options.editor, + ); + const { doc, schema, extensions } = context; const serializerPlugin = extensions .map( @@ -95,7 +188,7 @@ export const composeReactEmail = async ({ }); return content.map((node: JSONContent, index: number) => { - const style = serializerPlugin?.getNodeStyles(node, depth, editor) ?? {}; + const style = serializerPlugin?.getNodeStyles(node, depth, context) ?? {}; const inlineStyles = inlineCssToJs(node.attrs?.style); @@ -132,7 +225,7 @@ export const composeReactEmail = async ({ ); if (node.marks) { - for (const mark of sortMarksBySchema(node.marks, editor.schema)) { + for (const mark of sortMarksBySchema(node.marks, schema)) { const emailMark = typeToExtensionMap[mark.type]; if (emailMark instanceof EmailMark) { const MarkComponent = emailMark.config.renderToReactEmail; @@ -143,7 +236,7 @@ export const composeReactEmail = async ({ attrs: mark.attrs ?? {}, }, depth, - editor, + context, ) ?? {}; renderedNode = ( {parsedContent} @@ -177,9 +271,9 @@ export const composeReactEmail = async ({ ); const [prettyHtml, text] = await Promise.all([ - pretty(unformattedHtml), + format ? pretty(unformattedHtml) : unformattedHtml, toPlainText(unformattedHtml), ]); return { html: prettyHtml, text, unformattedHtml }; -}; +} diff --git a/packages/editor/src/core/serializer/serializer-plugin.ts b/packages/editor/src/core/serializer/serializer-plugin.ts index 09acfd2fdc..58ceaa4a54 100644 --- a/packages/editor/src/core/serializer/serializer-plugin.ts +++ b/packages/editor/src/core/serializer/serializer-plugin.ts @@ -1,15 +1,23 @@ import type { Editor, JSONContent } from '@tiptap/core'; +import type { ComposeContext } from './compose-context'; export interface SerializerPlugin { getNodeStyles( node: JSONContent, depth: number, - editor: Editor, + context: ComposeContext, ): React.CSSProperties; BaseTemplate(props: { previewText?: string; children: React.ReactNode; - editor: Editor; + context: ComposeContext; + /** + * @deprecated Kept so BaseTemplate implementations written against the + * editor-based API keep working: it carries the live editor when + * composing with `{ editor }`, and falls back to the context otherwise. + * Read `context` instead. + */ + editor?: Editor | ComposeContext; previewMode?: boolean; }): React.ReactNode; } diff --git a/packages/editor/src/extensions/global-content.ts b/packages/editor/src/extensions/global-content.ts index a6781e10a2..efd3cdb274 100644 --- a/packages/editor/src/extensions/global-content.ts +++ b/packages/editor/src/extensions/global-content.ts @@ -1,4 +1,9 @@ -import { type Editor, mergeAttributes, Node } from '@tiptap/core'; +import { + type Editor, + type JSONContent, + mergeAttributes, + Node, +} from '@tiptap/core'; const GLOBAL_CONTENT_NODE_TYPE = 'globalContent' as const; @@ -57,6 +62,35 @@ export function getGlobalContent(key: string, editor: Editor): unknown | null { return editor.state.doc.nodeAt(position)?.attrs.data[key] ?? null; } +function findGlobalContentNode(nodes: JSONContent[]): JSONContent | null { + for (const node of nodes) { + if (node.type === GLOBAL_CONTENT_NODE_TYPE) { + return node; + } + if (node.content) { + const nested = findGlobalContentNode(node.content); + if (nested) { + return nested; + } + } + } + return null; +} + +/** + * Reads a `globalContent` value straight from a document's JSON, without an + * editor. Pure and cache-free, so it is safe for server workloads that + * serialize many documents concurrently. + */ +export function getGlobalContentFromJSON( + key: string, + doc: JSONContent, +): unknown | null { + const node = findGlobalContentNode(doc.content ?? []); + const data = node?.attrs?.data as Record | undefined; + return data?.[key] ?? null; +} + export const GlobalContent = Node.create({ name: GLOBAL_CONTENT_NODE_TYPE, diff --git a/packages/editor/src/plugins/email-theming/extension.tsx b/packages/editor/src/plugins/email-theming/extension.tsx index 907330544a..818d9405be 100644 --- a/packages/editor/src/plugins/email-theming/extension.tsx +++ b/packages/editor/src/plugins/email-theming/extension.tsx @@ -1,11 +1,16 @@ -import type { Editor, JSONContent } from '@tiptap/core'; +import type { Editor, Extensions, JSONContent } from '@tiptap/core'; import { Extension } from '@tiptap/core'; import { Plugin, PluginKey } from '@tiptap/pm/state'; import { useEditorState } from '@tiptap/react'; import type * as React from 'react'; import { Body, Head, Html, Preview } from 'react-email'; +import type { ComposeContext } from '../../core/serializer/compose-context'; +import { toComposeContext } from '../../core/serializer/compose-context'; import type { SerializerPlugin } from '../../core/serializer/serializer-plugin'; -import { getGlobalContent } from '../../extensions/global-content'; +import { + getGlobalContent, + getGlobalContentFromJSON, +} from '../../extensions/global-content'; import { DARK_MODE_CSS } from '../../utils/dark-mode'; import { injectGlobalPlainCss, @@ -187,19 +192,117 @@ function resolveThemeConfig(config: EditorThemeInput): { return { baseTheme, panels }; } -export function getEmailTheming(editor: Editor) { - const theme = getEmailTheme(editor); - const normalizedStyles = - normalizeThemePanelStyles(theme, getEmailStyles(editor)) ?? - EDITOR_THEMES[theme]; +interface ThemingInputs { + /** The theme configured on the theming extension, if any. */ + configuredTheme: EditorThemeInput | undefined; + /** Values persisted in the document's `globalContent` node. */ + globalTheme: unknown; + globalStyles: PanelGroup[] | null; + globalCss: string | null; +} + +export interface EmailThemingResult { + theme: EditorTheme; + styles: PanelGroup[]; + css: string | null; +} + +function resolveEmailTheme( + configuredTheme: EditorThemeInput | undefined, + globalTheme: unknown, + globalStyles: PanelGroup[] | null, +): EditorTheme { + if (isThemeConfig(configuredTheme)) { + return resolveThemeConfig(configuredTheme).baseTheme; + } + if (configuredTheme === 'basic' || configuredTheme === 'minimal') { + return configuredTheme; + } + if (globalTheme === 'basic' || globalTheme === 'minimal') { + return globalTheme; + } + return inferThemeFromPanelStyles(globalStyles) ?? 'basic'; +} + +/** + * Resolves the effective theme, panel styles, and global CSS from the two + * places theming can live: the extension's configuration and the document's + * `globalContent` node. Shared by the editor path (live document) and the + * serializer path (JSON document) so both resolve identically. + */ +function resolveEmailTheming({ + configuredTheme, + globalTheme, + globalStyles, + globalCss, +}: ThemingInputs): EmailThemingResult { + const theme = resolveEmailTheme(configuredTheme, globalTheme, globalStyles); + + // Styles persisted in the document win. Otherwise a config-object theme + // contributes its own panel overrides — the editor also seeds these into + // `globalContent` on mount, but a document serialized on a server may + // never have been opened in an editor, so seeding cannot be relied on. + const panelStyles = + globalStyles ?? + (isThemeConfig(configuredTheme) + ? (resolveThemeConfig(configuredTheme).panels ?? null) + : null); return { - styles: normalizedStyles, theme, - css: getEmailCss(editor), + styles: + normalizeThemePanelStyles(theme, panelStyles) ?? EDITOR_THEMES[theme], + css: globalCss, }; } +function findConfiguredTheme( + extensions: Extensions, +): EditorThemeInput | undefined { + return ( + extensions.find((extension) => extension.name === 'theming') as + | { options?: { theme?: EditorThemeInput } } + | undefined + )?.options?.theme; +} + +export function getEmailTheming( + editor: Editor | ComposeContext, +): EmailThemingResult { + // Also accepts a ComposeContext: serializer plugins written against the + // editor-based API call this with whatever the serializer handed them, + // which is a context since the SSR refactor. + if (!('extensionManager' in editor)) { + return getEmailThemingFromContext(editor); + } + return resolveEmailTheming({ + configuredTheme: findConfiguredTheme(editor.extensionManager.extensions), + globalTheme: getGlobalContent('theme', editor), + globalStyles: getEmailStyles(editor), + globalCss: getEmailCss(editor), + }); +} + +const themingByContext = new WeakMap(); + +export function getEmailThemingFromContext( + context: ComposeContext, +): EmailThemingResult { + let theming = themingByContext.get(context); + if (!theming) { + theming = resolveEmailTheming({ + configuredTheme: findConfiguredTheme(context.extensions), + globalTheme: getGlobalContentFromJSON('theme', context.doc), + globalStyles: getGlobalContentFromJSON('styles', context.doc) as + | PanelGroup[] + | null, + globalCss: getGlobalContentFromJSON('css', context.doc) as string | null, + }); + themingByContext.set(context, theming); + } + return theming; +} + export function useEmailTheming(editor: Editor | null) { return useEditorState({ editor, @@ -241,37 +344,30 @@ export function setGlobalCssInjected(editor: Editor, css: string): boolean { } function getEmailTheme(editor: Editor): EditorTheme { - const extensionOptions = ( - editor.extensionManager.extensions.find( - (extension) => extension.name === 'theming', - ) as { options?: { theme?: EditorThemeInput } } - )?.options?.theme; - - if (isThemeConfig(extensionOptions)) { - return extensionOptions.extends ?? 'minimal'; - } - - if (extensionOptions === 'basic' || extensionOptions === 'minimal') { - return extensionOptions; - } - - const globalTheme = getGlobalContent('theme', editor) as EditorTheme | null; - if (globalTheme === 'basic' || globalTheme === 'minimal') { - return globalTheme; - } - - const inferredTheme = inferThemeFromPanelStyles(getEmailStyles(editor)); - if (inferredTheme) { - return inferredTheme; - } - - return 'basic'; + return resolveEmailTheme( + findConfiguredTheme(editor.extensionManager.extensions), + getGlobalContent('theme', editor), + getEmailStyles(editor), + ); } function getEmailCss(editor: Editor) { return getGlobalContent('css', editor) as string | null; } +const mergedCssJsByContext = new WeakMap(); + +function getMergedCssJsForContext(value: Editor | ComposeContext): CssJs { + const context = toComposeContext(value); + let merged = mergedCssJsByContext.get(context); + if (!merged) { + const { theme, styles } = getEmailThemingFromContext(context); + merged = getMergedCssJs(theme, styles); + mergedCssJsByContext.set(context, merged); + } + return merged; +} + export const EmailTheming = Extension.create<{ theme?: EditorThemeInput; serializerPlugin: SerializerPlugin; @@ -282,18 +378,32 @@ export const EmailTheming = Extension.create<{ return { theme: undefined as EditorThemeInput | undefined, serializerPlugin: { - getNodeStyles(node, depth, editor): React.CSSProperties { - const theming = getEmailTheming(editor); - + getNodeStyles(node, depth, context): React.CSSProperties { return getResolvedNodeStyles( node, depth, - getMergedCssJs(theming.theme, theming.styles), + getMergedCssJsForContext(context), ); }, - BaseTemplate({ previewText, children, editor, previewMode = false }) { - const { css: globalCss, styles, theme } = getEmailTheming(editor); - const mergedStyles = getMergedCssJs(theme, styles); + BaseTemplate({ + previewText, + children, + context, + editor, + previewMode = false, + }) { + // `editor` carries the context when a BaseTemplate written against + // the editor-based API delegates here forwarding only that prop. + const received = context ?? editor; + if (!received) { + throw new Error( + "EmailTheming's BaseTemplate received neither `context` nor `editor`. Serializer plugins must forward the `context` prop (the `editor` prop was replaced by `context`).", + ); + } + const resolvedContext = toComposeContext(received); + const { css: globalCss } = + getEmailThemingFromContext(resolvedContext); + const mergedStyles = getMergedCssJsForContext(resolvedContext); return ( @@ -351,11 +461,9 @@ export const EmailTheming = Extension.create<{ const sync = () => { if (!seededFromConfig) { seededFromConfig = true; - const extensionTheme = ( - editor.extensionManager.extensions.find( - (ext) => ext.name === 'theming', - ) as { options?: { theme?: EditorThemeInput } } - )?.options?.theme; + const extensionTheme = findConfiguredTheme( + editor.extensionManager.extensions, + ); if (isThemeConfig(extensionTheme)) { const { baseTheme, panels } = diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cae5bcd28d..2fe8d0ca66 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -450,6 +450,37 @@ importers: specifier: 'catalog:' version: 4.3.6 + benchmarks/editor-ssr: + dependencies: + '@react-email/editor': + specifier: workspace:* + version: link:../../packages/editor + '@tiptap/core': + specifier: ^3.17.1 + version: 3.20.1(@tiptap/pm@3.20.1) + react: + specifier: 19.2.4 + version: 19.2.4 + react-dom: + specifier: 19.2.4 + version: 19.2.4(react@19.2.4) + tinybench: + specifier: 'catalog:' + version: 3.1.0 + devDependencies: + '@types/node': + specifier: 'catalog:' + version: 22.19.18 + tsconfig: + specifier: workspace:* + version: link:../../packages/tsconfig + tsx: + specifier: 'catalog:' + version: 4.21.0 + typescript: + specifier: 'catalog:' + version: 5.9.3 + benchmarks/tailwind: dependencies: '@react-email/render': @@ -531,7 +562,7 @@ importers: specifier: ^1.2.3 version: 1.2.4(@types/react@19.2.14)(react@19.2.4) '@tiptap/core': - specifier: ^3.17.1 + specifier: ^3.18.0 version: 3.20.1(@tiptap/pm@3.20.1) '@tiptap/extension-blockquote': specifier: ^3.17.1 diff --git a/skills/react-email/references/EDITOR.md b/skills/react-email/references/EDITOR.md index 8b4f98879f..37ef4f53f2 100644 --- a/skills/react-email/references/EDITOR.md +++ b/skills/react-email/references/EDITOR.md @@ -315,8 +315,30 @@ function ExportPanel() { The `preview` parameter is optional — when provided, it sets the inbox preview text in the exported HTML. +### Server-side (no editor, no DOM) + +`composeReactEmail` also accepts a stored TipTap JSON document plus the extension set it +was written with, and renders it anywhere Node runs — API routes, workers, cron jobs: + +```tsx +import { composeReactEmail } from '@react-email/editor/core'; +import { StarterKit } from '@react-email/editor/extensions'; +import { EmailTheming } from '@react-email/editor/plugins'; + +const extensions = [StarterKit, EmailTheming]; + +const { unformattedHtml, text } = await composeReactEmail({ + content: storedDocument, // editor.getJSON() persisted earlier + extensions, + format: false, // skip the Prettier pass servers never read +}); +``` + +Output is byte-identical to exporting from a live editor. HTML strings are rejected — +convert HTML to JSON first with `generateJSON` from `@tiptap/html`. + The export pipeline: -1. Reads the editor's JSON document +1. Reads the document (from the editor, or from `content` + `extensions`) 2. Traverses each node and mark 3. Calls `renderToReactEmail()` on each `EmailNode` and `EmailMark` 4. Applies theme styles via `EmailTheming` plugin (if configured)