Skip to content

Commit 8e857ac

Browse files
add concurrency tests for SSRing server component containing client components
1 parent e4ae8b4 commit 8e857ac

File tree

5 files changed

+400
-53
lines changed

5 files changed

+400
-53
lines changed

react_on_rails_pro/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@
7272
"jest-junit": "^16.0.0",
7373
"jsdom": "^16.5.0",
7474
"ndb": "^1.1.5",
75+
"node-html-parser": "^7.0.1",
7576
"nps": "^5.9.12",
7677
"pino-pretty": "^13.0.0",
7778
"prettier": "^3.2.5",
Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
import { randomUUID } from 'crypto';
2+
import { createClient } from 'redis';
3+
import parser from 'node-html-parser';
4+
5+
// @ts-expect-error TODO: fix later
6+
import { RSCPayloadChunk } from 'react-on-rails';
7+
import buildApp from '../src/worker';
8+
import config from './testingNodeRendererConfigs';
9+
import { makeRequest } from './httpRequestUtils';
10+
import { Config } from '../src/shared/configBuilder';
11+
12+
const app = buildApp(config as Partial<Config>);
13+
const redisUrl = process.env.REDIS_URL || 'redis://localhost:6379';
14+
const redisClient = createClient({ url: redisUrl });
15+
16+
beforeAll(async () => {
17+
await redisClient.connect();
18+
await app.ready();
19+
await app.listen({ port: 0 });
20+
});
21+
22+
afterAll(async () => {
23+
await app.close();
24+
await redisClient.close();
25+
});
26+
27+
const sendRedisValue = async (redisRequestId: string, key: string, value: string) => {
28+
await redisClient.xAdd(`stream:${redisRequestId}`, '*', { [`:${key}`]: JSON.stringify(value) });
29+
};
30+
31+
const sendRedisItemValue = async (redisRequestId: string, itemIndex: number, value: string) => {
32+
await sendRedisValue(redisRequestId, `Item${itemIndex}`, value);
33+
};
34+
35+
const extractHtmlFromChunks = (chunks: string) => {
36+
const html = chunks
37+
.split('\n')
38+
.map((chunk) => (chunk.trim().length > 0 ? (JSON.parse(chunk) as RSCPayloadChunk).html : chunk))
39+
.join('');
40+
const parsedHtml = parser.parse(html);
41+
// TODO: investigate why ReactOnRails produces different RSC payload on each request
42+
parsedHtml.querySelectorAll('script').forEach((x) => x.remove());
43+
const sanitizedHtml = parsedHtml.toString();
44+
return sanitizedHtml;
45+
};
46+
47+
const createParallelRenders = (size: number) => {
48+
const redisRequestIds = Array(size)
49+
.fill(null)
50+
.map(() => randomUUID());
51+
const renderRequests = redisRequestIds.map((redisRequestId) => {
52+
return makeRequest(app, {
53+
componentName: 'RedisReceiver',
54+
props: { requestId: redisRequestId },
55+
});
56+
});
57+
58+
const expectNextChunk = async (expectedNextChunk: string) => {
59+
const nextChunks = await Promise.all(
60+
renderRequests.map((renderRequest) => renderRequest.waitForNextChunk()),
61+
);
62+
nextChunks.forEach((chunk, index) => {
63+
const redisRequestId = redisRequestIds[index]!;
64+
const chunksAfterRemovingRequestId = chunk.replace(new RegExp(redisRequestId, 'g'), '');
65+
expect(extractHtmlFromChunks(chunksAfterRemovingRequestId)).toEqual(
66+
extractHtmlFromChunks(expectedNextChunk),
67+
);
68+
});
69+
};
70+
71+
const sendRedisItemValues = async (itemIndex: number, itemValue: string) => {
72+
await Promise.all(
73+
redisRequestIds.map((redisRequestId) => sendRedisItemValue(redisRequestId, itemIndex, itemValue)),
74+
);
75+
};
76+
77+
const waitUntilFinished = async () => {
78+
await Promise.all(renderRequests.map((renderRequest) => renderRequest.finishedPromise));
79+
renderRequests.forEach((renderRequest) => {
80+
expect(renderRequest.getBuffer()).toHaveLength(0);
81+
});
82+
};
83+
84+
return {
85+
expectNextChunk,
86+
sendRedisItemValues,
87+
waitUntilFinished,
88+
};
89+
};
90+
91+
test('Happy Path', async () => {
92+
const parallelInstances = 50;
93+
expect.assertions(parallelInstances * 7 + 7);
94+
const redisRequestId = randomUUID();
95+
const { waitForNextChunk, finishedPromise, getBuffer } = makeRequest(app, {
96+
componentName: 'RedisReceiver',
97+
props: { requestId: redisRequestId },
98+
});
99+
const chunks: string[] = [];
100+
let chunk = await waitForNextChunk();
101+
expect(chunk).not.toContain('Unique Value');
102+
chunks.push(chunk.replace(new RegExp(redisRequestId, 'g'), ''));
103+
104+
await sendRedisItemValue(redisRequestId, 0, 'First Unique Value');
105+
chunk = await waitForNextChunk();
106+
expect(chunk).toContain('First Unique Value');
107+
chunks.push(chunk.replace(new RegExp(redisRequestId, 'g'), ''));
108+
109+
await sendRedisItemValue(redisRequestId, 4, 'Fifth Unique Value');
110+
chunk = await waitForNextChunk();
111+
expect(chunk).toContain('Fifth Unique Value');
112+
chunks.push(chunk.replace(new RegExp(redisRequestId, 'g'), ''));
113+
114+
await sendRedisItemValue(redisRequestId, 2, 'Third Unique Value');
115+
chunk = await waitForNextChunk();
116+
expect(chunk).toContain('Third Unique Value');
117+
chunks.push(chunk.replace(new RegExp(redisRequestId, 'g'), ''));
118+
119+
await sendRedisItemValue(redisRequestId, 1, 'Second Unique Value');
120+
chunk = await waitForNextChunk();
121+
expect(chunk).toContain('Second Unique Value');
122+
chunks.push(chunk.replace(new RegExp(redisRequestId, 'g'), ''));
123+
124+
await sendRedisItemValue(redisRequestId, 3, 'Forth Unique Value');
125+
chunk = await waitForNextChunk();
126+
expect(chunk).toContain('Forth Unique Value');
127+
chunks.push(chunk.replace(new RegExp(redisRequestId, 'g'), ''));
128+
129+
await finishedPromise;
130+
expect(getBuffer).toHaveLength(0);
131+
132+
const { expectNextChunk, sendRedisItemValues, waitUntilFinished } =
133+
createParallelRenders(parallelInstances);
134+
await expectNextChunk(chunks[0]!);
135+
await sendRedisItemValues(0, 'First Unique Value');
136+
await expectNextChunk(chunks[1]!);
137+
await sendRedisItemValues(4, 'Fifth Unique Value');
138+
await expectNextChunk(chunks[2]!);
139+
await sendRedisItemValues(2, 'Third Unique Value');
140+
await expectNextChunk(chunks[3]!);
141+
await sendRedisItemValues(1, 'Second Unique Value');
142+
await expectNextChunk(chunks[4]!);
143+
await sendRedisItemValues(3, 'Forth Unique Value');
144+
await expectNextChunk(chunks[5]!);
145+
await waitUntilFinished();
146+
}, 50000);

