Skip to content

Commit add0cd8

Browse files
add concurrency tests and check logs leakage
1 parent 43accf3 commit add0cd8

File tree

7 files changed

+378
-8
lines changed

7 files changed

+378
-8
lines changed

packages/react-on-rails-pro/package.json

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,5 +75,9 @@
7575
"bugs": {
7676
"url": "https://github.com/shakacode/react_on_rails/issues"
7777
},
78-
"homepage": "https://github.com/shakacode/react_on_rails#readme"
78+
"homepage": "https://github.com/shakacode/react_on_rails#readme",
79+
"devDependencies": {
80+
"@types/mock-fs": "^4.13.4",
81+
"mock-fs": "^5.5.0"
82+
}
7983
}
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import * as EventEmitter from 'node:events';
2+
3+
class AsyncQueue<T> {
4+
private eventEmitter = new EventEmitter<{ data: any, end: any }>();
5+
private buffer: T[] = [];
6+
private isEnded = false;
7+
8+
enqueue(value: T) {
9+
if (this.isEnded) {
10+
throw new Error("Queue Ended");
11+
}
12+
13+
if (this.eventEmitter.listenerCount('data') > 0) {
14+
this.eventEmitter.emit('data', value);
15+
} else {
16+
this.buffer.push(value);
17+
}
18+
}
19+
20+
end() {
21+
this.isEnded = true;
22+
this.eventEmitter.emit('end');
23+
}
24+
25+
dequeue() {
26+
return new Promise<T>((resolve, reject) => {
27+
const bufferValueIfExist = this.buffer.shift();
28+
if (bufferValueIfExist) {
29+
resolve(bufferValueIfExist);
30+
} else if (this.isEnded) {
31+
reject(new Error("Queue Ended"));
32+
} else {
33+
let teardown = () => {}
34+
const onData = (value: T) => {
35+
resolve(value);
36+
teardown();
37+
}
38+
39+
const onEnd = () => {
40+
reject(new Error("Queue Ended"));
41+
teardown();
42+
}
43+
44+
this.eventEmitter.on('data', onData);
45+
this.eventEmitter.on('end', onEnd);
46+
teardown = () => {
47+
this.eventEmitter.off('data', onData);
48+
this.eventEmitter.off('end', onEnd);
49+
}
50+
}
51+
})
52+
}
53+
54+
toString() {
55+
return ""
56+
}
57+
}
58+
59+
export default AsyncQueue;
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import { PassThrough, Readable } from 'node:stream';
2+
import AsyncQueue from './AsyncQueue';
3+
4+
class StreamReader {
5+
private asyncQueue: AsyncQueue<string>;
6+
7+
constructor(pipeableStream: Pick<Readable, 'pipe'>) {
8+
this.asyncQueue = new AsyncQueue();
9+
const decoder = new TextDecoder();
10+
11+
const readableStream = new PassThrough();
12+
pipeableStream.pipe(readableStream);
13+
14+
readableStream.on('data', (chunk) => {
15+
const decodedChunk = decoder.decode(chunk);
16+
this.asyncQueue.enqueue(decodedChunk);
17+
});
18+
19+
if (readableStream.closed) {
20+
this.asyncQueue.end();
21+
} else {
22+
readableStream.on('end', () => {
23+
this.asyncQueue.end();
24+
});
25+
}
26+
}
27+
28+
nextChunk() {
29+
return this.asyncQueue.dequeue();
30+
}
31+
}
32+
33+
export default StreamReader;
Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
/**
2+
* @jest-environment node
3+
*/
4+
/// <reference types="react/experimental" />
5+
6+
import * as React from 'react';
7+
import { PassThrough, Readable, Transform } from 'node:stream';
8+
import { text } from 'node:stream/consumers';
9+
import { Suspense, PropsWithChildren } from 'react';
10+
11+
import * as path from 'path';
12+
import * as mock from 'mock-fs';
13+
14+
import ReactOnRails, { RailsContextWithServerStreamingCapabilities } from '../src/ReactOnRailsRSC';
15+
import AsyncQueue from './AsyncQueue';
16+
import StreamReader from './StreamReader';
17+
18+
const manifestFileDirectory = path.resolve(__dirname, '../src')
19+
const clientManifestPath = path.join(manifestFileDirectory, 'react-client-manifest.json');
20+
21+
mock({
22+
[clientManifestPath]: JSON.stringify({
23+
filePathToModuleMetadata: {},
24+
moduleLoading: { prefix: '', crossOrigin: null },
25+
}),
26+
});
27+
28+
afterAll(() => mock.restore());
29+
30+
const AsyncQueueItem = async ({ asyncQueue, children }: PropsWithChildren<{asyncQueue: AsyncQueue<string>}>) => {
31+
const value = await asyncQueue.dequeue();
32+
33+
return (
34+
<>
35+
<p>Data: {value}</p>
36+
{children}
37+
</>
38+
)
39+
}
40+
41+
const AsyncQueueContainer = ({ asyncQueue }: { asyncQueue: AsyncQueue<string> }) => {
42+
return (
43+
<div>
44+
<h1>Async Queue</h1>
45+
<Suspense fallback={<p>Loading Item1</p>}>
46+
<AsyncQueueItem asyncQueue={asyncQueue}>
47+
<Suspense fallback={<p>Loading Item2</p>}>
48+
<AsyncQueueItem asyncQueue={asyncQueue}>
49+
<Suspense fallback={<p>Loading Item3</p>}>
50+
<AsyncQueueItem asyncQueue={asyncQueue} />
51+
</Suspense>
52+
</AsyncQueueItem>
53+
</Suspense>
54+
</AsyncQueueItem>
55+
</Suspense>
56+
</div>
57+
)
58+
}
59+
60+
ReactOnRails.register({ AsyncQueueContainer });
61+
62+
const renderComponent = (props: Record<string, unknown>) => {
63+
return ReactOnRails.serverRenderRSCReactComponent({
64+
railsContext: {
65+
reactClientManifestFileName: 'react-client-manifest.json',
66+
reactServerClientManifestFileName: 'react-server-client-manifest.json',
67+
} as unknown as RailsContextWithServerStreamingCapabilities,
68+
name: 'AsyncQueueContainer',
69+
renderingReturnsPromises: true,
70+
throwJsErrors: true,
71+
domNodeId: 'dom-id',
72+
props,
73+
});
74+
}
75+
76+
const createParallelRenders = (size: number) => {
77+
const asyncQueues = new Array(size).fill(null).map(() => new AsyncQueue<string>());
78+
const streams = asyncQueues.map((asyncQueue) => {
79+
return renderComponent({ asyncQueue });
80+
});
81+
const readers = streams.map(stream => new StreamReader(stream));
82+
83+
const enqueue = (value: string) => asyncQueues.forEach(asyncQueues => asyncQueues.enqueue(value));
84+
85+
const removeComponentJsonData = (chunk: string) => {
86+
const parsedJson = JSON.parse(chunk);
87+
const html = parsedJson.html as string;
88+
const santizedHtml = html.split('\n').map(chunkLine => {
89+
if (!chunkLine.includes('"stack":')) {
90+
return chunkLine;
91+
}
92+
93+
const regexMatch = /(^\d+):\{/.exec(chunkLine)
94+
if (!regexMatch) {
95+
return;
96+
}
97+
98+
const chunkJsonString = chunkLine.slice(chunkLine.indexOf('{'));
99+
const chunkJson = JSON.parse(chunkJsonString);
100+
delete chunkJson.stack;
101+
return `${regexMatch[1]}:${JSON.stringify(chunkJson)}`
102+
});
103+
104+
return JSON.stringify({
105+
...parsedJson,
106+
html: santizedHtml,
107+
});
108+
}
109+
110+
const expectNextChunk = (nextChunk: string) => Promise.all(
111+
readers.map(async (reader) => {
112+
const chunk = await reader.nextChunk();
113+
expect(removeComponentJsonData(chunk)).toEqual(removeComponentJsonData(nextChunk));
114+
})
115+
);
116+
117+
const expectEndOfStream = () => Promise.all(
118+
readers.map(reader => expect(reader.nextChunk()).rejects.toThrow(/Queue Ended/))
119+
);
120+
121+
return { enqueue, expectNextChunk, expectEndOfStream };
122+
}
123+
124+
test('Renders concurrent rsc streams as single rsc stream', async () => {
125+
expect.assertions(258);
126+
const asyncQueue = new AsyncQueue<string>();
127+
const stream = renderComponent({ asyncQueue });
128+
const reader = new StreamReader(stream);
129+
130+
const chunks: string[] = [];
131+
let chunk = await reader.nextChunk()
132+
chunks.push(chunk);
133+
expect(chunk).toContain("Async Queue");
134+
expect(chunk).toContain("Loading Item2");
135+
expect(chunk).not.toContain("Random Value");
136+
137+
asyncQueue.enqueue("Random Value1");
138+
chunk = await reader.nextChunk();
139+
chunks.push(chunk);
140+
expect(chunk).toContain("Random Value1");
141+
142+
asyncQueue.enqueue("Random Value2");
143+
chunk = await reader.nextChunk();
144+
chunks.push(chunk);
145+
expect(chunk).toContain("Random Value2");
146+
147+
asyncQueue.enqueue("Random Value3");
148+
chunk = await reader.nextChunk();
149+
chunks.push(chunk);
150+
expect(chunk).toContain("Random Value3");
151+
152+
await expect(reader.nextChunk()).rejects.toThrow(/Queue Ended/);
153+
154+
const { enqueue, expectNextChunk, expectEndOfStream } = createParallelRenders(50);
155+
156+
expect(chunks).toHaveLength(4);
157+
await expectNextChunk(chunks[0]!);
158+
enqueue("Random Value1");
159+
await expectNextChunk(chunks[1]!);
160+
enqueue("Random Value2");
161+
await expectNextChunk(chunks[2]!);
162+
enqueue("Random Value3");
163+
await expectNextChunk(chunks[3]!);
164+
await expectEndOfStream();
165+
});
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
/**
2+
* @jest-environment node
3+
*/
4+
5+
import * as React from 'react';
6+
import { Suspense } from 'react';
7+
import * as mock from 'mock-fs';
8+
import * as fs from 'fs';
9+
import * as path from 'path';
10+
import { pipeline, finished } from 'stream/promises';
11+
import { text } from 'stream/consumers';
12+
import { buildServerRenderer } from 'react-on-rails-rsc/server.node';
13+
import createReactOutput from 'react-on-rails/createReactOutput';
14+
import ReactOnRails, { RailsContextWithServerStreamingCapabilities } from '../src/ReactOnRailsRSC.ts';
15+
16+
const PromiseWrapper = async ({ promise, name }: { promise: Promise<string>, name: string }) => {
17+
console.log(`[${name}] Before awaitng`);
18+
const value = await promise;
19+
console.log(`[${name}] After awaitng`);
20+
return (
21+
<p>Value: {value}</p>
22+
);
23+
}
24+
25+
const PromiseContainer = ({ name }: { name: string }) => {
26+
const promise = new Promise<string>((resolve) => {
27+
let i = 0;
28+
const intervalId = setInterval(() => {
29+
console.log(`Interval ${i} at [${name}]`);
30+
i += 1;
31+
if (i === 50) {
32+
clearInterval(intervalId);
33+
resolve(`Value of name ${name}`);
34+
}
35+
}, 1);
36+
});
37+
38+
return (
39+
<div>
40+
<h1>Initial Header</h1>
41+
<Suspense fallback={<p>Loading Promise</p>}>
42+
<PromiseWrapper name={name} promise={promise} />
43+
</Suspense>
44+
</div>
45+
);
46+
}
47+
48+
ReactOnRails.register({ PromiseContainer });
49+
50+
const manifestFileDirectory = path.resolve(__dirname, '../src')
51+
const clientManifestPath = path.join(manifestFileDirectory, 'react-client-manifest.json');
52+
53+
mock({
54+
[clientManifestPath]: JSON.stringify({
55+
filePathToModuleMetadata: {},
56+
moduleLoading: { prefix: '', crossOrigin: null },
57+
}),
58+
});
59+
60+
afterAll(() => {
61+
mock.restore();
62+
})
63+
64+
test('no logs leakage between concurrent rendering components', async () => {
65+
const readable1 = ReactOnRails.serverRenderRSCReactComponent({
66+
railsContext: {
67+
reactClientManifestFileName: 'react-client-manifest.json',
68+
reactServerClientManifestFileName: 'react-server-client-manifest.json',
69+
} as unknown as RailsContextWithServerStreamingCapabilities,
70+
name: 'PromiseContainer',
71+
renderingReturnsPromises: true,
72+
throwJsErrors: true,
73+
domNodeId: 'dom-id',
74+
props: { name: "First Unique Name" }
75+
});
76+
const readable2 = ReactOnRails.serverRenderRSCReactComponent({
77+
railsContext: {
78+
reactClientManifestFileName: 'react-client-manifest.json',
79+
reactServerClientManifestFileName: 'react-server-client-manifest.json',
80+
} as unknown as RailsContextWithServerStreamingCapabilities,
81+
name: 'PromiseContainer',
82+
renderingReturnsPromises: true,
83+
throwJsErrors: true,
84+
domNodeId: 'dom-id',
85+
props: { name: "Second Unique Name" }
86+
});
87+
88+
const [content1, content2] = await Promise.all([text(readable1), text(readable2)]);
89+
90+
expect(content1).toContain("First Unique Name");
91+
expect(content2).toContain("Second Unique Name");
92+
// expect(content1.match(/First Unique Name/g)).toHaveLength(55)
93+
// expect(content2.match(/Second Unique Name/g)).toHaveLength(55)
94+
expect(content1).not.toContain("Second Unique Name");
95+
expect(content2).not.toContain("First Unique Name");
96+
97+
// expect(content1.replace(new RegExp("First Unique Name", 'g'), "Second Unique Name")).toEqual(content2);
98+
})

packages/react-on-rails/src/types/index.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -225,7 +225,6 @@ export interface RenderParams extends Params {
225225

226226
export interface RSCRenderParams extends Omit<RenderParams, 'railsContext'> {
227227
railsContext: RailsContextWithServerStreamingCapabilities;
228-
reactClientManifestFileName: string;
229228
}
230229

231230
export interface CreateParams extends Params {

0 commit comments

Comments
 (0)