Skip to content
Open
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
1 change: 1 addition & 0 deletions packages/render/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,7 @@
"devDependencies": {
"@edge-runtime/vm": "5.0.0",
"@types/html-to-text": "9.0.4",
"@types/node": "^25.0.3",
"@types/react": "npm:[email protected]",
"@types/react-dom": "npm:[email protected]",
"jsdom": "26.1.0",
Expand Down
15 changes: 2 additions & 13 deletions packages/render/src/browser/render.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { Suspense } from 'react';
import { pretty, toPlainText } from '../node';
import type { Options } from '../shared/options';
import { readStream } from '../shared/read-stream.browser';
import { renderToReadableStream } from '../shared/render-to-readable-stream';

export const render = async (node: React.ReactNode, options?: Options) => {
const suspendedElement = <Suspense>{node}</Suspense>;
Expand All @@ -12,18 +12,7 @@ export const render = async (node: React.ReactNode, options?: Options) => {
return m;
});

const html = await new Promise<string>((resolve, reject) => {
reactDOMServer
.renderToReadableStream(suspendedElement, {
onError(error: unknown) {
reject(error);
},
progressiveChunkSize: Number.POSITIVE_INFINITY,
})
.then(readStream)
.then(resolve)
.catch(reject);
});
const html = await renderToReadableStream(suspendedElement, reactDOMServer);

if (options?.plainText) {
return toPlainText(html, options.htmlToTextOptions);
Expand Down
15 changes: 2 additions & 13 deletions packages/render/src/edge/render.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { Suspense } from 'react';
import { pretty } from '../node';
import type { Options } from '../shared/options';
import { readStream } from '../shared/read-stream.browser';
import { renderToReadableStream } from '../shared/render-to-readable-stream';
import { toPlainText } from '../shared/utils/to-plain-text';
import { importReactDom } from './import-react-dom';

Expand All @@ -18,18 +18,7 @@ export const render = async (
return m;
});

const html = await new Promise<string>((resolve, reject) => {
reactDOMServer
.renderToReadableStream(suspendedElement, {
onError(error: unknown) {
reject(error);
},
progressiveChunkSize: Number.POSITIVE_INFINITY,
})
.then(readStream)
.then(resolve)
.catch(reject);
});
const html = await renderToReadableStream(suspendedElement, reactDOMServer);

if (options?.plainText) {
return toPlainText(html, options.htmlToTextOptions);
Expand Down
305 changes: 135 additions & 170 deletions packages/render/src/node/read-stream.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,189 +3,154 @@ import { describe, expect, it } from 'vitest';
import { readStream } from './read-stream';

describe('readStream', () => {
describe('multi-byte character handling', () => {
it('correctly decodes Japanese characters split across chunks', async () => {
// Create a string with Japanese text that will be split across chunks
const japaneseText = 'これはテストです。スケジュール確認をお願いします。';
const buffer = Buffer.from(japaneseText, 'utf-8');

// Split the buffer at a position that would break a multi-byte character
// "ス" in UTF-8 is E3 82 B9, let's split after E3 82
const splitPosition = buffer.indexOf(Buffer.from('スケジュール')) + 2;

// Create chunks that split the character
const chunk1 = buffer.slice(0, splitPosition);
const chunk2 = buffer.slice(splitPosition);

// Create a mock PipeableStream that emits our chunks
const mockStream = new Readable({
read() {
if (this.chunkIndex === 0) {
this.push(chunk1);
this.chunkIndex++;
} else if (this.chunkIndex === 1) {
this.push(chunk2);
this.chunkIndex++;
} else {
this.push(null); // End the stream
}
},
});
mockStream.chunkIndex = 0;

// Add pipe method to match PipeableStream interface
const pipeableStream = {
pipe: (writable: any) => mockStream.pipe(writable),
};

const result = await readStream(pipeableStream as any);

// The result should match the original text without corruption
expect(result).toBe(japaneseText);
expect(result).not.toContain('\0'); // No null characters
expect(result).toContain('スケジュール'); // Full word intact
it('correctly decodes Japanese characters split across chunks', async () => {
// Create a string with Japanese text that will be split across chunks
const japaneseText = 'これはテストです。スケジュール確認をお願いします。';
const buffer = Buffer.from(japaneseText, 'utf-8');

// Split the buffer at a position that would break a multi-byte character
// "ス" in UTF-8 is E3 82 B9, let's split after E3 82
const splitPosition = buffer.indexOf(Buffer.from('スケジュール')) + 2;

// Create chunks that split the character
const chunk1 = buffer.subarray(0, splitPosition);
const chunk2 = buffer.subarray(splitPosition);

// Create a mock stream that emits our chunks
let chunkIndex = 0;
const mockStream = new Readable({
read() {
if (chunkIndex === 0) {
this.push(chunk1);
chunkIndex++;
} else if (chunkIndex === 1) {
this.push(chunk2);
chunkIndex++;
} else {
this.push(null); // End the stream
}
},
});

it('handles Chinese characters split across chunks', async () => {
const chineseText = '这是一个测试。请确认您的日程安排。';
const buffer = Buffer.from(chineseText, 'utf-8');

// Split in the middle of a Chinese character
const splitPosition = buffer.indexOf(Buffer.from('日程')) + 1;

const chunk1 = buffer.slice(0, splitPosition);
const chunk2 = buffer.slice(splitPosition);

const mockStream = new Readable({
read() {
if (this.chunkIndex === 0) {
this.push(chunk1);
this.chunkIndex++;
} else if (this.chunkIndex === 1) {
this.push(chunk2);
this.chunkIndex++;
} else {
this.push(null);
}
},
});
mockStream.chunkIndex = 0;

const pipeableStream = {
pipe: (writable: any) => mockStream.pipe(writable),
};

const result = await readStream(pipeableStream as any);

expect(result).toBe(chineseText);
expect(result).not.toContain('\0');
expect(result).toContain('日程');
});
// Add pipe method to match stream interface
const pipeableStream = {
pipe: (writable: any) => mockStream.pipe(writable),
};

it('handles emoji characters split across chunks', async () => {
const emojiText = 'Hello 👋 World 🌍 Test 🚀';
const buffer = Buffer.from(emojiText, 'utf-8');

// Emojis are 4-byte UTF-8 sequences, split one
const rocketEmoji = Buffer.from('🚀');
const splitPosition = buffer.indexOf(rocketEmoji) + 2; // Split in middle of rocket emoji

const chunk1 = buffer.slice(0, splitPosition);
const chunk2 = buffer.slice(splitPosition);

const mockStream = new Readable({
read() {
if (this.chunkIndex === 0) {
this.push(chunk1);
this.chunkIndex++;
} else if (this.chunkIndex === 1) {
this.push(chunk2);
this.chunkIndex++;
} else {
this.push(null);
}
},
});
mockStream.chunkIndex = 0;

const pipeableStream = {
pipe: (writable: any) => mockStream.pipe(writable),
};

const result = await readStream(pipeableStream as any);

expect(result).toBe(emojiText);
expect(result).not.toContain('\0');
expect(result).toContain('🚀');
});
const result = await readStream(pipeableStream as any);

it('handles many small chunks with multi-byte characters', async () => {
const mixedText = 'Test テスト 测试 Тест מבחן';
const buffer = Buffer.from(mixedText, 'utf-8');

// Create many small chunks (3 bytes each)
const chunks: Buffer[] = [];
for (let i = 0; i < buffer.length; i += 3) {
chunks.push(buffer.slice(i, Math.min(i + 3, buffer.length)));
}

let currentChunk = 0;
const mockStream = new Readable({
read() {
if (currentChunk < chunks.length) {
this.push(chunks[currentChunk]);
currentChunk++;
} else {
this.push(null);
}
},
});

const pipeableStream = {
pipe: (writable: any) => mockStream.pipe(writable),
};

const result = await readStream(pipeableStream as any);

expect(result).toBe(mixedText);
expect(result).not.toContain('\0');
expect(result).toContain('テスト');
expect(result).toContain('测试');
expect(result).toContain('Тест');
expect(result).toContain('מבחן');
});
// The result should match the original text without corruption
expect(result).toBe(japaneseText);
expect(result).not.toContain('\0'); // No null characters
expect(result).toContain('スケジュール'); // Full word intact
});

describe('ReadableStream (pipeTo) path', () => {
it('handles multi-byte characters with ReadableStream', async () => {
const japaneseText = 'バクラクのメールテンプレートでスケジュール確認';
const buffer = Buffer.from(japaneseText, 'utf-8');
it('handles Chinese characters split across chunks', async () => {
const chineseText = '这是一个测试。请确认您的日程安排。';
const buffer = Buffer.from(chineseText, 'utf-8');

// Split in the middle of a Chinese character
const splitPosition = buffer.indexOf(Buffer.from('日程')) + 1;

const chunk1 = buffer.subarray(0, splitPosition);
const chunk2 = buffer.subarray(splitPosition);

let chunkIndex = 0;
const mockStream = new Readable({
read() {
if (chunkIndex === 0) {
this.push(chunk1);
chunkIndex++;
} else if (chunkIndex === 1) {
this.push(chunk2);
chunkIndex++;
} else {
this.push(null);
}
},
});

// Split at a position that breaks a character
const splitPosition = buffer.indexOf(Buffer.from('スケジュール')) + 2;
const chunk1 = buffer.slice(0, splitPosition);
const chunk2 = buffer.slice(splitPosition);
const pipeableStream = {
pipe: (writable: any) => mockStream.pipe(writable),
};

// Mock a ReactDOMServerReadableStream
const chunks = [chunk1, chunk2];
const result = await readStream(pipeableStream as any);

const mockReadableStream = {
pipeTo: async (writable: WritableStream) => {
const writer = writable.getWriter();
expect(result).toBe(chineseText);
expect(result).not.toContain('\0');
expect(result).toContain('日程');
});

for (const chunk of chunks) {
await writer.write(chunk);
}
it('handles emoji characters split across chunks', async () => {
const emojiText = 'Hello 👋 World 🌍 Test 🚀';
const buffer = Buffer.from(emojiText, 'utf-8');

// Emojis are 4-byte UTF-8 sequences, split one
const rocketEmoji = Buffer.from('🚀');
const splitPosition = buffer.indexOf(rocketEmoji) + 2; // Split in middle of rocket emoji

const chunk1 = buffer.subarray(0, splitPosition);
const chunk2 = buffer.subarray(splitPosition);

let chunkIndex = 0;
const mockStream = new Readable({
read() {
if (chunkIndex === 0) {
this.push(chunk1);
chunkIndex++;
} else if (chunkIndex === 1) {
this.push(chunk2);
chunkIndex++;
} else {
this.push(null);
}
},
});

await writer.close();
},
};
const pipeableStream = {
pipe: (writable: any) => mockStream.pipe(writable),
};

const result = await readStream(mockReadableStream as any);
const result = await readStream(pipeableStream as any);

expect(result).toBe(japaneseText);
expect(result).not.toContain('\0');
expect(result).toContain('スケジュール');
expect(result).toBe(emojiText);
expect(result).not.toContain('\0');
expect(result).toContain('🚀');
});

it('handles many small chunks with multi-byte characters', async () => {
const mixedText = 'Test テスト 测试 Тест מבחן';
const buffer = Buffer.from(mixedText, 'utf-8');

// Create many small chunks (3 bytes each)
const chunks: Buffer[] = [];
for (let i = 0; i < buffer.length; i += 3) {
chunks.push(buffer.subarray(i, Math.min(i + 3, buffer.length)));
}

let currentChunk = 0;
const mockStream = new Readable({
read() {
if (currentChunk < chunks.length) {
this.push(chunks[currentChunk]);
currentChunk++;
} else {
this.push(null);
}
},
});

const pipeableStream = {
pipe: (writable: any) => mockStream.pipe(writable),
};

const result = await readStream(pipeableStream as any);

expect(result).toBe(mixedText);
expect(result).not.toContain('\0');
expect(result).toContain('テスト');
expect(result).toContain('测试');
expect(result).toContain('Тест');
expect(result).toContain('מבחן');
});
});
Loading
Loading