Skip to content

Commit 4e1e3ad

Browse files
Add AsyncPropManager to react-on-rails-pro package (#2049)
1 parent 4c74dc9 commit 4e1e3ad

File tree

14 files changed

+705
-15
lines changed

14 files changed

+705
-15
lines changed

packages/react-on-rails-pro-node-renderer/src/worker.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -287,7 +287,7 @@ export default function run(config: Partial<Config>) {
287287
);
288288

289289
const initial: IncrementalRenderInitialRequest = {
290-
renderingRequest: String((tempReqBody as { renderingRequest?: string }).renderingRequest ?? ''),
290+
firstRequestChunk: obj,
291291
bundleTimestamp,
292292
dependencyBundleTimestamps,
293293
};
@@ -322,6 +322,7 @@ export default function run(config: Partial<Config>) {
322322
}
323323

324324
try {
325+
log.info(`Received a new update chunk ${JSON.stringify(obj)}`);
325326
incrementalSink.add(obj);
326327
} catch (err) {
327328
// Log error but don't stop processing
@@ -334,7 +335,11 @@ export default function run(config: Partial<Config>) {
334335
},
335336

336337
onRequestEnded: () => {
337-
// Do nothing
338+
if (!incrementalSink) {
339+
return;
340+
}
341+
342+
incrementalSink.handleRequestClosed();
338343
},
339344
});
340345
} catch (err) {

packages/react-on-rails-pro-node-renderer/src/worker/handleIncrementalRenderRequest.ts

Lines changed: 48 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { getRequestBundleFilePath } from '../shared/utils';
66
export type IncrementalRenderSink = {
77
/** Called for every subsequent NDJSON object after the first one */
88
add: (chunk: unknown) => void;
9+
handleRequestClosed: () => void;
910
};
1011

1112
export type UpdateChunk = {
@@ -27,11 +28,33 @@ function assertIsUpdateChunk(value: unknown): asserts value is UpdateChunk {
2728
}
2829

2930
export type IncrementalRenderInitialRequest = {
30-
renderingRequest: string;
31+
firstRequestChunk: unknown;
3132
bundleTimestamp: string | number;
3233
dependencyBundleTimestamps?: string[] | number[];
3334
};
3435

36+
export type FirstIncrementalRenderRequestChunk = {
37+
renderingRequest: string;
38+
onRequestClosedUpdateChunk?: string;
39+
};
40+
41+
function assertFirstIncrementalRenderRequestChunk(
42+
chunk: unknown,
43+
): asserts chunk is FirstIncrementalRenderRequestChunk {
44+
if (
45+
typeof chunk !== 'object' ||
46+
chunk === null ||
47+
!('renderingRequest' in chunk) ||
48+
typeof chunk.renderingRequest !== 'string' ||
49+
// onRequestClosedUpdateChunk is an optional field
50+
('onRequestClosedUpdateChunk' in chunk &&
51+
chunk.onRequestClosedUpdateChunk &&
52+
typeof chunk.onRequestClosedUpdateChunk !== 'object')
53+
) {
54+
throw new Error('Invalid first incremental render request chunk received, missing properties');
55+
}
56+
}
57+
3558
export type IncrementalRenderResult = {
3659
response: ResponseResult;
3760
sink?: IncrementalRenderSink;
@@ -46,7 +69,9 @@ export type IncrementalRenderResult = {
4669
export async function handleIncrementalRenderRequest(
4770
initial: IncrementalRenderInitialRequest,
4871
): Promise<IncrementalRenderResult> {
49-
const { renderingRequest, bundleTimestamp, dependencyBundleTimestamps } = initial;
72+
const { firstRequestChunk, bundleTimestamp, dependencyBundleTimestamps } = initial;
73+
assertFirstIncrementalRenderRequestChunk(firstRequestChunk);
74+
const { renderingRequest, onRequestClosedUpdateChunk } = firstRequestChunk;
5075

5176
try {
5277
// Call handleRenderRequest internally to handle all validation and VM execution
@@ -79,6 +104,27 @@ export async function handleIncrementalRenderRequest(
79104
log.error({ msg: 'Invalid incremental render chunk', err, chunk });
80105
}
81106
},
107+
handleRequestClosed: () => {
108+
if (!onRequestClosedUpdateChunk) {
109+
return;
110+
}
111+
112+
try {
113+
assertIsUpdateChunk(onRequestClosedUpdateChunk);
114+
const bundlePath = getRequestBundleFilePath(onRequestClosedUpdateChunk.bundleTimestamp);
115+
executionContext
116+
.runInVM(onRequestClosedUpdateChunk.updateChunk, bundlePath)
117+
.catch((err: unknown) => {
118+
log.error({
119+
msg: 'Error running onRequestClosedUpdateChunk',
120+
err,
121+
onRequestClosedUpdateChunk,
122+
});
123+
});
124+
} catch (err) {
125+
log.error({ msg: 'Invalid onRequestClosedUpdateChunk', err, onRequestClosedUpdateChunk });
126+
}
127+
},
82128
},
83129
};
84130
} catch (error) {

packages/react-on-rails-pro-node-renderer/tests/fixtures/projects/spec-dummy/asyncComponentsTreeForTestingRenderingRequest.js

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,13 @@
2020

2121
ReactOnRails.clearHydratedStores();
2222
var usedProps = typeof props === 'undefined' ? {"helloWorldData":{"name":"Mr. Server Side Rendering","\u003cscript\u003ewindow.alert('xss1');\u003c/script\u003e":"\u003cscript\u003ewindow.alert(\"xss2\");\u003c/script\u003e"}} : props;
23+
24+
if (ReactOnRails.isRSCBundle) {
25+
var { props: propsWithAsyncProps, asyncPropManager } = ReactOnRails.addAsyncPropsCapabilityToComponentProps(usedProps);
26+
usedProps = propsWithAsyncProps;
27+
sharedExecutionContext.set("asyncPropsManager", asyncPropManager);
28+
}
29+
2330
return ReactOnRails[ReactOnRails.isRSCBundle ? 'serverRenderRSCReactComponent' : 'streamServerRenderedReactComponent']({
2431
name: componentName,
2532
domNodeId: 'AsyncComponentsTreeForTesting-react-component-0',

packages/react-on-rails-pro-node-renderer/tests/httpRequestUtils.ts

Lines changed: 95 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -19,19 +19,13 @@ type RequestOptions = {
1919
renderRscPayload: boolean;
2020
};
2121

22-
export const createForm = ({
22+
export const createRenderingRequest = ({
2323
project = 'spec-dummy',
2424
commit = '',
2525
props = {},
2626
throwJsErrors = false,
2727
componentName = undefined,
2828
}: Partial<RequestOptions> = {}) => {
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-
3529
let renderingRequestCode = readRenderingRequest(
3630
project,
3731
commit,
@@ -45,6 +39,29 @@ export const createForm = ({
4539
if (throwJsErrors) {
4640
renderingRequestCode = renderingRequestCode.replace('throwJsErrors: false', 'throwJsErrors: true');
4741
}
42+
return renderingRequestCode;
43+
};
44+
45+
export const createForm = ({
46+
project = 'spec-dummy',
47+
commit = '',
48+
props = {},
49+
throwJsErrors = false,
50+
componentName = undefined,
51+
}: Partial<RequestOptions> = {}) => {
52+
const form = new FormData();
53+
form.append('gemVersion', packageJson.version);
54+
form.append('protocolVersion', packageJson.protocolVersion);
55+
form.append('password', 'myPassword1');
56+
form.append('dependencyBundleTimestamps[]', RSC_BUNDLE_TIMESTAMP);
57+
58+
const renderingRequestCode = createRenderingRequest({
59+
project,
60+
commit,
61+
props,
62+
throwJsErrors,
63+
componentName,
64+
});
4865
form.append('renderingRequest', renderingRequestCode);
4966

5067
const testBundlesDirectory = path.join(__dirname, '../../../react_on_rails_pro/spec/dummy/ssr-generated');
@@ -76,7 +93,14 @@ export const createForm = ({
7693
return form;
7794
};
7895

79-
const getAppUrl = (app: ReturnType<typeof buildApp>) => {
96+
export const createUploadAssetsForm = (options: Partial<RequestOptions> = {}) => {
97+
const requestForm = createForm(options);
98+
requestForm.append('targetBundles[]', SERVER_BUNDLE_TIMESTAMP);
99+
requestForm.append('targetBundles[]', RSC_BUNDLE_TIMESTAMP);
100+
return requestForm;
101+
};
102+
103+
export const getAppUrl = (app: ReturnType<typeof buildApp>) => {
80104
const addresssInfo = app.server.address();
81105
if (!addresssInfo) {
82106
throw new Error('The app has no address, ensure to run the app before running tests');
@@ -177,3 +201,66 @@ export const makeRequest = (app: ReturnType<typeof buildApp>, options: Partial<R
177201
getBuffer,
178202
};
179203
};
204+
205+
export const getNextChunkInternal = (
206+
stream: NodeJS.ReadableStream,
207+
{ timeout = 250 }: { timeout?: number } = {},
208+
) => {
209+
return new Promise<string>((resolve, reject) => {
210+
let timeoutId: NodeJS.Timeout;
211+
let cancelDataListener = () => {};
212+
if (timeout) {
213+
timeoutId = setTimeout(() => {
214+
cancelDataListener();
215+
reject(new Error(`Timeout after waiting for ${timeout}ms to get the next stream chunk`));
216+
}, timeout);
217+
}
218+
219+
const onData = (chunk: Buffer) => {
220+
clearTimeout(timeoutId);
221+
cancelDataListener();
222+
resolve(chunk.toString());
223+
};
224+
225+
const onError = (error: Error) => {
226+
clearTimeout(timeoutId);
227+
cancelDataListener();
228+
reject(error);
229+
};
230+
231+
const onClose = () => {
232+
reject(new Error('Stream Closed'));
233+
};
234+
235+
cancelDataListener = () => {
236+
stream.off('data', onData);
237+
stream.off('error', onError);
238+
stream.off('close', onClose);
239+
};
240+
241+
stream.once('data', onData);
242+
stream.once('error', onError);
243+
if (stream.closed) {
244+
onClose();
245+
} else {
246+
stream.once('close', onClose);
247+
}
248+
});
249+
};
250+
251+
export const getNextChunk = async (stream: NodeJS.ReadableStream, options: { timeout?: number } = {}) => {
252+
const receivedChunks: string[] = [];
253+
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
254+
while (true) {
255+
try {
256+
// eslint-disable-next-line no-await-in-loop
257+
const chunk = await getNextChunkInternal(stream, options);
258+
receivedChunks.push(chunk);
259+
} catch (err) {
260+
if (receivedChunks.length > 0) {
261+
return receivedChunks.join('');
262+
}
263+
throw err;
264+
}
265+
}
266+
};

0 commit comments

Comments
 (0)