Skip to content

Commit

Permalink
fix(render): Null characters in between chunks (#1709)
Browse files Browse the repository at this point in the history
  • Loading branch information
gabrielmfern committed Oct 14, 2024
1 parent 58d0898 commit 4627675
Show file tree
Hide file tree
Showing 22 changed files with 354 additions and 124 deletions.
5 changes: 5 additions & 0 deletions .changeset/tall-cameras-cry.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@react-email/render": patch
---

Fix null characters in between chunks when using high-density characters
2 changes: 1 addition & 1 deletion packages/react-email/next-env.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,4 @@
/// <reference types="next/image-types/global" />

// NOTE: This file should not be edited
// see https://nextjs.org/docs/basic-features/typescript for more information.
// see https://nextjs.org/docs/app/building-your-application/configuring/typescript for more information.
2 changes: 2 additions & 0 deletions packages/render/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,8 @@
"react-dom": "^18.0 || ^19.0 || ^19.0.0-rc"
},
"devDependencies": {
"@types/react": "npm:types-react@19.0.0-rc.1",
"@types/react-dom": "npm:types-react-dom@19.0.0-rc.1",
"@edge-runtime/vm": "3.1.8",
"@types/html-to-text": "9.0.4",
"@types/js-beautify": "1.14.3",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html

exports[`renderAsync on the browser environment > should handle characters with a higher byte count gracefully 1`] = `"<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd"><!--$--><p>Test Normal 情報Ⅰコース担当者様</p><p>平素よりお世話になっております。 情報Ⅰサポートチームです。 情報Ⅰ本講座につきまして仕様変更のためご連絡させていただきました。<!-- --> </p>今後ジクタス上の講座につきましては、8回分の授業をひとまとまりとしてパート分けされた状態で公開されてまいります。<p>伴いまして、画面上の表示に一部変更がありますのでお知らせいたします。 ご登録いただいた各生徒の受講ペースに応じて公開パートが増えてまいります。 具体的な表示イメージは下記ページをご確認ください。</p><p>2024年8月末時点で情報Ⅰ本講座を受講していたアカウントにつきましては、 今まで公開していた第1~9回までの講座一覧に加え、パート分けされた講座が追加で公開されてまりいます。 第1~9回の表示はそのまま引き継がれますが、Webドリルの進捗表示はパート分けの講座には適用されません。ご了承くださいませ。 仕様変更に伴い、現在教室長もしくは講師に生徒アカウントログインをお願いしておりますが、今後は生徒自身にてログインをしていただいて問題ございません。</p><p>また、生徒が自宅等にてログインし復習に取り組むことも問題ございませんので、教室にてご指示いただければと存じます。 (実際にご指示いただくかは教室判断に委ねさせていただきます。)</p><p>受講ペースが変更したり、増コマが発生したりする場合などは、公開ペースを本部にて調整いたしますので、下記より必ずご連絡くださいませ。 また本件に関して不明な点がございましたら、同フォームよりお問い合わせください。 以上、引き続きよろしくお願い申し上げます。 情報Ⅰサポートチーム</p><!--/$-->"`;
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html

exports[`render on the browser environment > should handle characters with a higher byte count gracefully 1`] = `"<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd"><!--$--><p>Test Normal 情報Ⅰコース担当者様</p><p>平素よりお世話になっております。 情報Ⅰサポートチームです。 情報Ⅰ本講座につきまして仕様変更のためご連絡させていただきました。<!-- --> </p>今後ジクタス上の講座につきましては、8回分の授業をひとまとまりとしてパート分けされた状態で公開されてまいります。<p>伴いまして、画面上の表示に一部変更がありますのでお知らせいたします。 ご登録いただいた各生徒の受講ペースに応じて公開パートが増えてまいります。 具体的な表示イメージは下記ページをご確認ください。</p><p>2024年8月末時点で情報Ⅰ本講座を受講していたアカウントにつきましては、 今まで公開していた第1~9回までの講座一覧に加え、パート分けされた講座が追加で公開されてまりいます。 第1~9回の表示はそのまま引き継がれますが、Webドリルの進捗表示はパート分けの講座には適用されません。ご了承くださいませ。 仕様変更に伴い、現在教室長もしくは講師に生徒アカウントログインをお願いしておりますが、今後は生徒自身にてログインをしていただいて問題ございません。</p><p>また、生徒が自宅等にてログインし復習に取り組むことも問題ございませんので、教室にてご指示いただければと存じます。 (実際にご指示いただくかは教室判断に委ねさせていただきます。)</p><p>受講ペースが変更したり、増コマが発生したりする場合などは、公開ペースを本部にて調整いたしますので、下記より必ずご連絡くださいませ。 また本件に関して不明な点がございましたら、同フォームよりお問い合わせください。 以上、引き続きよろしくお願い申し上げます。 情報Ⅰサポートチーム</p><!--/$-->"`;
44 changes: 44 additions & 0 deletions packages/render/src/browser/read-stream.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import {
PipeableStream,
ReactDOMServerReadableStream,
} from "react-dom/server.browser";

const decoder = new TextDecoder("utf-8");

export const readStream = async (
stream: PipeableStream | ReactDOMServerReadableStream,
) => {
const chunks: Uint8Array[] = [];

if ("pipeTo" in stream) {
// means it's a readable stream
const writableStream = new WritableStream({
write(chunk: Uint8Array) {
chunks.push(chunk);
},
});
await stream.pipeTo(writableStream);
} else {
throw new Error(
"For some reason, the Node version of `react-dom/server` has been imported instead of the browser one.",
{
cause: {
stream,
},
},
);
}

let length = 0;
chunks.forEach((item) => {
length += item.length;
});
const mergedChunks = new Uint8Array(length);
let offset = 0;
chunks.forEach((item) => {
mergedChunks.set(item, offset);
offset += item.length;
});

return decoder.decode(mergedChunks);
};
36 changes: 36 additions & 0 deletions packages/render/src/browser/render-async-web.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,42 @@ describe("renderAsync on the browser environment", () => {
);
});

