Skip to content

Commit

Permalink
Handle errors happen during streaming components (#1648)
Browse files Browse the repository at this point in the history
* 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>
  • Loading branch information
3 people authored Oct 31, 2024
1 parent cffaed8 commit a87c1e6
Show file tree
Hide file tree
Showing 8 changed files with 331 additions and 78 deletions.
15 changes: 7 additions & 8 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,15 +18,14 @@ Please follow the recommendations outlined at [keepachangelog.com](http://keepac
### [Unreleased]
Changes since the last non-beta release.

### Added
- 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).

### Added
#### Added(https://github.com/AbanoubGhadban).
- Added streaming server rendering support:
- New `stream_react_component` helper for adding streamed components to views
- New `streamServerRenderedReactComponent` function in the react-on-rails package that uses React 18's `renderToPipeableStream` API
- Enables progressive page loading and improved performance for server-rendered React components
[PR #1633](https://github.com/shakacode/react_on_rails/pull/1633) by [AbanoubGhadban](https://github.com/AbanoubGhadban).
- [PR #1633](https://github.com/shakacode/react_on_rails/pull/1633) by [AbanoubGhadban](https://github.com/AbanoubGhadban).
- New `stream_react_component` helper for adding streamed components to views
- New `streamServerRenderedReactComponent` function in the react-on-rails package that uses React 18's `renderToPipeableStream` API
- Enables progressive page loading and improved performance for server-rendered React components
- 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).
- 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).

#### Changed
- 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).
Expand Down
42 changes: 30 additions & 12 deletions lib/react_on_rails/helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -570,6 +570,25 @@ def props_string(props)
props.is_a?(String) ? props : props.to_json
end

def raise_prerender_error(json_result, react_component_name, props, js_code)
raise ReactOnRails::PrerenderError.new(
component_name: react_component_name,
props: sanitized_props_string(props),
err: nil,
js_code: js_code,
console_messages: json_result["consoleReplayScript"]
)
end

def should_raise_streaming_prerender_error?(chunk_json_result, render_options)
chunk_json_result["hasErrors"] &&
(if chunk_json_result["isShellReady"]
render_options.raise_non_shell_server_rendering_errors
else
render_options.raise_on_prerender_error
end)
end

# Returns object with values that are NOT html_safe!
def server_rendered_react_component(render_options)
return { "html" => "", "consoleReplayScript" => "" } unless render_options.prerender
Expand Down Expand Up @@ -617,19 +636,18 @@ def server_rendered_react_component(render_options)
js_code: js_code)
end

# TODO: handle errors for streams
return result if render_options.stream?

if result["hasErrors"] && render_options.raise_on_prerender_error
# We caught this exception on our backtrace handler
raise ReactOnRails::PrerenderError.new(component_name: react_component_name,
# Sanitize as this might be browser logged
props: sanitized_props_string(props),
err: nil,
js_code: js_code,
console_messages: result["consoleReplayScript"])

if render_options.stream?
result.transform do |chunk_json_result|
if should_raise_streaming_prerender_error?(chunk_json_result, render_options)
raise_prerender_error(chunk_json_result, react_component_name, props, js_code)
end
# It doesn't make any transformation, it listens and raises error if a chunk has errors
chunk_json_result
end
elsif result["hasErrors"] && render_options.raise_on_prerender_error
raise_prerender_error(result, react_component_name, props, js_code)
end

result
end

Expand Down
12 changes: 12 additions & 0 deletions lib/react_on_rails/react_component/render_options.rb
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,10 @@ def raise_on_prerender_error
retrieve_configuration_value_for(:raise_on_prerender_error)
end

def raise_non_shell_server_rendering_errors
retrieve_react_on_rails_pro_config_value_for(:raise_non_shell_server_rendering_errors)
end

def logging_on_server
retrieve_configuration_value_for(:logging_on_server)
end
Expand Down Expand Up @@ -128,6 +132,14 @@ def retrieve_configuration_value_for(key)
ReactOnRails.configuration.public_send(key)
end
end

def retrieve_react_on_rails_pro_config_value_for(key)
options.fetch(key) do
return nil unless ReactOnRails::Utils.react_on_rails_pro?

ReactOnRailsPro.configuration.public_send(key)
end
end
end
end
end
146 changes: 109 additions & 37 deletions node_package/src/serverRenderReactComponent.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import ReactDOMServer from 'react-dom/server';
import { PassThrough, Readable, Transform } from 'stream';
import ReactDOMServer, { type PipeableStream } from 'react-dom/server';
import { PassThrough, Readable } from 'stream';
import type { ReactElement } from 'react';

import ComponentRegistry from './ComponentRegistry';
Expand All @@ -15,13 +15,22 @@ type RenderState = {
error?: RenderingError;
};

type StreamRenderState = Omit<RenderState, 'result'> & {
result: null | Readable;
isShellReady: boolean;
};

type RenderOptions = {
componentName: string;
domNodeId?: string;
trace?: boolean;
renderingReturnsPromises: boolean;
};

function convertToError(e: unknown): Error {
return e instanceof Error ? e : new Error(String(e));
}

