Skip to content

Commit 0b0875b

Browse files
authored
Merge branch 'canary' into chore-web
2 parents 416fa97 + e3f75cf commit 0b0875b

File tree

3 files changed

+212
-4
lines changed

3 files changed

+212
-4
lines changed

.changeset/tender-knives-throw.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+
fixed multi-byte characters causing problems during stream reading
Lines changed: 191 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,191 @@
1+
import { Readable } from 'node:stream';
2+
import { describe, expect, it } from 'vitest';
3+
import { readStream } from './read-stream';
4+
5+
describe('readStream', () => {
6+
describe('multi-byte character handling', () => {
7+
it('correctly decodes Japanese characters split across chunks', async () => {
8+
// Create a string with Japanese text that will be split across chunks
9+
const japaneseText = 'これはテストです。スケジュール確認をお願いします。';
10+
const buffer = Buffer.from(japaneseText, 'utf-8');
11+
12+
// Split the buffer at a position that would break a multi-byte character
13+
// "ス" in UTF-8 is E3 82 B9, let's split after E3 82
14+
const splitPosition = buffer.indexOf(Buffer.from('スケジュール')) + 2;
15+
16+
// Create chunks that split the character
17+
const chunk1 = buffer.slice(0, splitPosition);
18+
const chunk2 = buffer.slice(splitPosition);
19+
20+
// Create a mock PipeableStream that emits our chunks
21+
const mockStream = new Readable({
22+
read() {
23+
if (this.chunkIndex === 0) {
24+
this.push(chunk1);
25+
this.chunkIndex++;
26+
} else if (this.chunkIndex === 1) {
27+
this.push(chunk2);
28+
this.chunkIndex++;
29+
} else {
30+
this.push(null); // End the stream
31+
}
32+
},
33+
});
34+
mockStream.chunkIndex = 0;
35+
36+
// Add pipe method to match PipeableStream interface
37+
const pipeableStream = {
38+
pipe: (writable: any) => mockStream.pipe(writable),
39+
};
40+
41+
const result = await readStream(pipeableStream as any);
42+
43+
// The result should match the original text without corruption
44+
expect(result).toBe(japaneseText);
45+
expect(result).not.toContain('\0'); // No null characters
46+
expect(result).toContain('スケジュール'); // Full word intact
47+
});
48+
49+
it('handles Chinese characters split across chunks', async () => {
50+
const chineseText = '这是一个测试。请确认您的日程安排。';
51+
const buffer = Buffer.from(chineseText, 'utf-8');
52+
53+
// Split in the middle of a Chinese character
54+
const splitPosition = buffer.indexOf(Buffer.from('日程')) + 1;
55+
56+
const chunk1 = buffer.slice(0, splitPosition);
57+
const chunk2 = buffer.slice(splitPosition);
58+
59+
const mockStream = new Readable({
60+
read() {
61+
if (this.chunkIndex === 0) {
62+
this.push(chunk1);
63+
this.chunkIndex++;
64+
} else if (this.chunkIndex === 1) {
65+
this.push(chunk2);
66+
this.chunkIndex++;
67+
} else {
68+
this.push(null);
69+
}
70+
},
71+
});
72+
mockStream.chunkIndex = 0;
73+
74+
const pipeableStream = {
75+
pipe: (writable: any) => mockStream.pipe(writable),
76+
};
77+
78+
const result = await readStream(pipeableStream as any);
79+
80+
expect(result).toBe(chineseText);
81+
expect(result).not.toContain('\0');
82+
expect(result).toContain('日程');
83+
});
84+
85+
it('handles emoji characters split across chunks', async () => {
86+
const emojiText = 'Hello 👋 World 🌍 Test 🚀';
87+
const buffer = Buffer.from(emojiText, 'utf-8');
88+
89+
// Emojis are 4-byte UTF-8 sequences, split one
90+
const rocketEmoji = Buffer.from('🚀');
91+
const splitPosition = buffer.indexOf(rocketEmoji) + 2; // Split in middle of rocket emoji
92+
93+
const chunk1 = buffer.slice(0, splitPosition);
94+
const chunk2 = buffer.slice(splitPosition);
95+
96+
const mockStream = new Readable({
97+
read() {
98+
if (this.chunkIndex === 0) {
99+
this.push(chunk1);
100+
this.chunkIndex++;
101+
} else if (this.chunkIndex === 1) {
102+
this.push(chunk2);
103+
this.chunkIndex++;
104+
} else {
105+
this.push(null);
106+
}
107+
},
108+
});
109+
mockStream.chunkIndex = 0;
110+
111+
const pipeableStream = {
112+
pipe: (writable: any) => mockStream.pipe(writable),
113+
};
114+
115+
const result = await readStream(pipeableStream as any);
116+
117+
expect(result).toBe(emojiText);
118+
expect(result).not.toContain('\0');
119+
expect(result).toContain('🚀');
120+
});
121+
122+
it('handles many small chunks with multi-byte characters', async () => {
123+
const mixedText = 'Test テスト 测试 Тест מבחן';
124+
const buffer = Buffer.from(mixedText, 'utf-8');
125+
126+
// Create many small chunks (3 bytes each)
127+
const chunks: Buffer[] = [];
128+
for (let i = 0; i < buffer.length; i += 3) {
129+
chunks.push(buffer.slice(i, Math.min(i + 3, buffer.length)));
130+
}
131+
132+
let currentChunk = 0;
133+
const mockStream = new Readable({
134+
read() {
135+
if (currentChunk < chunks.length) {
136+
this.push(chunks[currentChunk]);
137+
currentChunk++;
138+
} else {
139+
this.push(null);
140+
}
141+
},
142+
});
143+
144+
const pipeableStream = {
145+
pipe: (writable: any) => mockStream.pipe(writable),
146+
};
147+
148+
const result = await readStream(pipeableStream as any);
149+
150+
expect(result).toBe(mixedText);
151+
expect(result).not.toContain('\0');
152+
expect(result).toContain('テスト');
153+
expect(result).toContain('测试');
154+
expect(result).toContain('Тест');
155+
expect(result).toContain('מבחן');
156+
});
157+
});
158+
159+
describe('ReadableStream (pipeTo) path', () => {
160+
it('handles multi-byte characters with ReadableStream', async () => {
161+
const japaneseText = 'バクラクのメールテンプレートでスケジュール確認';
162+
const buffer = Buffer.from(japaneseText, 'utf-8');
163+
164+
// Split at a position that breaks a character
165+
const splitPosition = buffer.indexOf(Buffer.from('スケジュール')) + 2;
166+
const chunk1 = buffer.slice(0, splitPosition);
167+
const chunk2 = buffer.slice(splitPosition);
168+
169+
// Mock a ReactDOMServerReadableStream
170+
const chunks = [chunk1, chunk2];
171+
172+
const mockReadableStream = {
173+
pipeTo: async (writable: WritableStream) => {
174+
const writer = writable.getWriter();
175+
176+
for (const chunk of chunks) {
177+
await writer.write(chunk);
178+
}
179+
180+
await writer.close();
181+
},
182+
};
183+
184+
const result = await readStream(mockReadableStream as any);
185+
186+
expect(result).toBe(japaneseText);
187+
expect(result).not.toContain('\0');
188+
expect(result).toContain('スケジュール');
189+
});
190+
});
191+
});