// This is a test to ensure we have no regressions for https://github.com/resend/react-email/issues/1667
it("should handle characters with a higher byte count gracefully", async () => {
const actualOutput = await renderAsync(
<>
<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 PlainText", async () => {
const actualOutput = await renderAsync(<Template firstName="Jim" />, {
plainText: true,
Expand Down
36 changes: 2 additions & 34 deletions packages/render/src/browser/render-async.tsx
Original file line number Diff line number Diff line change
@@ -1,41 +1,9 @@
import { convert } from "html-to-text";
import type {
PipeableStream,
ReactDOMServerReadableStream,
} from "react-dom/server";
import { Suspense } from "react";
import { pretty } from "../shared/utils/pretty";
import { plainTextSelectors } from "../shared/plain-text-selectors";
import type { Options } from "../shared/options";
import { Suspense } from "react";

const decoder = new TextDecoder("utf-8");

const readStream = async (
stream: PipeableStream | ReactDOMServerReadableStream,
) => {
let result = "";

if ("pipeTo" in stream) {
// means it's a readable stream
const writableStream = new WritableStream({
write(chunk: BufferSource) {
result += decoder.decode(chunk);
},
});
await stream.pipeTo(writableStream);
} else {
throw new Error(
"For some reason, the Node version of `react-dom/server` has been imported instead of the browser one.",
{
cause: {
stream,
},
},
);
}

return result;
};
import { readStream } from "./read-stream";

export const renderAsync = async (
element: React.ReactElement,
Expand Down
36 changes: 36 additions & 0 deletions packages/render/src/browser/render-web.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,42 @@ describe("render on the browser environment", () => {
);
});

// This is a test to ensure we have no regressions for https://github.com/resend/react-email/issues/1667
it("should handle 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" />);

Expand Down
21 changes: 16 additions & 5 deletions packages/render/src/browser/render.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,23 +3,23 @@ import type {
PipeableStream,
ReactDOMServerReadableStream,
} from "react-dom/server";
import { Suspense } from "react";
import { pretty } from "../shared/utils/pretty";
import { plainTextSelectors } from "../shared/plain-text-selectors";
import type { Options } from "../shared/options";
import { Suspense } from "react";

const decoder = new TextDecoder("utf-8");

const readStream = async (
stream: PipeableStream | ReactDOMServerReadableStream,
) => {
let result = "";
const chunks: Uint8Array[] = [];

if ("pipeTo" in stream) {
// means it's a readable stream
const writableStream = new WritableStream({
write(chunk: BufferSource) {
result += decoder.decode(chunk);
write(chunk: Uint8Array) {
chunks.push(chunk);
},
});
await stream.pipeTo(writableStream);
Expand All @@ -34,7 +34,18 @@ const readStream = async (
);
}

return result;
let length = 0;
chunks.forEach((item) => {
length += item.length;
});
const mergedChunks = new Uint8Array(length);
let offset = 0;
chunks.forEach((item) => {
mergedChunks.set(item, offset);
offset += item.length;
});

return decoder.decode(mergedChunks);
};

export const render = async (
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html

exports[`renderAsync on the edge > should handle characters with a higher byte count gracefully in React 18 1`] = `"<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd"><!--$--><p>Test Normal 情報Ⅰコース担当者様</p><p>平素よりお世話になっております。 情報Ⅰサポートチームです。 情報Ⅰ本講座につきまして仕様変更のためご連絡させていただきました。<!-- --> </p>今後ジクタス上の講座につきましては、8回分の授業をひとまとまりとしてパート分けされた状態で公開されてまいります。<p>伴いまして、画面上の表示に一部変更がありますのでお知らせいたします。 ご登録いただいた各生徒の受講ペースに応じて公開パートが増えてまいります。 具体的な表示イメージは下記ページをご確認ください。</p><p>2024年8月末時点で情報Ⅰ本講座を受講していたアカウントにつきましては、 今まで公開していた第1~9回までの講座一覧に加え、パート分けされた講座が追加で公開されてまりいます。 第1~9回の表示はそのまま引き継がれますが、Webドリルの進捗表示はパート分けの講座には適用されません。ご了承くださいませ。 仕様変更に伴い、現在教室長もしくは講師に生徒アカウントログインをお願いしておりますが、今後は生徒自身にてログインをしていただいて問題ございません。</p><p>また、生徒が自宅等にてログインし復習に取り組むことも問題ございませんので、教室にてご指示いただければと存じます。 (実際にご指示いただくかは教室判断に委ねさせていただきます。)</p><p>受講ペースが変更したり、増コマが発生したりする場合などは、公開ペースを本部にて調整いたしますので、下記より必ずご連絡くださいませ。 また本件に関して不明な点がございましたら、同フォームよりお問い合わせください。 以上、引き続きよろしくお願い申し上げます。 情報Ⅰサポートチーム</p><!--/$-->"`;
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html

exports[`renderAsync on node environments > should handle characters with a higher byte count gracefully in React 18 1`] = `"<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd"><!--$--><p>Test Normal 情報Ⅰコース担当者様</p><p>平素よりお世話になっております。 情報Ⅰサポートチームです。 情報Ⅰ本講座につきまして仕様変更のためご連絡させていただきました。<!-- --> </p>今後ジクタス上の講座につきましては、8回分の授業をひとまとまりとしてパート分けされた状態で公開されてまいります。<p>伴いまして、画面上の表示に一部変更がありますのでお知らせいたします。 ご登録いただいた各生徒の受講ペースに応じて公開パートが増えてまいります。 具体的な表示イメージは下記ページをご確認ください。</p><p>2024年8月末時点で情報Ⅰ本講座を受講していたアカウントにつきましては、 今まで公開していた第1~9回までの講座一覧に加え、パート分けされた講座が追加で公開されてまりいます。 第1~9回の表示はそのまま引き継がれますが、Webドリルの進捗表示はパート分けの講座には適用されません。ご了承くださいませ。 仕様変更に伴い、現在教室長もしくは講師に生徒アカウントログインをお願いしておりますが、今後は生徒自身にてログインをしていただいて問題ございません。</p><p>また、生徒が自宅等にてログインし復習に取り組むことも問題ございませんので、教室にてご指示いただければと存じます。 (実際にご指示いただくかは教室判断に委ねさせていただきます。)</p><p>受講ペースが変更したり、増コマが発生したりする場合などは、公開ペースを本部にて調整いたしますので、下記より必ずご連絡くださいませ。 また本件に関して不明な点がございましたら、同フォームよりお問い合わせください。 以上、引き続きよろしくお願い申し上げます。 情報Ⅰサポートチーム</p><!--/$-->"`;

exports[`renderAsync on node environments > that it properly waits for Suepsense boundaries to resolve before resolving 1`] = `
"<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd"><!--$--><!--$--><div><!doctype html>
<html>
Expand Down
Loading

0 comments on commit 4627675

Please sign in to comment.