Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 12 additions & 1 deletion packages/render/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,16 @@
],
"exports": {
".": {
"react-server": {
"import": {
"types": "./dist/react-server/index.d.mts",
"default": "./dist/react-server/index.mjs"
},
"require": {
"types": "./dist/react-server/index.d.ts",
"default": "./dist/react-server/index.js"
}
},
"workerd": {
"import": {
"types": "./dist/edge/index.d.mts",
Expand Down Expand Up @@ -115,7 +125,8 @@
},
"dependencies": {
"html-to-text": "^9.0.5",
"prettier": "^3.5.3"
"prettier": "^3.5.3",
"react-markup": "0.0.0-experimental-93fc5740-20251113"
},
"peerDependencies": {
"react": "^18.0 || ^19.0 || ^19.0.0-rc",
Expand Down

Large diffs are not rendered by default.

4 changes: 4 additions & 0 deletions packages/render/src/react-server/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export * from '../shared/options';
export * from '../shared/utils/pretty';
export * from '../shared/utils/to-plain-text';
export * from './render';
3 changes: 3 additions & 0 deletions packages/render/src/react-server/react-markup.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
declare module 'react-markup' {
function experimental_renderToHTML(react: React.ReactNode): Promise<string>;
}
167 changes: 167 additions & 0 deletions packages/render/src/react-server/render.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
/**
* @vitest-environment jsdom
*/

import { createElement, use } from 'react';
import { Preview } from '../shared/utils/testing/preview';
import { Template } from '../shared/utils/testing/template';
import { render } from './render';

type Import = typeof import('react-dom/server') & {
default: typeof import('react-dom/server');
};

describe('render on react-server using react-markup', () => {
it('converts a React component into HTML with Next 14 error stubs', async () => {
vi.mock('react-dom/server', async (_importOriginal) => {
const ReactDOMServerBrowser = await vi.importActual<Import>(
'react-dom/server.browser',
);
const ERROR_MESSAGE =
'Internal Error: do not use legacy react-dom/server APIs. If you encountered this error, please open an issue on the Next.js repo.';

return {
...ReactDOMServerBrowser,
default: {
...ReactDOMServerBrowser,
renderToString() {
throw new Error(ERROR_MESSAGE);
},
renderToStaticMarkup() {
throw new Error(ERROR_MESSAGE);
},
},
renderToString() {
throw new Error(ERROR_MESSAGE);
},
renderToStaticMarkup() {
throw new Error(ERROR_MESSAGE);
},
};
});

const actualOutput = await render(<Template firstName="Jim" />);

expect(actualOutput).toMatchInlineSnapshot(
`"<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd"><link rel="preload" as="image" href="img/test.png"/><h1>Welcome, Jim!</h1><img alt="test" src="img/test.png"/><p>Thanks for trying our product. We&#x27;re thrilled to have you on board!</p>"`,
);
});

// This is a test to ensure we have no regressions for https://github.com/resend/react-email/issues/1667
it('handles characters with a higher byte count gracefully', async () => {
const actualOutput = await render(
<>
<p>Test Normal 情報Ⅰコース担当者様</p>
<p>
平素よりお世話になっております。 情報Ⅰサポートチームです。
情報Ⅰ本講座につきまして仕様変更のためご連絡させていただきました。{' '}
</p>
今後ジクタス上の講座につきましては、8回分の授業をひとまとまりとしてパート分けされた状態で公開されてまいります。
<p>
伴いまして、画面上の表示に一部変更がありますのでお知らせいたします。
ご登録いただいた各生徒の受講ペースに応じて公開パートが増えてまいります。
具体的な表示イメージは下記ページをご確認ください。
</p>
<p>
2024年8月末時点で情報Ⅰ本講座を受講していたアカウントにつきましては、
今まで公開していた第1~9回までの講座一覧に加え、パート分けされた講座が追加で公開されてまりいます。
第1~9回の表示はそのまま引き継がれますが、Webドリルの進捗表示はパート分けの講座には適用されません。ご了承くださいませ。
仕様変更に伴い、現在教室長もしくは講師に生徒アカウントログインをお願いしておりますが、今後は生徒自身にてログインをしていただいて問題ございません。
</p>
<p>
また、生徒が自宅等にてログインし復習に取り組むことも問題ございませんので、教室にてご指示いただければと存じます。
(実際にご指示いただくかは教室判断に委ねさせていただきます。)
</p>
<p>
受講ペースが変更したり、増コマが発生したりする場合などは、公開ペースを本部にて調整いたしますので、下記より必ずご連絡くださいませ。
また本件に関して不明な点がございましたら、同フォームよりお問い合わせください。
以上、引き続きよろしくお願い申し上げます。 情報Ⅰサポートチーム
</p>
</>,
);

expect(actualOutput).toMatchSnapshot();
});

it('converts a React component into HTML', async () => {
const actualOutput = await render(<Template firstName="Jim" />);

expect(actualOutput).toMatchInlineSnapshot(
`"<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd"><link rel="preload" as="image" href="img/test.png"/><h1>Welcome, Jim!</h1><img alt="test" src="img/test.png"/><p>Thanks for trying our product. We&#x27;re thrilled to have you on board!</p>"`,
);
});

it('converts a React component into PlainText', async () => {
const actualOutput = await render(<Template firstName="Jim" />, {
plainText: true,
});

expect(actualOutput).toMatchInlineSnapshot(`
"WELCOME, JIM!

Thanks for trying our product. We're thrilled to have you on board!"
`);
});

it('converts to plain text and removes reserved ID', async () => {
const actualOutput = await render(<Preview />, {
plainText: true,
});

expect(actualOutput).toMatchInlineSnapshot(
`"THIS SHOULD BE RENDERED IN PLAIN TEXT"`,
);
});

it('waits for Suspense boundaries to ending before resolving', async () => {
const htmlPromise = fetch('https://example.com').then((res) => res.text());
const EmailTemplate = () => {
const html = use(htmlPromise);

return <div dangerouslySetInnerHTML={{ __html: html }} />;
};

const renderedTemplate = await render(<EmailTemplate />);

expect(renderedTemplate).toMatchSnapshot();
});

// See https://github.com/resend/react-email/issues/2263
it('throws error of rendering an invalid element instead of writing them into a template tag', async () => {
// @ts-expect-error we know this is not correct, and we want to test the error handling for it
const element = createElement(undefined);
await expect(render(element)).rejects.toThrowErrorMatchingSnapshot();
});

/**
* Create a large email that would trigger React's streaming optimization
* if progressiveChunkSize wasn't set to Infinity
*
* @see https://github.com/resend/react-email/issues/2353
*/
it('renders large emails without hydration markers', async () => {
const LargeEmailTemplate = () => {
const largeContent = Array(100)
.fill(null)
.map((_, i) => (
<p key={i}>
This is paragraph {i} with some content to make the email larger.
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do
eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim
ad minim veniam, quis nostrud exercitation ullamco laboris.
</p>
));

return (
<div>
<h1>Large Email Test</h1>
{largeContent}
</div>
);
};

const actualOutput = await render(<LargeEmailTemplate />);

expect(actualOutput).toMatchSnapshot();
});
});
25 changes: 25 additions & 0 deletions packages/render/src/react-server/render.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { Suspense } from 'react';
import { experimental_renderToHTML } from 'react-markup';
import { pretty, toPlainText } from '../node';
import type { Options } from '../shared/options';

export const render = async (node: React.ReactNode, options?: Options) => {
const suspendedElement = <Suspense>{node}</Suspense>;

const html = await experimental_renderToHTML(suspendedElement);

if (options?.plainText) {
return toPlainText(html, options.htmlToTextOptions);
}

const doctype =
'<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">';

const document = `${doctype}${html.replace(/<!DOCTYPE.*?>/, '')}`;

if (options?.pretty) {
return pretty(document);
}

return document;
};
6 changes: 6 additions & 0 deletions packages/render/tsdown.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,4 +19,10 @@ export default defineConfig([
outDir: './dist/edge',
format: ['cjs', 'esm'],
},
{
dts: true,
entry: ['./src/react-server/index.ts'],
outDir: './dist/react-server',
format: ['cjs', 'esm'],
},
]);
12 changes: 12 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading