Skip to content

feat(web): copy HTML snippet instead of full document for snippet components#3065

Draft
gabrielmfern wants to merge 4 commits intocanaryfrom
cursor/html-copy-snippet-document-8f01
Draft

feat(web): copy HTML snippet instead of full document for snippet components#3065
gabrielmfern wants to merge 4 commits intocanaryfrom
cursor/html-copy-snippet-document-8f01

Conversation

@gabrielmfern
Copy link
Member

@gabrielmfern gabrielmfern commented Mar 16, 2026

Summary

Previously, copying the HTML variant of any copy-paste component would copy the entire HTML email document (including <!DOCTYPE>, <html>, <head>, <body>, and the Layout's Container wrapper). This made the HTML tab unhelpful for users who want to embed a component snippet into their own email.

This PR makes the HTML copy behavior aware of whether a component is a snippet or a full document:

  • Snippet components (all existing ones — buttons, headers, articles, etc.): copying HTML now gives just the inner HTML fragment, ready to paste into an existing email template.
  • Document components (future components marked type: 'document'): copying HTML still gives the full HTML email document.

Changes

apps/web/components/structure.ts

  • Added optional type?: 'snippet' | 'document' field to the Component interface. Defaults to 'snippet' for all existing components.

apps/web/src/app/components/get-imported-components-for.tsx

  • Added htmlSnippet?: string to ImportedComponent.code — present for snippet components, absent for document components.
  • Added extractHtmlSnippet helper that strips the DOCTYPE declaration and React hydration markers from a rendered HTML document to produce a clean HTML fragment.
  • For snippet components, renders the element without the <Layout> wrapper to get the raw component HTML, then stores it as code.htmlSnippet.
  • The full code.html (with Layout wrapper) is still generated for all components and is used by the preview iframes and Send feature.

apps/web/src/components/component-code-view.tsx

  • When the HTML tab is selected, uses code.htmlSnippet if present (snippet components), otherwise falls back to the full code.html with the existing height: 100vh stripping (document components).

How it works

The code.html field always contains the full HTML document (used by preview iframes and the Send button). The new code.htmlSnippet contains only the rendered HTML of the component element itself — no <!DOCTYPE>, <html>, <head>, <body>, or Layout container wrappers. This is generated by rendering the component element directly without the Layout wrapper, then stripping the DOCTYPE and React markers from the output.

Slack Thread

Open in Web Open in Cursor 

Summary by cubic

Copying HTML for snippet components now returns only the inner HTML fragment, ready to paste into an existing email. Full-document components still copy the full HTML email.

  • New Features

    • Support type?: 'snippet' | 'document' (defaults to 'snippet').
    • Generate code.htmlSnippet by rendering the component without Layout using renderToStaticMarkup, producing clean HTML with no DOCTYPE or React markers.
    • ComponentCodeView uses htmlSnippet; falls back to full html for documents.
  • Bug Fixes

    • Use a dynamic import for react-dom/server to avoid Next.js App Router build restrictions.
    • If a snippet renders a full document, extract and return only the inner <body> content.

Written for commit 0639ee1. Summary will update on new commits.

…ponents

For all existing components (which are snippets), the HTML copy now
shows just the inner HTML fragment instead of the full email document.
The full document HTML (with Layout wrapper) is kept for preview iframes
and the Send feature.

For future full-document components, marking type as 'document' on the
Component interface will preserve the existing full-HTML-document behavior.

Changes:
- Add optional type field ('snippet' | 'document') to Component interface
- Generate htmlSnippet by rendering element without Layout wrapper and
  stripping the DOCTYPE declaration and React hydration markers
- ComponentCodeView uses htmlSnippet when available (snippet components),
  falls back to full html for document components

Co-authored-by: Gabriel Miranda <gabrielmfern@outlook.com>
@vercel
Copy link
Contributor

vercel bot commented Mar 16, 2026

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

Project Deployment Actions Updated (UTC)
react-email Ready Ready Preview, Comment Mar 16, 2026 8:26pm
react-email-demo Ready Ready Preview, Comment Mar 16, 2026 8:26pm

Request Review

@changeset-bot
Copy link

changeset-bot bot commented Mar 16, 2026

⚠️ No Changeset found

Latest commit: 0639ee1

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes no changesets

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Click here to learn what changesets are, and how to add one.

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

…deQL

Replace targeted React marker regexes with a single pattern that removes
all HTML comments. This comprehensively handles React hydration markers
(<!--hBHc->, <!--/hBHc->, etc.) and prevents the CodeQL 'Incomplete
multi-character sanitization' alert.

Co-authored-by: Gabriel Miranda <gabrielmfern@outlook.com>
Copy link
Contributor

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

Choose a reason for hiding this comment

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

1 issue found across 1 file (changes from recent commits).

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="apps/web/src/app/components/get-imported-components-for.tsx">

<violation number="1" location="apps/web/src/app/components/get-imported-components-for.tsx:80">
P2: This strips required MSO conditional comments from copied snippets, so Outlook-specific email markup such as button spacing hacks is lost.</violation>
</file>

Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.

… alert

Replace the extractHtmlSnippet regex-stripping approach with React's
renderToStaticMarkup, which produces pure static HTML with no DOCTYPE
and no React hydration markers (<!--$-->, etc.).

This eliminates the need to strip HTML comments entirely, removing the
CodeQL 'Incomplete multi-character sanitization' alert at the root.

Co-authored-by: Gabriel Miranda <gabrielmfern@outlook.com>
Copy link
Contributor

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

Choose a reason for hiding this comment

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

No issues found across 3 files

Confidence score: 5/5

  • Automated review surfaced no issues in the provided summaries.
  • No files require special attention.

Copy link
Contributor

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

Choose a reason for hiding this comment

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

1 issue found across 1 file (changes from recent commits).

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="apps/web/src/app/components/get-imported-components-for.tsx">

<violation number="1" location="apps/web/src/app/components/get-imported-components-for.tsx:81">
P1: This helper still returns full-document wrappers for snippet components that export `<Html>/<Body>` themselves.</violation>
</file>

Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.

…ppets

Two fixes:
1. Build failure: Next.js App Router forbids static imports of
   react-dom/server in Server Components. Switch to a dynamic import
   (the same pattern used internally by @react-email/render) so the
   bundler does not flag it.

2. Review: if a snippet component happens to wrap its own output in
   <Html>/<Body>, extract the inner body content so the copied HTML
   stays an embeddable fragment rather than a full document.

Co-authored-by: Gabriel Miranda <gabrielmfern@outlook.com>
Copy link
Contributor

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

Choose a reason for hiding this comment

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

1 issue found across 1 file (changes from recent commits).

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="apps/web/src/app/components/get-imported-components-for.tsx">

<violation number="1" location="apps/web/src/app/components/get-imported-components-for.tsx:89">
P2: Extracting only `body` innerHTML drops attributes/styles set on a component’s own `<Body>` element.</violation>
</file>

Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.

const { renderToStaticMarkup } = await import('react-dom/server');
const html = renderToStaticMarkup(element);
const bodyMatch = html.match(/<body[^>]*>([\s\S]*)<\/body>/i);
return pretty((bodyMatch?.[1] ?? html).trim());
Copy link
Contributor

Choose a reason for hiding this comment

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

P2: Extracting only body innerHTML drops attributes/styles set on a component’s own <Body> element.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/web/src/app/components/get-imported-components-for.tsx, line 89:

<comment>Extracting only `body` innerHTML drops attributes/styles set on a component’s own `<Body>` element.</comment>

<file context>
@@ -71,14 +70,23 @@ const getComponentCodeFrom = (fileContent: string): string => {
+  const { renderToStaticMarkup } = await import('react-dom/server');
+  const html = renderToStaticMarkup(element);
+  const bodyMatch = html.match(/<body[^>]*>([\s\S]*)<\/body>/i);
+  return pretty((bodyMatch?.[1] ?? html).trim());
 };
 
</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.

2 participants