function validateComponent(componentObj: RegisteredComponent, componentName: string) {
if (componentObj.isRenderer) {
throw new Error(`Detected a renderer while server rendering component '${componentName}'. See https://github.com/shakacode/react_on_rails#renderer-functions`);
Expand Down Expand Up @@ -87,20 +96,21 @@ function handleRenderingError(e: unknown, options: { componentName: string, thro
if (options.throwJsErrors) {
throw e;
}
const error = e instanceof Error ? e : new Error(String(e));
const error = convertToError(e);
return {
hasErrors: true,
result: handleError({ e: error, name: options.componentName, serverSide: true }),
error,
};
}

function createResultObject(html: string | null, consoleReplayScript: string, renderState: RenderState): RenderResult {
function createResultObject(html: string | null, consoleReplayScript: string, renderState: RenderState | StreamRenderState): RenderResult {
return {
html,
consoleReplayScript,
hasErrors: renderState.hasErrors,
renderingError: renderState.error && { message: renderState.error.message, stack: renderState.error.stack },
isShellReady: 'isShellReady' in renderState ? renderState.isShellReady : undefined,
};
}

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

const stringToStream = (str: string): Readable => {
const stream = new PassThrough();
stream.push(str);
stream.push(null);
stream.write(str);
stream.end();
return stream;
};

const transformRenderStreamChunksToResultObject = (renderState: StreamRenderState) => {
const consoleHistory = console.history;
let previouslyReplayedConsoleMessages = 0;

const transformStream = new PassThrough({
transform(chunk, _, callback) {
const htmlChunk = chunk.toString();
const consoleReplayScript = buildConsoleReplay(consoleHistory, previouslyReplayedConsoleMessages);
previouslyReplayedConsoleMessages = consoleHistory?.length || 0;

const jsonChunk = JSON.stringify(createResultObject(htmlChunk, consoleReplayScript, renderState));

this.push(`${jsonChunk}\n`);
callback();
}
});

let pipedStream: PipeableStream | null = null;
const pipeToTransform = (pipeableStream: PipeableStream) => {
pipeableStream.pipe(transformStream);
pipedStream = pipeableStream;
};
// We need to wrap the transformStream in a Readable stream to properly handle errors:
// 1. If we returned transformStream directly, we couldn't emit errors into it externally
// 2. If an error is emitted into the transformStream, it would cause the render to fail
// 3. By wrapping in Readable.from(), we can explicitly emit errors into the readableStream without affecting the transformStream
// Note: Readable.from can merge multiple chunks into a single chunk, so we need to ensure that we can separate them later
const readableStream = Readable.from(transformStream);

const writeChunk = (chunk: string) => transformStream.write(chunk);
const emitError = (error: unknown) => readableStream.emit('error', error);
const endStream = () => {
transformStream.end();
pipedStream?.abort();
}
return { readableStream, pipeToTransform, writeChunk, emitError, endStream };
}

const streamRenderReactComponent = (reactRenderingResult: ReactElement, options: RenderParams) => {
const { name: componentName, throwJsErrors } = options;
const renderState: StreamRenderState = {
result: null,
hasErrors: false,
isShellReady: false
};

const {
readableStream,
pipeToTransform,
writeChunk,
emitError,
endStream
} = transformRenderStreamChunksToResultObject(renderState);

const renderingStream = ReactDOMServer.renderToPipeableStream(reactRenderingResult, {
onShellError(e) {
const error = convertToError(e);
renderState.hasErrors = true;
renderState.error = error;

if (throwJsErrors) {
emitError(error);
}

const errorHtml = handleError({ e: error, name: componentName, serverSide: true });
writeChunk(errorHtml);
endStream();
},
onShellReady() {
renderState.isShellReady = true;
pipeToTransform(renderingStream);
},
onError(e) {
if (!renderState.isShellReady) {
return;
}
const error = convertToError(e);
if (throwJsErrors) {
emitError(error);
}
renderState.hasErrors = true;
renderState.error = error;
},
});

return readableStream;
}

export const streamServerRenderedReactComponent = (options: RenderParams): Readable => {
const { name: componentName, domNodeId, trace, props, railsContext, throwJsErrors } = options;

let renderResult: null | Readable = null;
let previouslyReplayedConsoleMessages: number = 0;

try {
const componentObj = ComponentRegistry.get(componentName);
validateComponent(componentObj, componentName);
Expand All @@ -222,40 +317,17 @@ export const streamServerRenderedReactComponent = (options: RenderParams): Reada
throw new Error('Server rendering of streams is not supported for server render hashes or promises.');
}

const consoleHistory = console.history;
const transformStream = new Transform({
transform(chunk, _, callback) {
const htmlChunk = chunk.toString();
const consoleReplayScript = buildConsoleReplay(consoleHistory, previouslyReplayedConsoleMessages);
previouslyReplayedConsoleMessages = consoleHistory?.length || 0;

const jsonChunk = JSON.stringify({
html: htmlChunk,
consoleReplayScript,
});

this.push(jsonChunk);
callback();
}
});

ReactDOMServer.renderToPipeableStream(reactRenderingResult).pipe(transformStream);

renderResult = transformStream;
return streamRenderReactComponent(reactRenderingResult, options);
} catch (e) {
if (throwJsErrors) {
throw e;
}

const error = e instanceof Error ? e : new Error(String(e));
renderResult = stringToStream(handleError({
e: error,
name: componentName,
serverSide: true,
}));
const error = convertToError(e);
const htmlResult = handleError({ e: error, name: componentName, serverSide: true });
const jsonResult = JSON.stringify(createResultObject(htmlResult, buildConsoleReplay(), { hasErrors: true, error, result: null }));
return stringToStream(jsonResult);
}

return renderResult;
};

export default serverRenderReactComponent;
1 change: 1 addition & 0 deletions node_package/src/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,7 @@ export interface RenderResult {
consoleReplayScript: string;
hasErrors: boolean;
renderingError?: RenderingError;
isShellReady?: boolean;
}

// from react-dom 18
Expand Down
Loading

0 comments on commit a87c1e6

Please sign in to comment.