Skip to content

Commit b8de5e9

Browse files
Enhance error handling and support for async render functions in streaming components and add more tests
1 parent ec0d6a2 commit b8de5e9

File tree

4 files changed

+204
-24
lines changed

4 files changed

+204
-24
lines changed

node_package/src/handleError.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,10 @@ Message: ${e.message}
6060
${e.stack}`;
6161

6262
const reactElement = React.createElement('pre', null, msg);
63-
return ReactDOMServer.renderToString(reactElement);
63+
if (typeof ReactDOMServer.renderToString === 'function') {
64+
return ReactDOMServer.renderToString(reactElement);
65+
}
66+
return msg;
6467
}
6568

6669
return 'undefined';

node_package/src/streamServerRenderedReactComponent.ts

Lines changed: 9 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -10,13 +10,6 @@ import handleError from './handleError';
1010
import { createResultObject, convertToError, validateComponent } from './serverRenderUtils';
1111
import type { RenderParams, StreamRenderState } from './types';
1212

13-
const stringToStream = (str: string): Readable => {
14-
const stream = new PassThrough();
15-
stream.write(str);
16-
stream.end();
17-
return stream;
18-
};
19-
2013
type BufferedEvent = {
2114
event: 'data' | 'error' | 'end';
2215
data: unknown;
@@ -223,16 +216,20 @@ export const streamServerRenderedComponent = <T, P extends RenderParams>(
223216

224217
return renderStrategy(reactRenderingResult, options);
225218
} catch (e) {
219+
const { readableStream, writeChunk, emitError, endStream } = transformRenderStreamChunksToResultObject({
220+
hasErrors: true,
221+
isShellReady: false,
222+
result: null,
223+
});
226224
if (throwJsErrors) {
227-
throw e;
225+
emitError(e);
228226
}
229227

230228
const error = convertToError(e);
231229
const htmlResult = handleError({ e: error, name: componentName, serverSide: true });
232-
const jsonResult = JSON.stringify(
233-
createResultObject(htmlResult, buildConsoleReplay(), { hasErrors: true, error, result: null }),
234-
);
235-
return stringToStream(jsonResult) as T;
230+
writeChunk(htmlResult);
231+
endStream();
232+
return readableStream as T;
236233
}
237234
};
238235

node_package/tests/serverRenderReactComponent.test.ts

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -216,7 +216,7 @@ describe('serverRenderReactComponent', () => {
216216
const reactComponentHashResult = { componentHtml: '<div>Hello</div>' };
217217
const X7 = (_props: unknown, _railsContext?: RailsContext) => Promise.resolve(reactComponentHashResult);
218218

219-
ComponentRegistry.register({ X7: X7 as unknown as RenderFunction });
219+
ComponentRegistry.register({ X7 });
220220

221221
const renderParams: RenderParams = {
222222
name: 'X7',
@@ -232,6 +232,23 @@ describe('serverRenderReactComponent', () => {
232232
expect(result.html).toEqual(JSON.stringify(reactComponentHashResult));
233233
});
234234

235+
it('serverRenderReactComponent renders async render function that returns react component', async () => {
236+
const X8 = (_props: unknown, _railsContext?: RailsContext) =>
237+
Promise.resolve(() => React.createElement('div', null, 'Hello'));
238+
ComponentRegistry.register({ X8 });
239+
240+
const renderResult = serverRenderReactComponent({
241+
name: 'X8',
242+
domNodeId: 'myDomId',
243+
trace: false,
244+
throwJsErrors: false,
245+
renderingReturnsPromises: true,
246+
});
247+
assertIsPromise(renderResult);
248+
const result = await renderResult;
249+
expect(result.html).toEqual('<div>Hello</div>');
250+
});
251+
235252
it('serverRenderReactComponent renders an error if attempting to render a renderer', () => {
236253
const X4: RenderFunction = (
237254
_props: unknown,

node_package/tests/streamServerRenderedReactComponent.test.jsx

Lines changed: 173 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -60,8 +60,41 @@ describe('streamServerRenderedReactComponent', () => {
6060
throwSyncError = false,
6161
throwJsErrors = false,
6262
throwAsyncError = false,
63+
componentType = 'reactComponent',
6364
} = {}) => {
64-
ComponentRegistry.register({ TestComponentForStreaming });
65+
switch (componentType) {
66+
case 'reactComponent':
67+
ComponentRegistry.register({ TestComponentForStreaming });
68+
break;
69+
case 'renderFunction':
70+
ComponentRegistry.register({
71+
TestComponentForStreaming: (props, _railsContext) => () => <TestComponentForStreaming {...props} />,
72+
});
73+
break;
74+
case 'asyncRenderFunction':
75+
ComponentRegistry.register({
76+
TestComponentForStreaming: (props, _railsContext) => () =>
77+
Promise.resolve(<TestComponentForStreaming {...props} />),
78+
});
79+
break;
80+
case 'erroneousRenderFunction':
81+
ComponentRegistry.register({
82+
TestComponentForStreaming: (_props, _railsContext) => {
83+
// The error happen inside the render function itself not inside the returned React component
84+
throw new Error('Sync Error from render function');
85+
},
86+
});
87+
break;
88+
case 'erroneousAsyncRenderFunction':
89+
ComponentRegistry.register({
90+
TestComponentForStreaming: (_props, _railsContext) =>
91+
// The error happen inside the render function itself not inside the returned React component
92+
Promise.reject(new Error('Async Error from render function')),
93+
});
94+
break;
95+
default:
96+
throw new Error(`Unknown component type: ${componentType}`);
97+
}
6598
const renderResult = streamServerRenderedReactComponent({
6699
name: 'TestComponentForStreaming',
67100
domNodeId: 'myDomId',
@@ -82,7 +115,7 @@ describe('streamServerRenderedReactComponent', () => {
82115
it('streamServerRenderedReactComponent streams the rendered component', async () => {
83116
const { renderResult, chunks } = setupStreamTest();
84117
await new Promise((resolve) => {
85-
renderResult.on('end', resolve);
118+
renderResult.once('end', resolve);
86119
});
87120

88121
expect(chunks).toHaveLength(2);
@@ -101,7 +134,7 @@ describe('streamServerRenderedReactComponent', () => {
101134
const onError = jest.fn();
102135
renderResult.on('error', onError);
103136
await new Promise((resolve) => {
104-
renderResult.on('end', resolve);
137+
renderResult.once('end', resolve);
105138
});
106139

107140
expect(onError).toHaveBeenCalled();
@@ -117,7 +150,7 @@ describe('streamServerRenderedReactComponent', () => {
117150
const onError = jest.fn();
118151
renderResult.on('error', onError);
119152
await new Promise((resolve) => {
120-
renderResult.on('end', resolve);
153+
renderResult.once('end', resolve);
121154
});
122155

123156
expect(onError).not.toHaveBeenCalled();
@@ -133,17 +166,17 @@ describe('streamServerRenderedReactComponent', () => {
133166
const onError = jest.fn();
134167
renderResult.on('error', onError);
135168
await new Promise((resolve) => {
136-
renderResult.on('end', resolve);
169+
renderResult.once('end', resolve);
137170
});
138171

139172
expect(onError).toHaveBeenCalled();
140-
expect(chunks).toHaveLength(2);
173+
expect(chunks.length).toBeGreaterThanOrEqual(2);
141174
expect(chunks[0].html).toContain('Header In The Shell');
142175
expect(chunks[0].consoleReplayScript).toBe('');
143176
expect(chunks[0].hasErrors).toBe(false);
144177
expect(chunks[0].isShellReady).toBe(true);
145178
// Script that fallbacks the render to client side
146-
expect(chunks[1].html).toMatch(/<script>[.\s\S]*Async Error[.\s\S]*<\/script>/);
179+
expect(chunks[1].html).toMatch(/the server rendering errored:\\n\\nAsync Error/);
147180
expect(chunks[1].consoleReplayScript).toBe('');
148181
expect(chunks[1].hasErrors).toBe(true);
149182
expect(chunks[1].isShellReady).toBe(true);
@@ -154,19 +187,149 @@ describe('streamServerRenderedReactComponent', () => {
154187
const onError = jest.fn();
155188
renderResult.on('error', onError);
156189
await new Promise((resolve) => {
157-
renderResult.on('end', resolve);
190+
renderResult.once('end', resolve);
158191
});
159192

160193
expect(onError).not.toHaveBeenCalled();
161-
expect(chunks).toHaveLength(2);
194+
expect(chunks.length).toBeGreaterThanOrEqual(2);
162195
expect(chunks[0].html).toContain('Header In The Shell');
163196
expect(chunks[0].consoleReplayScript).toBe('');
164197
expect(chunks[0].hasErrors).toBe(false);
165198
expect(chunks[0].isShellReady).toBe(true);
166199
// Script that fallbacks the render to client side
167-
expect(chunks[1].html).toMatch(/<script>[.\s\S]*Async Error[.\s\S]*<\/script>/);
200+
expect(chunks[1].html).toMatch(/the server rendering errored:\\n\\nAsync Error/);
168201
expect(chunks[1].consoleReplayScript).toBe('');
169202
expect(chunks[1].hasErrors).toBe(true);
170203
expect(chunks[1].isShellReady).toBe(true);
171204
});
205+
206+
it.each(['asyncRenderFunction', 'renderFunction'])(
207+
'streams a component from a %s that resolves to a React component',
208+
async (componentType) => {
209+
const { renderResult, chunks } = setupStreamTest({ componentType });
210+
await new Promise((resolve) => {
211+
renderResult.once('end', resolve);
212+
});
213+
214+
expect(chunks).toHaveLength(2);
215+
expect(chunks[0].html).toContain('Header In The Shell');
216+
expect(chunks[0].consoleReplayScript).toBe('');
217+
expect(chunks[0].hasErrors).toBe(false);
218+
expect(chunks[0].isShellReady).toBe(true);
219+
expect(chunks[1].html).toContain('Async Content');
220+
expect(chunks[1].consoleReplayScript).toBe('');
221+
expect(chunks[1].hasErrors).toBe(false);
222+
expect(chunks[1].isShellReady).toBe(true);
223+
},
224+
);
225+
226+
it.each(['asyncRenderFunction', 'renderFunction'])(
227+
'handles sync errors in the %s',
228+
async (componentType) => {
229+
const { renderResult, chunks } = setupStreamTest({ componentType, throwSyncError: true });
230+
await new Promise((resolve) => {
231+
renderResult.once('end', resolve);
232+
});
233+
234+
expect(chunks).toHaveLength(1);
235+
expect(chunks[0].html).toMatch(/<pre>Exception in rendering[.\s\S]*Sync Error[.\s\S]*<\/pre>/);
236+
expect(chunks[0].consoleReplayScript).toBe('');
237+
expect(chunks[0].hasErrors).toBe(true);
238+
expect(chunks[0].isShellReady).toBe(false);
239+
},
240+
);
241+
242+
it.each(['asyncRenderFunction', 'renderFunction'])(
243+
'handles async errors in the %s',
244+
async (componentType) => {
245+
const { renderResult, chunks } = setupStreamTest({ componentType, throwAsyncError: true });
246+
await new Promise((resolve) => {
247+
renderResult.once('end', resolve);
248+
});
249+
250+
expect(chunks.length).toBeGreaterThanOrEqual(2);
251+
expect(chunks[0].html).toContain('Header In The Shell');
252+
expect(chunks[0].consoleReplayScript).toBe('');
253+
expect(chunks[0].hasErrors).toBe(false);
254+
expect(chunks[0].isShellReady).toBe(true);
255+
expect(chunks[1].html).toMatch(/the server rendering errored:\\n\\nAsync Error/);
256+
expect(chunks[1].consoleReplayScript).toBe('');
257+
expect(chunks[1].hasErrors).toBe(true);
258+
expect(chunks[1].isShellReady).toBe(true);
259+
},
260+
);
261+
262+
it.each(['erroneousRenderFunction', 'erroneousAsyncRenderFunction'])(
263+
'handles error in the %s',
264+
async (componentType) => {
265+
const { renderResult, chunks } = setupStreamTest({ componentType });
266+
await new Promise((resolve) => {
267+
renderResult.once('end', resolve);
268+
});
269+
270+
expect(chunks).toHaveLength(1);
271+
const errorMessage =
272+
componentType === 'erroneousRenderFunction'
273+
? 'Sync Error from render function'
274+
: 'Async Error from render function';
275+
expect(chunks[0].html).toMatch(
276+
new RegExp(`<pre>Exception in rendering[.\\s\\S]*${errorMessage}[.\\s\\S]*<\\/pre>`),
277+
);
278+
expect(chunks[0].consoleReplayScript).toBe('');
279+
expect(chunks[0].hasErrors).toBe(true);
280+
expect(chunks[0].isShellReady).toBe(false);
281+
},
282+
);
283+
284+
it.each(['erroneousRenderFunction', 'erroneousAsyncRenderFunction'])(
285+
'emits an error if there is an error in the %s',
286+
async (componentType) => {
287+
const { renderResult, chunks } = setupStreamTest({ componentType, throwJsErrors: true });
288+
const onError = jest.fn();
289+
renderResult.on('error', onError);
290+
await new Promise((resolve) => {
291+
renderResult.once('end', resolve);
292+
});
293+
294+
expect(chunks).toHaveLength(1);
295+
const errorMessage =
296+
componentType === 'erroneousRenderFunction'
297+
? 'Sync Error from render function'
298+
: 'Async Error from render function';
299+
expect(chunks[0].html).toMatch(
300+
new RegExp(`<pre>Exception in rendering[.\\s\\S]*${errorMessage}[.\\s\\S]*<\\/pre>`),
301+
);
302+
expect(chunks[0].consoleReplayScript).toBe('');
303+
expect(chunks[0].hasErrors).toBe(true);
304+
expect(chunks[0].isShellReady).toBe(false);
305+
expect(onError).toHaveBeenCalled();
306+
},
307+
);
308+
309+
it('streams a string from a Promise that resolves to a string', async () => {
310+
const StringPromiseComponent = () => Promise.resolve('<div>String from Promise</div>');
311+
ComponentRegistry.register({ StringPromiseComponent });
312+
313+
const renderResult = streamServerRenderedReactComponent({
314+
name: 'StringPromiseComponent',
315+
domNodeId: 'stringPromiseId',
316+
trace: false,
317+
throwJsErrors: false,
318+
});
319+
320+
const chunks = [];
321+
renderResult.on('data', (chunk) => {
322+
const decodedText = new TextDecoder().decode(chunk);
323+
chunks.push(expectStreamChunk(decodedText));
324+
});
325+
326+
await new Promise((resolve) => {
327+
renderResult.once('end', resolve);
328+
});
329+
330+
// Verify we have at least one chunk and it contains our string
331+
expect(chunks.length).toBeGreaterThan(0);
332+
expect(chunks[0].html).toContain('String from Promise');
333+
expect(chunks[0].hasErrors).toBe(false);
334+
});
172335
});

0 commit comments

Comments
 (0)