Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
7e522cc
use v19.0.3 of react-on-rails-rsc as git dep
AbanoubGhadban Dec 9, 2025
3b9a694
upgrade to react 19.2.1
AbanoubGhadban Dec 10, 2025
d8b0598
make vm adds the global `performance` object to the vm context
AbanoubGhadban Dec 11, 2025
b5cfa86
linting
AbanoubGhadban Dec 11, 2025
3ee59c5
add AbortController to the vm context
AbanoubGhadban Dec 11, 2025
a966424
revert this: make htmlStreaming.test.js fail to debug the error
AbanoubGhadban Dec 11, 2025
a8feb81
revert this: log chunk
AbanoubGhadban Dec 11, 2025
8146d01
log chunks size
AbanoubGhadban Dec 11, 2025
79e206b
use buffer for received incomplete chunks
AbanoubGhadban Dec 11, 2025
5f53e76
revert logging
AbanoubGhadban Dec 11, 2025
e5e851a
linting
AbanoubGhadban Dec 11, 2025
9618d45
increase the time of the test that reproduce the react condole replay…
AbanoubGhadban Dec 11, 2025
cdeb4ba
increase delay at test
AbanoubGhadban Dec 11, 2025
62d7455
add a check to ensure that the buggy component is rendered as expected
AbanoubGhadban Dec 11, 2025
5da6c67
add a check to ensure that the buggy component is rendered as expected
AbanoubGhadban Dec 11, 2025
2f6e7ae
revert this: run only the failing test on CI
AbanoubGhadban Dec 14, 2025
a627737
revert this: rerun tests with the pnpm test script
AbanoubGhadban Dec 14, 2025
b62f703
run all tests at serverRenderRSCReactComponent
AbanoubGhadban Dec 14, 2025
e8e9021
ENABLE_JEST_CONSOLE=y for jest tests
AbanoubGhadban Dec 14, 2025
5277869
bug investigation changes
AbanoubGhadban Dec 14, 2025
5c3c001
trick
AbanoubGhadban Dec 14, 2025
f21bc5b
Revert "trick"
AbanoubGhadban Dec 14, 2025
1fafcfb
revert all changes to reproduce the console leakage bug on CI
AbanoubGhadban Dec 14, 2025
9646924
increase number of logged messages outside the component
AbanoubGhadban Dec 14, 2025
4c28c26
make async quque waits for all chunks to be received
AbanoubGhadban Dec 14, 2025
7831d5a
linting
AbanoubGhadban Dec 14, 2025
00746f3
uncomment the assertions line
AbanoubGhadban Dec 18, 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.3",
"react-dom": "^19.0.3",
"react-on-rails-rsc": "^19.0.4"
"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) => {
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 @@ -102,7 +99,7 @@ const createParallelRenders = (size: number) => {

test('Renders concurrent rsc streams as single rsc stream', async () => {
expect.assertions(258);
const asyncQueue = new AsyncQueue<string>();
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;
48 changes: 36 additions & 12 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading