Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
424dd6c
use v19.0.3 of react-on-rails-rsc as git dep
AbanoubGhadban Dec 9, 2025
641893e
upgrade to react 19.2.1
AbanoubGhadban Dec 10, 2025
56fe358
make vm adds the global `performance` object to the vm context
AbanoubGhadban Dec 11, 2025
df3407b
linting
AbanoubGhadban Dec 11, 2025
48a4510
add AbortController to the vm context
AbanoubGhadban Dec 11, 2025
2a18b8e
revert this: make htmlStreaming.test.js fail to debug the error
AbanoubGhadban Dec 11, 2025
4dfb0dc
revert this: log chunk
AbanoubGhadban Dec 11, 2025
ee9464f
log chunks size
AbanoubGhadban Dec 11, 2025
5f64386
use buffer for received incomplete chunks
AbanoubGhadban Dec 11, 2025
7764069
revert logging
AbanoubGhadban Dec 11, 2025
8759ecd
linting
AbanoubGhadban Dec 11, 2025
98c48f3
increase the time of the test that reproduce the react condole replay…
AbanoubGhadban Dec 11, 2025
062cf51
increase delay at test
AbanoubGhadban Dec 11, 2025
9ffaac2
add a check to ensure that the buggy component is rendered as expected
AbanoubGhadban Dec 11, 2025
6faf912
add a check to ensure that the buggy component is rendered as expected
AbanoubGhadban Dec 11, 2025
78f1770
revert this: run only the failing test on CI
AbanoubGhadban Dec 14, 2025
0bd359b
revert this: rerun tests with the pnpm test script
AbanoubGhadban Dec 14, 2025
1b8f55f
run all tests at serverRenderRSCReactComponent
AbanoubGhadban Dec 14, 2025
9dee239
ENABLE_JEST_CONSOLE=y for jest tests
AbanoubGhadban Dec 14, 2025
f3f3fe1
bug investigation changes
AbanoubGhadban Dec 14, 2025
6275fb4
trick
AbanoubGhadban Dec 14, 2025
6af7949
Revert "trick"
AbanoubGhadban Dec 14, 2025
1ae0f1d
revert all changes to reproduce the console leakage bug on CI
AbanoubGhadban Dec 14, 2025
617e7c8
increase number of logged messages outside the component
AbanoubGhadban Dec 14, 2025
2d00a85
make async quque waits for all chunks to be received
AbanoubGhadban Dec 14, 2025
e7e1c87
linting
AbanoubGhadban Dec 14, 2025
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
2 changes: 2 additions & 0 deletions packages/react-on-rails-pro-node-renderer/src/worker/vm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -212,11 +212,13 @@ export async function buildVM(filePath: string) {
// 1. docs/node-renderer/js-configuration.md
// 2. packages/node-renderer/src/shared/configBuilder.ts
extendContext(contextObject, {
AbortController,
Buffer,
TextDecoder,
TextEncoder,
URLSearchParams,
ReadableStream,
performance,
process,
setTimeout,
setInterval,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ const makeRequest = async (options = {}) => {
const jsonChunks = [];
let firstByteTime;
let status;
let buffer = '';
const decoder = new TextDecoder();

request.on('response', (headers) => {
Expand All @@ -44,10 +45,17 @@ const makeRequest = async (options = {}) => {
// Sometimes, multiple chunks are merged into one.
// So, the server uses \n as a delimiter between chunks.
const decodedData = typeof data === 'string' ? data : decoder.decode(data, { stream: false });
const decodedChunksFromData = decodedData
const decodedChunksFromData = (buffer + decodedData)
.split('\n')
.map((chunk) => chunk.trim())
.filter((chunk) => chunk.length > 0);

if (!decodedData.endsWith('\n')) {
buffer = decodedChunksFromData.pop() ?? '';
} else {
buffer = '';
}

chunks.push(...decodedChunksFromData);
jsonChunks.push(
...decodedChunksFromData.map((chunk) => {
Expand Down Expand Up @@ -197,6 +205,7 @@ describe('html streaming', () => {
expect(fullBody).toContain('branch2 (level 1)');
expect(fullBody).toContain('branch2 (level 0)');

// Fail to findout the chunks content on CI
expect(jsonChunks[0].isShellReady).toBeTruthy();
expect(jsonChunks[0].hasErrors).toBeTruthy();
expect(jsonChunks[0].renderingError).toMatchObject({
Expand Down
6 changes: 3 additions & 3 deletions packages/react-on-rails-pro/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -78,8 +78,8 @@
"devDependencies": {
"@types/mock-fs": "^4.13.4",
"mock-fs": "^5.5.0",
"react": "^19.0.1",
"react-dom": "^19.0.1",
"react-on-rails-rsc": "^19.0.3"
"react": "19.2.1",
"react-dom": "19.2.1",
"react-on-rails-rsc": "git+https://github.com/shakacode/react_on_rails_rsc#upgrade-to-react-v19.2.1"
}
}
67 changes: 40 additions & 27 deletions packages/react-on-rails-pro/tests/AsyncQueue.ts
Original file line number Diff line number Diff line change
@@ -1,22 +1,29 @@
import * as EventEmitter from 'node:events';

class AsyncQueue<T> {
private eventEmitter = new EventEmitter();
const debounce = <T extends unknown[]>(callback: (...args: T) => void, delay: number) => {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Strange we don't already have it somewhere, but apparently not.

let timeoutTimer: ReturnType<typeof setTimeout>;

private buffer: T[] = [];
return (...args: T) => {
clearTimeout(timeoutTimer);

timeoutTimer = setTimeout(() => {
callback(...args);
}, delay);
};
};

class AsyncQueue {
private eventEmitter = new EventEmitter<{ data: any; end: any }>();
private buffer: string = '';
private isEnded = false;

enqueue(value: T) {
enqueue(value: string) {
if (this.isEnded) {
throw new Error('Queue Ended');
}

if (this.eventEmitter.listenerCount('data') > 0) {
this.eventEmitter.emit('data', value);
} else {
this.buffer.push(value);
}
this.buffer += value;
this.eventEmitter.emit('data', value);
}

end() {
Expand All @@ -25,33 +32,39 @@ class AsyncQueue<T> {
}

dequeue() {
return new Promise<T>((resolve, reject) => {
const bufferValueIfExist = this.buffer.shift();
if (bufferValueIfExist) {
resolve(bufferValueIfExist);
} else if (this.isEnded) {
return new Promise<string>((resolve, reject) => {
if (this.isEnded) {
reject(new Error('Queue Ended'));
} else {
let teardown = () => {};
const onData = (value: T) => {
resolve(value);
teardown();
return;
}

const checkBuffer = debounce(() => {
const teardown = () => {
this.eventEmitter.off('data', checkBuffer);
this.eventEmitter.off('end', checkBuffer);
};

const onEnd = () => {
if (this.buffer.length > 0) {
resolve(this.buffer);
this.buffer = '';
teardown();
} else if (this.isEnded) {
reject(new Error('Queue Ended'));
teardown();
};
}
}, 250);

this.eventEmitter.on('data', onData);
this.eventEmitter.on('end', onEnd);
teardown = () => {
this.eventEmitter.off('data', onData);
this.eventEmitter.off('end', onEnd);
};
if (this.buffer.length > 0) {
checkBuffer();
}
this.eventEmitter.on('data', checkBuffer);
this.eventEmitter.on('end', checkBuffer);
});
}

toString() {
return '';
}
}

export default AsyncQueue;
2 changes: 1 addition & 1 deletion packages/react-on-rails-pro/tests/StreamReader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { PassThrough, Readable } from 'node:stream';
import AsyncQueue from './AsyncQueue.ts';

class StreamReader {
private asyncQueue: AsyncQueue<string>;
private asyncQueue: AsyncQueue;

constructor(pipeableStream: Pick<Readable, 'pipe'>) {
this.asyncQueue = new AsyncQueue();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,10 +28,7 @@ beforeEach(() => {

afterEach(() => mock.restore());

const AsyncQueueItem = async ({
asyncQueue,
children,
}: PropsWithChildren<{ asyncQueue: AsyncQueue<string> }>) => {
const AsyncQueueItem = async ({ asyncQueue, children }: PropsWithChildren<{ asyncQueue: AsyncQueue }>) => {
const value = await asyncQueue.dequeue();

return (
Expand All @@ -42,7 +39,7 @@ const AsyncQueueItem = async ({
);
};

const AsyncQueueContainer = ({ asyncQueue }: { asyncQueue: AsyncQueue<string> }) => {
const AsyncQueueContainer = ({ asyncQueue }: { asyncQueue: AsyncQueue }) => {
return (
<div>
<h1>Async Queue</h1>
Expand Down Expand Up @@ -78,7 +75,7 @@ const renderComponent = (props: Record<string, unknown>) => {
};

const createParallelRenders = (size: number) => {
const asyncQueues = new Array(size).fill(null).map(() => new AsyncQueue<string>());
const asyncQueues = new Array(size).fill(null).map(() => new AsyncQueue());
const streams = asyncQueues.map((asyncQueue) => {
return renderComponent({ asyncQueue });
});
Expand All @@ -101,8 +98,8 @@ const createParallelRenders = (size: number) => {
};

test('Renders concurrent rsc streams as single rsc stream', async () => {
expect.assertions(258);
const asyncQueue = new AsyncQueue<string>();
// expect.assertions(258);
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's delete, not uncomment. expect.assertions is useful in the first place when using callbacks instead of async/await, to check callbacks actually get called.

Suggested change
// expect.assertions(258);

const asyncQueue = new AsyncQueue();
const stream = renderComponent({ asyncQueue });
const reader = new StreamReader(stream);

Expand All @@ -114,6 +111,7 @@ test('Renders concurrent rsc streams as single rsc stream', async () => {
expect(chunk).not.toContain('Random Value');

asyncQueue.enqueue('Random Value1');

chunk = await reader.nextChunk();
chunks.push(chunk);
expect(chunk).toContain('Random Value1');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -140,7 +140,7 @@ test('[bug] catches logs outside the component during reading the stream', async
readable1.on('data', (chunk: Buffer) => {
i += 1;
// To avoid infinite loop
if (i < 5) {
if (i < 10) {
console.log('Outside The Component');
}
content1 += chunk.toString();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -193,7 +193,7 @@ describe('streamServerRenderedReactComponent', () => {
// One of the chunks should have a hasErrors property of true
expect(chunks[0].hasErrors || chunks[1].hasErrors).toBe(true);
expect(chunks[0].hasErrors && chunks[1].hasErrors).toBe(false);
}, 100000);
}, 10000);

it("doesn't emit an error if there is an error in the async content and throwJsErrors is false", async () => {
const { renderResult, chunks } = setupStreamTest({ throwAsyncError: true, throwJsErrors: false });
Expand Down
36 changes: 29 additions & 7 deletions packages/react-on-rails-pro/tests/utils/removeRSCChunkStack.ts
Original file line number Diff line number Diff line change
@@ -1,22 +1,40 @@
import { RSCPayloadChunk } from 'react-on-rails';

const removeRSCChunkStack = (chunk: string) => {
const parsedJson = JSON.parse(chunk) as RSCPayloadChunk;
const removeRSCChunkStackInternal = (chunk: string) => {
if (chunk.trim().length === 0) {
return chunk;
}

let parsedJson: RSCPayloadChunk;
try {
parsedJson = JSON.parse(chunk) as RSCPayloadChunk;
} catch (err) {
throw new Error(`Error while parsing the json: "${chunk}", ${err}`);
}
const { html } = parsedJson;
const santizedHtml = html.split('\n').map((chunkLine) => {
if (!chunkLine.includes('"stack":')) {
if (/^[0-9a-fA-F]+\:D/.exec(chunkLine) || chunkLine.startsWith(':N')) {
return '';
}
if (!(chunkLine.includes('"stack":') || chunkLine.includes('"start":') || chunkLine.includes('"end":'))) {
return chunkLine;
}

const regexMatch = /(^\d+):\{/.exec(chunkLine);
const regexMatch = /([^\{]+)\{/.exec(chunkLine);
if (!regexMatch) {
return chunkLine;
}

const chunkJsonString = chunkLine.slice(chunkLine.indexOf('{'));
const chunkJson = JSON.parse(chunkJsonString) as { stack?: string };
delete chunkJson.stack;
return `${regexMatch[1]}:${JSON.stringify(chunkJson)}`;
try {
const chunkJson = JSON.parse(chunkJsonString);
delete chunkJson.stack;
delete chunkJson.start;
delete chunkJson.end;
return `${regexMatch[1]}${JSON.stringify(chunkJson)}`;
} catch {
return chunkLine;
}
});

return JSON.stringify({
Expand All @@ -25,4 +43,8 @@ const removeRSCChunkStack = (chunk: string) => {
});
};

const removeRSCChunkStack = (chunk: string) => {
chunk.split('\n').map(removeRSCChunkStackInternal).join('\n');
};

export default removeRSCChunkStack;
Loading
Loading