Skip to content

Commit a87c1e6

Browse files
AbanoubGhadbanalexeyr-cicoderabbitai[bot]
authored
Handle errors happen during streaming components (#1648)
* handle errors happen during streaming components * remove debugging statements * linting * emit errors when an error happen and refactor * add unit tests for streamServerRenderedReactComponent * linting * make requested changes * make a condition simpler Co-authored-by: Alexey Romanov <[email protected]> * fix variable name Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> * tiny changes * linting * update CHANGELOG.md * rename ensureError function to convertToError * update CHANELOG.md * update CHANELOG.md * update CHANELOG.md --------- Co-authored-by: Alexey Romanov <[email protected]> Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
1 parent cffaed8 commit a87c1e6

File tree

8 files changed

+331
-78
lines changed

8 files changed

+331
-78
lines changed

CHANGELOG.md

+7-8
Original file line numberDiff line numberDiff line change
@@ -18,15 +18,14 @@ Please follow the recommendations outlined at [keepachangelog.com](http://keepac
1818
### [Unreleased]
1919
Changes since the last non-beta release.
2020

21-
### Added
22-
- Added support for replaying console logs that occur during server rendering of streamed React components. This enables debugging of server-side rendering issues by capturing and displaying console output on the client and on the server output. [PR #1647](https://github.com/shakacode/react_on_rails/pull/1647) by [AbanoubGhadban](https://github.com/AbanoubGhadban).
23-
24-
### Added
21+
#### Added(https://github.com/AbanoubGhadban).
2522
- Added streaming server rendering support:
26-
- New `stream_react_component` helper for adding streamed components to views
27-
- New `streamServerRenderedReactComponent` function in the react-on-rails package that uses React 18's `renderToPipeableStream` API
28-
- Enables progressive page loading and improved performance for server-rendered React components
29-
[PR #1633](https://github.com/shakacode/react_on_rails/pull/1633) by [AbanoubGhadban](https://github.com/AbanoubGhadban).
23+
- [PR #1633](https://github.com/shakacode/react_on_rails/pull/1633) by [AbanoubGhadban](https://github.com/AbanoubGhadban).
24+
- New `stream_react_component` helper for adding streamed components to views
25+
- New `streamServerRenderedReactComponent` function in the react-on-rails package that uses React 18's `renderToPipeableStream` API
26+
- Enables progressive page loading and improved performance for server-rendered React components
27+
- Added support for replaying console logs that occur during server rendering of streamed React components. This enables debugging of server-side rendering issues by capturing and displaying console output on the client and on the server output. [PR #1647](https://github.com/shakacode/react_on_rails/pull/1647) by [AbanoubGhadban](https://github.com/AbanoubGhadban).
28+
- Added support for handling errors happening during server rendering of streamed React components. It handles errors that happen during the initial render and errors that happen inside suspense boundaries. [PR #1648](https://github.com/shakacode/react_on_rails/pull/1648) by [AbanoubGhadban](https://github.com/AbanoubGhadban).
3029

3130
#### Changed
3231
- Console replay script generation now awaits the render request promise before generating, allowing it to capture console logs from asynchronous operations. This requires using a version of the Node renderer that supports replaying async console logs. [PR #1649](https://github.com/shakacode/react_on_rails/pull/1649) by [AbanoubGhadban](https://github.com/AbanoubGhadban).

lib/react_on_rails/helper.rb

+30-12
Original file line numberDiff line numberDiff line change
@@ -570,6 +570,25 @@ def props_string(props)
570570
props.is_a?(String) ? props : props.to_json
571571
end
572572

573+
def raise_prerender_error(json_result, react_component_name, props, js_code)
574+
raise ReactOnRails::PrerenderError.new(
575+
component_name: react_component_name,
576+
props: sanitized_props_string(props),
577+
err: nil,
578+
js_code: js_code,
579+
console_messages: json_result["consoleReplayScript"]
580+
)
581+
end
582+
583+
def should_raise_streaming_prerender_error?(chunk_json_result, render_options)
584+
chunk_json_result["hasErrors"] &&
585+
(if chunk_json_result["isShellReady"]
586+
render_options.raise_non_shell_server_rendering_errors
587+
else
588+
render_options.raise_on_prerender_error
589+
end)
590+
end
591+
573592
# Returns object with values that are NOT html_safe!
574593
def server_rendered_react_component(render_options)
575594
return { "html" => "", "consoleReplayScript" => "" } unless render_options.prerender
@@ -617,19 +636,18 @@ def server_rendered_react_component(render_options)
617636
js_code: js_code)
618637
end
619638

620-
# TODO: handle errors for streams
621-
return result if render_options.stream?
622-
623-
if result["hasErrors"] && render_options.raise_on_prerender_error
624-
# We caught this exception on our backtrace handler
625-
raise ReactOnRails::PrerenderError.new(component_name: react_component_name,
626-
# Sanitize as this might be browser logged
627-
props: sanitized_props_string(props),
628-
err: nil,
629-
js_code: js_code,
630-
console_messages: result["consoleReplayScript"])
631-
639+
if render_options.stream?
640+
result.transform do |chunk_json_result|
641+
if should_raise_streaming_prerender_error?(chunk_json_result, render_options)
642+
raise_prerender_error(chunk_json_result, react_component_name, props, js_code)
643+
end
644+
# It doesn't make any transformation, it listens and raises error if a chunk has errors
645+
chunk_json_result
646+
end
647+
elsif result["hasErrors"] && render_options.raise_on_prerender_error
648+
raise_prerender_error(result, react_component_name, props, js_code)
632649
end
650+
633651
result
634652
end
635653

lib/react_on_rails/react_component/render_options.rb

+12
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,10 @@ def raise_on_prerender_error
8787
retrieve_configuration_value_for(:raise_on_prerender_error)
8888
end
8989

90+
def raise_non_shell_server_rendering_errors
91+
retrieve_react_on_rails_pro_config_value_for(:raise_non_shell_server_rendering_errors)
92+
end
93+
9094
def logging_on_server
9195
retrieve_configuration_value_for(:logging_on_server)
9296
end
@@ -128,6 +132,14 @@ def retrieve_configuration_value_for(key)
128132
ReactOnRails.configuration.public_send(key)
129133
end
130134
end
135+
136+
def retrieve_react_on_rails_pro_config_value_for(key)
137+
options.fetch(key) do
138+
return nil unless ReactOnRails::Utils.react_on_rails_pro?
139+
140+
ReactOnRailsPro.configuration.public_send(key)
141+
end
142+
end
131143
end
132144
end
133145
end

node_package/src/serverRenderReactComponent.ts

+109-37
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
import ReactDOMServer from 'react-dom/server';
2-
import { PassThrough, Readable, Transform } from 'stream';
1+
import ReactDOMServer, { type PipeableStream } from 'react-dom/server';
2+
import { PassThrough, Readable } from 'stream';
33
import type { ReactElement } from 'react';
44

55
import ComponentRegistry from './ComponentRegistry';
@@ -15,13 +15,22 @@ type RenderState = {
1515
error?: RenderingError;
1616
};
1717

18+
type StreamRenderState = Omit<RenderState, 'result'> & {
19+
result: null | Readable;
20+
isShellReady: boolean;
21+
};
22+
1823
type RenderOptions = {
1924
componentName: string;
2025
domNodeId?: string;
2126
trace?: boolean;
2227
renderingReturnsPromises: boolean;
2328
};
2429

30+
function convertToError(e: unknown): Error {
31+
return e instanceof Error ? e : new Error(String(e));
32+
}
33+
2534
function validateComponent(componentObj: RegisteredComponent, componentName: string) {
2635
if (componentObj.isRenderer) {
2736
throw new Error(`Detected a renderer while server rendering component '${componentName}'. See https://github.com/shakacode/react_on_rails#renderer-functions`);
@@ -87,20 +96,21 @@ function handleRenderingError(e: unknown, options: { componentName: string, thro
8796
if (options.throwJsErrors) {
8897
throw e;
8998
}
90-
const error = e instanceof Error ? e : new Error(String(e));
99+
const error = convertToError(e);
91100
return {
92101
hasErrors: true,
93102
result: handleError({ e: error, name: options.componentName, serverSide: true }),
94103
error,
95104
};
96105
}
97106

98-
function createResultObject(html: string | null, consoleReplayScript: string, renderState: RenderState): RenderResult {
107+
function createResultObject(html: string | null, consoleReplayScript: string, renderState: RenderState | StreamRenderState): RenderResult {
99108
return {
100109
html,
101110
consoleReplayScript,
102111
hasErrors: renderState.hasErrors,
103112
renderingError: renderState.error && { message: renderState.error.message, stack: renderState.error.stack },
113+
isShellReady: 'isShellReady' in renderState ? renderState.isShellReady : undefined,
104114
};
105115
}
106116

@@ -195,17 +205,102 @@ const serverRenderReactComponent: typeof serverRenderReactComponentInternal = (o
195205

196206
const stringToStream = (str: string): Readable => {
197207
const stream = new PassThrough();
198-
stream.push(str);
199-
stream.push(null);
208+
stream.write(str);
209+
stream.end();
200210
return stream;
201211
};
202212

213+
const transformRenderStreamChunksToResultObject = (renderState: StreamRenderState) => {
214+
const consoleHistory = console.history;
215+
let previouslyReplayedConsoleMessages = 0;
216+
217+
const transformStream = new PassThrough({
218+
transform(chunk, _, callback) {
219+
const htmlChunk = chunk.toString();
220+
const consoleReplayScript = buildConsoleReplay(consoleHistory, previouslyReplayedConsoleMessages);
221+
previouslyReplayedConsoleMessages = consoleHistory?.length || 0;
222+
223+
const jsonChunk = JSON.stringify(createResultObject(htmlChunk, consoleReplayScript, renderState));
224+
225+
this.push(`${jsonChunk}\n`);
226+
callback();
227+
}
228+
});
229+
230+
let pipedStream: PipeableStream | null = null;
231+
const pipeToTransform = (pipeableStream: PipeableStream) => {
232+
pipeableStream.pipe(transformStream);
233+
pipedStream = pipeableStream;
234+
};
235+
// We need to wrap the transformStream in a Readable stream to properly handle errors:
236+
// 1. If we returned transformStream directly, we couldn't emit errors into it externally
237+
// 2. If an error is emitted into the transformStream, it would cause the render to fail
238+
// 3. By wrapping in Readable.from(), we can explicitly emit errors into the readableStream without affecting the transformStream
239+
// Note: Readable.from can merge multiple chunks into a single chunk, so we need to ensure that we can separate them later
240+
const readableStream = Readable.from(transformStream);
241+
242+
const writeChunk = (chunk: string) => transformStream.write(chunk);
243+
const emitError = (error: unknown) => readableStream.emit('error', error);
244+
const endStream = () => {
245+
transformStream.end();
246+
pipedStream?.abort();
247+
}
248+
return { readableStream, pipeToTransform, writeChunk, emitError, endStream };
249+
}
250+
251+
const streamRenderReactComponent = (reactRenderingResult: ReactElement, options: RenderParams) => {
252+
const { name: componentName, throwJsErrors } = options;
253+
const renderState: StreamRenderState = {
254+
result: null,
255+
hasErrors: false,
256+
isShellReady: false
257+
};
258+
259+
const {
260+
readableStream,
261+
pipeToTransform,
262+
writeChunk,
263+
emitError,
264+
endStream
265+
} = transformRenderStreamChunksToResultObject(renderState);
266+
267+
const renderingStream = ReactDOMServer.renderToPipeableStream(reactRenderingResult, {
268+
onShellError(e) {
269+
const error = convertToError(e);
270+
renderState.hasErrors = true;
271+
renderState.error = error;
272+
273+
if (throwJsErrors) {
274+
emitError(error);
275+
}
276+
277+
const errorHtml = handleError({ e: error, name: componentName, serverSide: true });
278+
writeChunk(errorHtml);
279+
endStream();
280+
},
281+
onShellReady() {
282+
renderState.isShellReady = true;
283+
pipeToTransform(renderingStream);
284+
},
285+
onError(e) {
286+
if (!renderState.isShellReady) {
287+
return;
288+
}
289+
const error = convertToError(e);
290+
if (throwJsErrors) {
291+
emitError(error);
292+
}
293+
renderState.hasErrors = true;
294+
renderState.error = error;
295+
},
296+
});
297+
298+
return readableStream;
299+
}
300+
203301
export const streamServerRenderedReactComponent = (options: RenderParams): Readable => {
204302
const { name: componentName, domNodeId, trace, props, railsContext, throwJsErrors } = options;
205303

206-
let renderResult: null | Readable = null;
207-
let previouslyReplayedConsoleMessages: number = 0;
208-
209304
try {
210305
const componentObj = ComponentRegistry.get(componentName);
211306
validateComponent(componentObj, componentName);
@@ -222,40 +317,17 @@ export const streamServerRenderedReactComponent = (options: RenderParams): Reada
222317
throw new Error('Server rendering of streams is not supported for server render hashes or promises.');
223318
}
224319

225-
const consoleHistory = console.history;
226-
const transformStream = new Transform({
227-
transform(chunk, _, callback) {
228-
const htmlChunk = chunk.toString();
229-
const consoleReplayScript = buildConsoleReplay(consoleHistory, previouslyReplayedConsoleMessages);
230-
previouslyReplayedConsoleMessages = consoleHistory?.length || 0;
231-
232-
const jsonChunk = JSON.stringify({
233-
html: htmlChunk,
234-
consoleReplayScript,
235-
});
236-
237-
this.push(jsonChunk);
238-
callback();
239-
}
240-
});
241-
242-
ReactDOMServer.renderToPipeableStream(reactRenderingResult).pipe(transformStream);
243-
244-
renderResult = transformStream;
320+
return streamRenderReactComponent(reactRenderingResult, options);
245321
} catch (e) {
246322
if (throwJsErrors) {
247323
throw e;
248324
}
249325

250-
const error = e instanceof Error ? e : new Error(String(e));
251-
renderResult = stringToStream(handleError({
252-
e: error,
253-
name: componentName,
254-
serverSide: true,
255-
}));
326+
const error = convertToError(e);
327+
const htmlResult = handleError({ e: error, name: componentName, serverSide: true });
328+
const jsonResult = JSON.stringify(createResultObject(htmlResult, buildConsoleReplay(), { hasErrors: true, error, result: null }));
329+
return stringToStream(jsonResult);
256330
}
257-
258-
return renderResult;
259331
};
260332

261333
export default serverRenderReactComponent;

node_package/src/types/index.ts

+1
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,7 @@ export interface RenderResult {
138138
consoleReplayScript: string;
139139
hasErrors: boolean;
140140
renderingError?: RenderingError;
141+
isShellReady?: boolean;
141142
}
142143

143144
// from react-dom 18

0 commit comments

Comments
 (0)