Skip to content

Commit 115088f

Browse files
committed
fix(render): Null characters in between chunks (#1709)
1 parent 6c9dd4c commit 115088f

22 files changed

+354
-124
lines changed

.changeset/tall-cameras-cry.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@react-email/render": patch
3+
---
4+
5+
Fix null characters in between chunks when using high-density characters

packages/react-email/next-env.d.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,4 @@
22
/// <reference types="next/image-types/global" />
33

44
// NOTE: This file should not be edited
5-
// see https://nextjs.org/docs/basic-features/typescript for more information.
5+
// see https://nextjs.org/docs/app/building-your-application/configuring/typescript for more information.

packages/render/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,8 @@
9494
"react-dom": "^18.0 || ^19.0 || ^19.0.0-rc"
9595
},
9696
"devDependencies": {
97+
"@types/react": "npm:[email protected]",
98+
"@types/react-dom": "npm:[email protected]",
9799
"@edge-runtime/vm": "3.1.8",
98100
"@types/html-to-text": "9.0.4",
99101
"@types/js-beautify": "1.14.3",
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
2+
3+
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><!--/$-->"`;
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
2+
3+
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><!--/$-->"`;
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import {
2+
PipeableStream,
3+
ReactDOMServerReadableStream,
4+
} from "react-dom/server.browser";
5+
6+
const decoder = new TextDecoder("utf-8");
7+
8+
export const readStream = async (
9+
stream: PipeableStream | ReactDOMServerReadableStream,
10+
) => {
11+
const chunks: Uint8Array[] = [];
12+
13+
if ("pipeTo" in stream) {
14+
// means it's a readable stream
15+
const writableStream = new WritableStream({
16+
write(chunk: Uint8Array) {
17+
chunks.push(chunk);
18+
},
19+
});
20+
await stream.pipeTo(writableStream);
21+
} else {
22+
throw new Error(
23+
"For some reason, the Node version of `react-dom/server` has been imported instead of the browser one.",
24+
{
25+
cause: {
26+
stream,
27+
},
28+
},
29+
);
30+
}
31+
32+
let length = 0;
33+
chunks.forEach((item) => {
34+
length += item.length;
35+
});
36+
const mergedChunks = new Uint8Array(length);
37+
let offset = 0;
38+
chunks.forEach((item) => {
39+
mergedChunks.set(item, offset);
40+
offset += item.length;
41+
});
42+
43+
return decoder.decode(mergedChunks);
44+
};

packages/render/src/browser/render-async-web.spec.tsx

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,42 @@ describe("renderAsync on the browser environment", () => {
6565
);
6666
});
6767

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

packages/render/src/browser/render-async.tsx

Lines changed: 2 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,41 +1,9 @@
11
import { convert } from "html-to-text";
2-
import type {
3-
PipeableStream,
4-
ReactDOMServerReadableStream,
5-
} from "react-dom/server";
2+
import { Suspense } from "react";
63
import { pretty } from "../shared/utils/pretty";
74
import { plainTextSelectors } from "../shared/plain-text-selectors";
85
import type { Options } from "../shared/options";
9-
import { Suspense } from "react";
10-
11-
const decoder = new TextDecoder("utf-8");
12-
13-
const readStream = async (
14-
stream: PipeableStream | ReactDOMServerReadableStream,
15-
) => {
16-
let result = "";
17-
18-
if ("pipeTo" in stream) {
19-
// means it's a readable stream
20-
const writableStream = new WritableStream({
21-
write(chunk: BufferSource) {
22-
result += decoder.decode(chunk);
23-
},
24-
});
25-
await stream.pipeTo(writableStream);
26-
} else {
27-
throw new Error(
28-
"For some reason, the Node version of `react-dom/server` has been imported instead of the browser one.",
29-
{
30-
cause: {
31-
stream,
32-
},
33-
},
34-
);
35-
}
36-
37-
return result;
38-
};
6+
import { readStream } from "./read-stream";
397

408
export const renderAsync = async (
419
element: React.ReactElement,

packages/render/src/browser/render-web.spec.tsx

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,42 @@ describe("render on the browser environment", () => {
5757
);
5858
});
5959

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

packages/render/src/browser/render.tsx

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,23 +3,23 @@ import type {
33
PipeableStream,
44
ReactDOMServerReadableStream,
55
} from "react-dom/server";
6+
import { Suspense } from "react";
67
import { pretty } from "../shared/utils/pretty";
78
import { plainTextSelectors } from "../shared/plain-text-selectors";
89
import type { Options } from "../shared/options";
9-
import { Suspense } from "react";
1010

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

1313
const readStream = async (
1414
stream: PipeableStream | ReactDOMServerReadableStream,
1515
) => {
16-
let result = "";
16+
const chunks: Uint8Array[] = [];
1717

1818
if ("pipeTo" in stream) {
1919
// means it's a readable stream
2020
const writableStream = new WritableStream({
21-
write(chunk: BufferSource) {
22-
result += decoder.decode(chunk);
21+
write(chunk: Uint8Array) {
22+
chunks.push(chunk);
2323
},
2424
});
2525
await stream.pipeTo(writableStream);
@@ -34,7 +34,18 @@ const readStream = async (
3434
);
3535
}
3636

37-
return result;
37+
let length = 0;
38+
chunks.forEach((item) => {
39+
length += item.length;
40+
});
41+
const mergedChunks = new Uint8Array(length);
42+
let offset = 0;
43+
chunks.forEach((item) => {
44+
mergedChunks.set(item, offset);
45+
offset += item.length;
46+
});
47+
48+
return decoder.decode(mergedChunks);
3849
};
3950

4051
export const render = async (
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
2+
3+
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><!--/$-->"`;

packages/render/src/node/__snapshots__/render-async-node.spec.tsx.snap

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
22

3+
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><!--/$-->"`;
4+
35
exports[`renderAsync on node environments > that it properly waits for Suepsense boundaries to resolve before resolving 1`] = `
46
"<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd"><!--$--><!--$--><div><!doctype html>
57
<html>

0 commit comments

Comments
 (0)