react_on_rails_pro/packages/node-renderer/tests/htmlStreaming.test.js

Lines changed: 1 addition & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,8 @@
1-
import fs from 'fs';
21
import http2 from 'http2';
3-
import path from 'path';
4-
import FormData from 'form-data';
52
import buildApp from '../src/worker';
63
import config from './testingNodeRendererConfigs';
7-
import { readRenderingRequest } from './helper';
84
import * as errorReporter from '../src/shared/errorReporter';
9-
import packageJson from '../src/shared/packageJson';
5+
import { createForm, SERVER_BUNDLE_TIMESTAMP } from './httpRequestUtils';
106

117
const app = buildApp(config);
128

@@ -21,54 +17,6 @@ afterAll(async () => {
2117

2218
jest.spyOn(errorReporter, 'message').mockImplementation(jest.fn());
2319

24-
const SERVER_BUNDLE_TIMESTAMP = '77777-test';
25-
// Ensure to match the rscBundleHash at `asyncComponentsTreeForTestingRenderingRequest.js` fixture
26-
const RSC_BUNDLE_TIMESTAMP = '88888-test';
27-
28-
const createForm = ({ project = 'spec-dummy', commit = '', props = {}, throwJsErrors = false } = {}) => {
29-
const form = new FormData();
30-
form.append('gemVersion', packageJson.version);
31-
form.append('protocolVersion', packageJson.protocolVersion);
32-
form.append('password', 'myPassword1');
33-
form.append('dependencyBundleTimestamps[]', RSC_BUNDLE_TIMESTAMP);
34-
35-
let renderingRequestCode = readRenderingRequest(
36-
project,
37-
commit,
38-
'asyncComponentsTreeForTestingRenderingRequest.js',
39-
);
40-
renderingRequestCode = renderingRequestCode.replace(/\(\s*\)\s*$/, `(undefined, ${JSON.stringify(props)})`);
41-
if (throwJsErrors) {
42-
renderingRequestCode = renderingRequestCode.replace('throwJsErrors: false', 'throwJsErrors: true');
43-
}
44-
form.append('renderingRequest', renderingRequestCode);
45-
46-
const testBundlesDirectory = path.join(__dirname, '../../../spec/dummy/ssr-generated');
47-
const testClientBundlesDirectory = path.join(__dirname, '../../../spec/dummy/public/webpack/test');
48-
const bundlePath = path.join(testBundlesDirectory, 'server-bundle.js');
49-
form.append(`bundle_${SERVER_BUNDLE_TIMESTAMP}`, fs.createReadStream(bundlePath), {
50-
contentType: 'text/javascript',
51-
filename: 'server-bundle.js',
52-
});
53-
const rscBundlePath = path.join(testBundlesDirectory, 'rsc-bundle.js');
54-
form.append(`bundle_${RSC_BUNDLE_TIMESTAMP}`, fs.createReadStream(rscBundlePath), {
55-
contentType: 'text/javascript',
56-
filename: 'rsc-bundle.js',
57-
});
58-
const clientManifestPath = path.join(testClientBundlesDirectory, 'react-client-manifest.json');
59-
form.append('asset1', fs.createReadStream(clientManifestPath), {
60-
contentType: 'application/json',
61-
filename: 'react-client-manifest.json',
62-
});
63-
const reactServerClientManifestPath = path.join(testBundlesDirectory, 'react-server-client-manifest.json');
64-
form.append('asset2', fs.createReadStream(reactServerClientManifestPath), {
65-
contentType: 'application/json',
66-
filename: 'react-server-client-manifest.json',
67-
});
68-
69-
return form;
70-
};
71-
7220
const makeRequest = async (options = {}) => {
7321
const startTime = Date.now();
7422
const form = createForm(options);

0 commit comments

Comments
 (0)