Skip to content

feat: make editor extensions ssr#3673

Draft
danilowoz wants to merge 1 commit into
canaryfrom
07-21-feat_make_editor_extensions_ssr
Draft

feat: make editor extensions ssr#3673
danilowoz wants to merge 1 commit into
canaryfrom
07-21-feat_make_editor_extensions_ssr

Conversation

@danilowoz

@danilowoz danilowoz commented Jul 21, 2026

Copy link
Copy Markdown
Member

Summary by cubic

Make the email serialization pipeline SSR-capable. composeReactEmail can now render stored TipTap JSON with your extensions on any server, no Editor or DOM required, and includes a format option to skip Prettier when not needed.

  • New Features

    • composeReactEmail({ content, extensions }) returns html, text, and unformattedHtml; { editor } still works.
    • format (default true) skips Prettier when false; html === unformattedHtml in that mode.
    • Serializer hooks now receive a ComposeContext ({ doc, schema, extensions }); getEmailTheming accepts either an editor or a context.
    • Extension resolution and schema compilation are cached by extensions-array identity; mutated arrays re-resolve; already-resolved arrays (like editor.extensionManager.extensions) are rejected.
    • Added getGlobalContentFromJSON and exported EmailThemingResult.
    • Docs updated and SSR benchmarks added; output is byte-identical to editor-based exports.
  • Migration

    • Update custom serializer plugins: use context in getNodeStyles and BaseTemplate. Legacy editor-based plugins continue working in { editor } mode; accessing editor internals in { content } mode throws a clear error.
    • When using server-side compose, pass TipTap JSON content and the original extensions list; do not mix editor with content/extensions, and do not pass pre-resolved arrays.
    • Prefer format: false on server send paths to avoid the Prettier cost.

Written for commit 6ca1bed. Summary will update on new commits.

Review in cubic

@vercel

vercel Bot commented Jul 21, 2026

Copy link
Copy Markdown
Contributor

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
react-email Ready Ready Preview, Comment Jul 21, 2026 10:49am
react-email-demo Ready Ready Preview, Comment Jul 21, 2026 10:49am

@changeset-bot

changeset-bot Bot commented Jul 21, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 6ca1bed

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 2 packages
Name Type
@react-email/editor Minor
@benchmarks/editor-ssr Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@cubic-dev-ai

cubic-dev-ai Bot commented Jul 21, 2026

Copy link
Copy Markdown
Contributor

Running ultrareview automatically — This PR refactors the core serialization pipeline, modifies public APIs (composeReactEmail, SerializerPlugin), adds caching with WeakMap, and introduces a new ComposeContext type — a bug here could silently produce incorrect email HTML for all server-side exports.. I'll post findings when complete.

@pkg-pr-new

pkg-pr-new Bot commented Jul 21, 2026

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/@react-email/editor@3673

commit: 6ca1bed

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ultrareview completed in 31m 37s

12 issues found across 20 files

Confidence score: 3/5

  • In benchmarks/editor-ssr/src/run.ts, parity mismatches are only logged so the benchmark can still exit successfully and publish false results, which undermines the main correctness signal for this PR — make main fail when any fixture mismatch is detected before merging.
  • Also in benchmarks/editor-ssr/src/run.ts, the Editor is torn down before composeReactEmail finishes, creating a race where serializer/template code can read a destroyed editor and produce flaky parity or throughput numbers — await compose completion before teardown.
  • benchmarks/editor-ssr/tsconfig.json currently drops Node ambient types, so src/run.ts type-checking fails on process and node:* imports; merging as-is risks a broken benchmark build/check step — restore Node types inheritance (or explicitly include them) before merge.
  • In packages/editor/src/core/serializer/compose-react-email.tsx, custom content-mode serializers calling editor.isActive(...) can throw a raw TypeError instead of migration guidance, which makes integration failures harder for consumers to diagnose — classify/intercept isActive as editor-only in the proxy path before shipping.
Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="benchmarks/editor-ssr/bench_notebook.md">

<violation number="1" location="benchmarks/editor-ssr/bench_notebook.md:173">
P3: The notebook currently describes the extension list as immutable, but the finalized API detects mutations and re-resolves the schema. This conflicts with both the checked-in changeset and the SSR regression test, so it can mislead maintainers about the cache contract. Updating this review note to describe snapshot validation and re-resolution would keep the benchmark record aligned with the shipped behavior.</violation>
</file>

<file name="benchmarks/editor-ssr/src/cold-start-worker.mjs">

<violation number="1" location="benchmarks/editor-ssr/src/cold-start-worker.mjs:2">
P3: The cold-start result is labeled as a newsletter compose, but this worker serializes a much smaller four-block document. That makes the published `composeMs` unsuitable for comparing the advertised newsletter scenario; consider either using the newsletter fixture's shape here or renaming the sample/benchmark description to a small email compose.</violation>
</file>