packages/render/src/node/read-stream.ts

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,28 +4,40 @@ import type {
44
ReactDOMServerReadableStream,
55
} from 'react-dom/server.browser';
66

7-
const decoder = new TextDecoder('utf-8');
8-
97
export const readStream = async (
108
stream: PipeableStream | ReactDOMServerReadableStream,
119
) => {
1210
let result = '';
11+
// Create a single TextDecoder instance to handle streaming properly
12+
// This fixes issues with multi-byte characters (e.g., CJK) being split across chunks
13+
const decoder = new TextDecoder('utf-8');
1314

1415
if ('pipeTo' in stream) {
1516
// means it's a readable stream
1617
const writableStream = new WritableStream({
1718
write(chunk: BufferSource) {
18-
result += decoder.decode(chunk);
19+
// Use stream: true to handle multi-byte characters split across chunks
20+
result += decoder.decode(chunk, { stream: true });
21+
},
22+
close() {
23+
// Flush any remaining bytes
24+
result += decoder.decode();
1925
},
2026
});
2127
await stream.pipeTo(writableStream);
2228
} else {
2329
const writable = new Writable({
2430
write(chunk: BufferSource, _encoding, callback) {
25-
result += decoder.decode(chunk);
31+
// Use stream: true to handle multi-byte characters split across chunks
32+
result += decoder.decode(chunk, { stream: true });
2633

2734
callback();
2835
},
36+
final(callback) {
37+
// Flush any remaining bytes
38+
result += decoder.decode();
39+
callback();
40+
},
2941
});
3042
stream.pipe(writable);
3143

0 commit comments

Comments
 (0)