<file name="packages/editor/src/core/serializer/compose-react-email.tsx">

<violation number="1" location="packages/editor/src/core/serializer/compose-react-email.tsx:113">
P2: Content-mode custom serializers that call `editor.isActive(...)` get an unhelpful `TypeError` rather than the intended migration guidance, because `isActive` is not classified as an editor-only member and the proxy returns `undefined` for it. Adding this public Editor method to the guarded set would preserve the actionable SSR migration error.</violation>
</file>

<file name="apps/docs/editor/api-reference/compose-react-email.mdx">

<violation number="1" location="apps/docs/editor/api-reference/compose-react-email.mdx:113">
P3: Custom node renderers can otherwise be written with the expectation that inline styles have already been merged into the `style` prop. The compose pipeline passes theme styles only; the built-in renderers merge `node.attrs.style` themselves. Suggest clarifying that this merge is renderer responsibility.</violation>
</file>

<file name="packages/editor/src/plugins/email-theming/extension.tsx">

<violation number="1" location="packages/editor/src/plugins/email-theming/extension.tsx:216">
P3: Editing with a config-object theme now rebuilds all configured panel overrides on every preview update even though this branch only needs the base theme. Reading `configuredTheme.extends` directly preserves the theme choice while avoiding repeated panel/input cloning during editor transactions.</violation>
</file>

<file name="benchmarks/editor-ssr/src/run.ts">

<violation number="1" location="benchmarks/editor-ssr/src/run.ts:4">
P3: Custom agent: **Initialisms and Acronyms Naming Conventions**

The new local identifier uses the all-caps `URL` acronym (`fileURLToPath`), which breaks the repository naming rule for identifiers. Aliasing this import to `fileUrlToPath` and using that alias would keep the Node API while preserving the required camel-cased acronym form.</violation>

<violation number="2" location="benchmarks/editor-ssr/src/run.ts:19">
P2: The editor benchmark tears down its `Editor` before `composeReactEmail` has completed. That can make the editor-mode parity/throughput path observe a destroyed editor when a serializer/BaseTemplate reads the legacy `editor` prop during rendering, and it differs from the cold-start benchmark's lifecycle. Await the compose promise inside the `try` so destruction occurs only after serialization finishes.</violation>

<violation number="3" location="benchmarks/editor-ssr/src/run.ts:80">
P2: A headless/editor output mismatch is only logged, so this advertised parity gate still exits successfully and can publish benchmark results marked with `false`. Fail `main` when any fixture does not match, after retaining the per-fixture diagnostic from `checkParity()`.</violation>

<violation number="4" location="benchmarks/editor-ssr/src/run.ts:142">
P3: The reported `outputBytes` value is a UTF-16 character count rather than the UTF-8 payload size. Unicode email content therefore makes the output-size benchmark undercount bytes; use `Buffer.byteLength` to match the section label and persisted field name.</violation>
</file>

<file name="benchmarks/editor-ssr/tsconfig.json">

<violation number="1" location="benchmarks/editor-ssr/tsconfig.json:8">
P2: The benchmark's TypeScript configuration excludes the Node ambient types inherited from its React-library base config, so type-checking `src/run.ts` fails on `process` and the `node:*` imports even though `@types/node` is installed. Including `node` while retaining the existing Vitest globals lets the SSR benchmark be type-checked.</violation>
</file>

<file name="packages/editor/src/extensions/global-content.ts">

<violation number="1" location="packages/editor/src/extensions/global-content.ts:85">
P2: Custom agent: **Initialisms and Acronyms Naming Conventions**

The new public helper breaks the project's acronym casing convention: `getGlobalContentFromJSON` leaves `JSON` fully uppercase. Naming it `getGlobalContentFromJson` keeps the API camel-cased consistently with the rule and avoids introducing a differently cased public identifier.</violation>
</file>

<file name="benchmarks/editor-ssr/src/fixtures.ts">

<violation number="1" location="benchmarks/editor-ssr/src/fixtures.ts:1">
P3: Custom agent: **Initialisms and Acronyms Naming Conventions**

The new fixture types expose the `JSON` initialism in all caps (`JSONContent`), which conflicts with the repository naming rule for identifiers. Aliasing the import to `JsonContent` and updating its local annotations keeps the TipTap API import intact while making the local type name consistent.</violation>
</file>

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

'getJSON',
'getHTML',
'getText',
'getAttributes',

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Content-mode custom serializers that call editor.isActive(...) get an unhelpful TypeError rather than the intended migration guidance, because isActive is not classified as an editor-only member and the proxy returns undefined for it. Adding this public Editor method to the guarded set would preserve the actionable SSR migration error.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/editor/src/core/serializer/compose-react-email.tsx, line 113:

<comment>Content-mode custom serializers that call `editor.isActive(...)` get an unhelpful `TypeError` rather than the intended migration guidance, because `isActive` is not classified as an editor-only member and the proxy returns `undefined` for it. Adding this public Editor method to the guarded set would preserve the actionable SSR migration error.</comment>

<file context>
@@ -49,17 +51,108 @@ interface ComposeReactEmailResult {
+  'getJSON',
+  'getHTML',
+  'getText',
+  'getAttributes',
+]);
+
</file context>
Suggested change
'getAttributes',
'getAttributes',
'isActive',

// 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();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: A headless/editor output mismatch is only logged, so this advertised parity gate still exits successfully and can publish benchmark results marked with false. Fail main when any fixture does not match, after retaining the per-fixture diagnostic from checkParity().

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At benchmarks/editor-ssr/src/run.ts, line 80:

<comment>A headless/editor output mismatch is only logged, so this advertised parity gate still exits successfully and can publish benchmark results marked with `false`. Fail `main` when any fixture does not match, after retaining the per-fixture diagnostic from `checkParity()`.</comment>

<file context>
@@ -0,0 +1,157 @@
+  // 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');
</file context>

function composeViaEditor(doc: (typeof fixtures)[keyof typeof fixtures]) {
const editor = new Editor({ extensions, content: doc });
try {
return composeReactEmail({ editor });

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: The editor benchmark tears down its Editor before composeReactEmail has completed. That can make the editor-mode parity/throughput path observe a destroyed editor when a serializer/BaseTemplate reads the legacy editor prop during rendering, and it differs from the cold-start benchmark's lifecycle. Await the compose promise inside the try so destruction occurs only after serialization finishes.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At benchmarks/editor-ssr/src/run.ts, line 19:

<comment>The editor benchmark tears down its `Editor` before `composeReactEmail` has completed. That can make the editor-mode parity/throughput path observe a destroyed editor when a serializer/BaseTemplate reads the legacy `editor` prop during rendering, and it differs from the cold-start benchmark's lifecycle. Await the compose promise inside the `try` so destruction occurs only after serialization finishes.</comment>

<file context>
@@ -0,0 +1,157 @@
+function composeViaEditor(doc: (typeof fixtures)[keyof typeof fixtures]) {
+  const editor = new Editor({ extensions, content: doc });
+  try {
+    return composeReactEmail({ editor });
+  } finally {
+    editor.destroy();
</file context>

"exclude": ["dist", "build", "node_modules"],
"compilerOptions": {
"target": "esnext",
"noUncheckedIndexedAccess": true,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: The benchmark's TypeScript configuration excludes the Node ambient types inherited from its React-library base config, so type-checking src/run.ts fails on process and the node:* imports even though @types/node is installed. Including node while retaining the existing Vitest globals lets the SSR benchmark be type-checked.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At benchmarks/editor-ssr/tsconfig.json, line 8:

<comment>The benchmark's TypeScript configuration excludes the Node ambient types inherited from its React-library base config, so type-checking `src/run.ts` fails on `process` and the `node:*` imports even though `@types/node` is installed. Including `node` while retaining the existing Vitest globals lets the SSR benchmark be type-checked.</comment>

<file context>
@@ -0,0 +1,15 @@
+  "exclude": ["dist", "build", "node_modules"],
+  "compilerOptions": {
+    "target": "esnext",
+    "noUncheckedIndexedAccess": true,
+    "resolveJsonModule": true,
+    "moduleResolution": "Bundler",
</file context>

* editor. Pure and cache-free, so it is safe for server workloads that
* serialize many documents concurrently.
*/
export function getGlobalContentFromJSON(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Custom agent: Initialisms and Acronyms Naming Conventions

The new public helper breaks the project's acronym casing convention: getGlobalContentFromJSON leaves JSON fully uppercase. Naming it getGlobalContentFromJson keeps the API camel-cased consistently with the rule and avoids introducing a differently cased public identifier.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/editor/src/extensions/global-content.ts, line 85:

<comment>The new public helper breaks the project's acronym casing convention: `getGlobalContentFromJSON` leaves `JSON` fully uppercase. Naming it `getGlobalContentFromJson` keeps the API camel-cased consistently with the rule and avoids introducing a differently cased public identifier.</comment>

<file context>
@@ -57,6 +62,35 @@ export function getGlobalContent(key: string, editor: Editor): unknown | null {
+ * editor. Pure and cache-free, so it is safe for server workloads that
+ * serialize many documents concurrently.
+ */
+export function getGlobalContentFromJSON(
+  key: string,
+  doc: JSONContent,
</file context>

Comment on lines +113 to 114
1. **Resolves styles** — calls `serializerPlugin.getNodeStyles(node, depth, context)` to get
theme styles, then merges any inline styles from the node's attributes

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: Custom node renderers can otherwise be written with the expectation that inline styles have already been merged into the style prop. The compose pipeline passes theme styles only; the built-in renderers merge node.attrs.style themselves. Suggest clarifying that this merge is renderer responsibility.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/docs/editor/api-reference/compose-react-email.mdx, line 113:

<comment>Custom node renderers can otherwise be written with the expectation that inline styles have already been merged into the `style` prop. The compose pipeline passes theme styles only; the built-in renderers merge `node.attrs.style` themselves. Suggest clarifying that this merge is renderer responsibility.</comment>

<file context>
@@ -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
</file context>
Suggested change
1. **Resolves styles** — calls `serializerPlugin.getNodeStyles(node, depth, context)` to get
theme styles, then merges any inline styles from the node's attributes
1. **Resolves styles** — calls `serializerPlugin.getNodeStyles(node, depth, context)` to get
theme styles and passes them to the node renderer, which can merge inline styles from its attributes

globalStyles: PanelGroup[] | null,
): EditorTheme {
if (isThemeConfig(configuredTheme)) {
return resolveThemeConfig(configuredTheme).baseTheme;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: Editing with a config-object theme now rebuilds all configured panel overrides on every preview update even though this branch only needs the base theme. Reading configuredTheme.extends directly preserves the theme choice while avoiding repeated panel/input cloning during editor transactions.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/editor/src/plugins/email-theming/extension.tsx, line 216:

<comment>Editing with a config-object theme now rebuilds all configured panel overrides on every preview update even though this branch only needs the base theme. Reading `configuredTheme.extends` directly preserves the theme choice while avoiding repeated panel/input cloning during editor transactions.</comment>

<file context>
@@ -187,19 +192,117 @@ function resolveThemeConfig(config: EditorThemeInput): {
+  globalStyles: PanelGroup[] | null,
+): EditorTheme {
+  if (isThemeConfig(configuredTheme)) {
+    return resolveThemeConfig(configuredTheme).baseTheme;
+  }
+  if (configuredTheme === 'basic' || configuredTheme === 'minimal') {
</file context>
Suggested change
return resolveThemeConfig(configuredTheme).baseTheme;
return configuredTheme.extends ?? 'minimal';

content: doc,
extensions,
});
sizes[name] = unformattedHtml.length;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: The reported outputBytes value is a UTF-16 character count rather than the UTF-8 payload size. Unicode email content therefore makes the output-size benchmark undercount bytes; use Buffer.byteLength to match the section label and persisted field name.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At benchmarks/editor-ssr/src/run.ts, line 142:

<comment>The reported `outputBytes` value is a UTF-16 character count rather than the UTF-8 payload size. Unicode email content therefore makes the output-size benchmark undercount bytes; use `Buffer.byteLength` to match the section label and persisted field name.</comment>

<file context>
@@ -0,0 +1,157 @@
+      content: doc,
+      extensions,
+    });
+    sizes[name] = unformattedHtml.length;
+  }
+  results.outputBytes = sizes;
</file context>

@@ -0,0 +1,160 @@
import type { JSONContent } from '@tiptap/core';

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: Custom agent: Initialisms and Acronyms Naming Conventions

The new fixture types expose the JSON initialism in all caps (JSONContent), which conflicts with the repository naming rule for identifiers. Aliasing the import to JsonContent and updating its local annotations keeps the TipTap API import intact while making the local type name consistent.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At benchmarks/editor-ssr/src/fixtures.ts, line 1:

<comment>The new fixture types expose the `JSON` initialism in all caps (`JSONContent`), which conflicts with the repository naming rule for identifiers. Aliasing the import to `JsonContent` and updating its local annotations keeps the TipTap API import intact while making the local type name consistent.</comment>

<file context>
@@ -0,0 +1,160 @@
+import type { JSONContent } from '@tiptap/core';
+
+/**
</file context>

import { spawnSync } from 'node:child_process';
import { mkdirSync, writeFileSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: Custom agent: Initialisms and Acronyms Naming Conventions

The new local identifier uses the all-caps URL acronym (fileURLToPath), which breaks the repository naming rule for identifiers. Aliasing this import to fileUrlToPath and using that alias would keep the Node API while preserving the required camel-cased acronym form.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At benchmarks/editor-ssr/src/run.ts, line 4:

<comment>The new local identifier uses the all-caps `URL` acronym (`fileURLToPath`), which breaks the repository naming rule for identifiers. Aliasing this import to `fileUrlToPath` and using that alias would keep the Node API while preserving the required camel-cased acronym form.</comment>

<file context>
@@ -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';
</file context>

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant