From 50bf32fae23f78f57012201b35bf7d285b26d08d Mon Sep 17 00:00:00 2001 From: Abanoub Ghadban Date: Mon, 26 Aug 2024 17:29:18 +0300 Subject: [PATCH 01/16] handle errors happen during streaming components --- lib/react_on_rails/helper.rb | 41 ++++++++++---- .../react_component/render_options.rb | 11 ++++ .../src/serverRenderReactComponent.ts | 55 +++++++++++++++---- 3 files changed, 84 insertions(+), 23 deletions(-) diff --git a/lib/react_on_rails/helper.rb b/lib/react_on_rails/helper.rb index 2a9e26c5d..cb83f89e1 100644 --- a/lib/react_on_rails/helper.rb +++ b/lib/react_on_rails/helper.rb @@ -570,6 +570,22 @@ 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"] && + ((render_options.raise_on_prerender_error && !chunk_json_result["isShellReady"]) || + (render_options.raise_non_shell_server_rendering_errors && chunk_json_result["isShellReady"])) + end + # Returns object with values that are NOT html_safe! def server_rendered_react_component(render_options) return { "html" => "", "consoleReplayScript" => "" } unless render_options.prerender @@ -617,19 +633,20 @@ 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? + # It doesn't make any transformation, it just listening to the streamed chunks and raise error if it has errors + 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 + chunk_json_result + end + else + if result["hasErrors"] && render_options.raise_on_prerender_error + raise_prerender_error(result, react_component_name, props, js_code) + end end + result end diff --git a/lib/react_on_rails/react_component/render_options.rb b/lib/react_on_rails/react_component/render_options.rb index 8bb8536ed..97b61e86a 100644 --- a/lib/react_on_rails/react_component/render_options.rb +++ b/lib/react_on_rails/react_component/render_options.rb @@ -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 @@ -128,6 +132,13 @@ 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 diff --git a/node_package/src/serverRenderReactComponent.ts b/node_package/src/serverRenderReactComponent.ts index dd21e92d3..6b30a0bd2 100644 --- a/node_package/src/serverRenderReactComponent.ts +++ b/node_package/src/serverRenderReactComponent.ts @@ -1,5 +1,5 @@ import ReactDOMServer from 'react-dom/server'; -import { PassThrough, Readable, Transform } from 'stream'; +import { PassThrough, Readable } from 'stream'; import type { ReactElement } from 'react'; import ComponentRegistry from './ComponentRegistry'; @@ -195,8 +195,8 @@ 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; }; @@ -204,7 +204,10 @@ export const streamServerRenderedReactComponent = (options: RenderParams): Reada const { name: componentName, domNodeId, trace, props, railsContext, throwJsErrors } = options; let renderResult: null | Readable = null; + let hasErrors = false; + let isShellReady = false; let previouslyReplayedConsoleMessages: number = 0; + let consoleHistory: typeof console['history'] | undefined; try { const componentObj = ComponentRegistry.get(componentName); @@ -222,24 +225,49 @@ 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({ + consoleHistory = console.history; + const transformStream = new PassThrough({ transform(chunk, _, callback) { const htmlChunk = chunk.toString(); + console.log('htmlChunk', htmlChunk); const consoleReplayScript = buildConsoleReplay(consoleHistory, previouslyReplayedConsoleMessages); previouslyReplayedConsoleMessages = consoleHistory?.length || 0; const jsonChunk = JSON.stringify({ html: htmlChunk, consoleReplayScript, + hasErrors, + isShellReady, }); - + this.push(jsonChunk); callback(); } }); - ReactDOMServer.renderToPipeableStream(reactRenderingResult).pipe(transformStream); + const renderingStream = ReactDOMServer.renderToPipeableStream(reactRenderingResult, { + onShellError(e) { + // Can't through error here if throwJsErrors is true because the error will happen inside the stream + // And will not be handled by any catch clause + hasErrors = true; + const error = e instanceof Error ? e : new Error(String(e)); + transformStream.write(handleError({ + e: error, + name: componentName, + serverSide: true, + })); + transformStream.end(); + }, + onShellReady() { + isShellReady = true; + renderingStream.pipe(transformStream); + }, + onError() { + // Can't through error here if throwJsErrors is true because the error will happen inside the stream + // And will not be handled by any catch clause + hasErrors = true; + }, + }); renderResult = transformStream; } catch (e) { @@ -248,10 +276,15 @@ export const streamServerRenderedReactComponent = (options: RenderParams): Reada } const error = e instanceof Error ? e : new Error(String(e)); - renderResult = stringToStream(handleError({ - e: error, - name: componentName, - serverSide: true, + renderResult = stringToStream(JSON.stringify({ + html: handleError({ + e: error, + name: componentName, + serverSide: true, + }), + consoleReplayScript: buildConsoleReplay(consoleHistory, previouslyReplayedConsoleMessages), + hasErrors: true, + isShellReady, })); } From 2ec583ebe30b99271a7a48144d3c0b37593b0932 Mon Sep 17 00:00:00 2001 From: Abanoub Ghadban Date: Wed, 28 Aug 2024 18:49:25 +0300 Subject: [PATCH 02/16] remove debugging statements --- lib/react_on_rails/helper.rb | 2 +- node_package/src/serverRenderReactComponent.ts | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/lib/react_on_rails/helper.rb b/lib/react_on_rails/helper.rb index cb83f89e1..a93485c8d 100644 --- a/lib/react_on_rails/helper.rb +++ b/lib/react_on_rails/helper.rb @@ -634,11 +634,11 @@ def server_rendered_react_component(render_options) end if render_options.stream? - # It doesn't make any transformation, it just listening to the streamed chunks and raise error if it has errors 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 to the streamed chunks and raise error if it has errors chunk_json_result end else diff --git a/node_package/src/serverRenderReactComponent.ts b/node_package/src/serverRenderReactComponent.ts index 6b30a0bd2..93792ee05 100644 --- a/node_package/src/serverRenderReactComponent.ts +++ b/node_package/src/serverRenderReactComponent.ts @@ -229,7 +229,6 @@ export const streamServerRenderedReactComponent = (options: RenderParams): Reada const transformStream = new PassThrough({ transform(chunk, _, callback) { const htmlChunk = chunk.toString(); - console.log('htmlChunk', htmlChunk); const consoleReplayScript = buildConsoleReplay(consoleHistory, previouslyReplayedConsoleMessages); previouslyReplayedConsoleMessages = consoleHistory?.length || 0; From ad9b93c5b78e530869f6edf637420d7acb48d8b1 Mon Sep 17 00:00:00 2001 From: Abanoub Ghadban Date: Thu, 29 Aug 2024 13:46:24 +0300 Subject: [PATCH 03/16] linting --- lib/react_on_rails/helper.rb | 14 ++++++-------- .../react_component/render_options.rb | 1 + 2 files changed, 7 insertions(+), 8 deletions(-) diff --git a/lib/react_on_rails/helper.rb b/lib/react_on_rails/helper.rb index a93485c8d..df0dcf1cc 100644 --- a/lib/react_on_rails/helper.rb +++ b/lib/react_on_rails/helper.rb @@ -581,8 +581,8 @@ def raise_prerender_error(json_result, react_component_name, props, js_code) end def should_raise_streaming_prerender_error?(chunk_json_result, render_options) - chunk_json_result["hasErrors"] && - ((render_options.raise_on_prerender_error && !chunk_json_result["isShellReady"]) || + chunk_json_result["hasErrors"] && + ((render_options.raise_on_prerender_error && !chunk_json_result["isShellReady"]) || (render_options.raise_non_shell_server_rendering_errors && chunk_json_result["isShellReady"])) end @@ -638,15 +638,13 @@ def server_rendered_react_component(render_options) 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 to the streamed chunks and raise error if it has errors + # It doesn't make any transformation, it listens to the streamed chunks and raise error if it has errors chunk_json_result end - else - if result["hasErrors"] && render_options.raise_on_prerender_error - raise_prerender_error(result, react_component_name, props, js_code) - end + elsif result["hasErrors"] && render_options.raise_on_prerender_error + raise_prerender_error(result, react_component_name, props, js_code) end - + result end diff --git a/lib/react_on_rails/react_component/render_options.rb b/lib/react_on_rails/react_component/render_options.rb index 97b61e86a..f93ba85c2 100644 --- a/lib/react_on_rails/react_component/render_options.rb +++ b/lib/react_on_rails/react_component/render_options.rb @@ -136,6 +136,7 @@ def retrieve_configuration_value_for(key) 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 From 2b111e3216c9d8f5ab6e328a2a10c99fae6a958b Mon Sep 17 00:00:00 2001 From: Abanoub Ghadban Date: Thu, 31 Oct 2024 13:10:14 +0300 Subject: [PATCH 04/16] emit errors when an error happen and refactor --- .../src/serverRenderReactComponent.ts | 168 +++++++++++------- node_package/src/types/index.ts | 1 + 2 files changed, 105 insertions(+), 64 deletions(-) diff --git a/node_package/src/serverRenderReactComponent.ts b/node_package/src/serverRenderReactComponent.ts index 93792ee05..1dbab56e3 100644 --- a/node_package/src/serverRenderReactComponent.ts +++ b/node_package/src/serverRenderReactComponent.ts @@ -1,4 +1,4 @@ -import ReactDOMServer from 'react-dom/server'; +import ReactDOMServer, { type PipeableStream } from 'react-dom/server'; import { PassThrough, Readable } from 'stream'; import type { ReactElement } from 'react'; @@ -15,6 +15,11 @@ type RenderState = { error?: RenderingError; }; +type StreamRenderState = Omit & { + result: null | Readable; + isShellReady: boolean; +}; + type RenderOptions = { componentName: string; domNodeId?: string; @@ -95,12 +100,13 @@ function handleRenderingError(e: unknown, options: { componentName: string, thro }; } -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, }; } @@ -200,15 +206,101 @@ const stringToStream = (str: string): Readable => { 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); + 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 + // Also, can't use Readable.from(transformStream) because if transformStream has multiple chunks, they will be merged into a single chunk in the readableStream + const readableStream = new PassThrough(); + transformStream.on('data', (chunk) => readableStream.push(chunk)); + transformStream.on('end', () => readableStream.push(null)); + transformStream.on('error', (error) => readableStream.emit('error', error)); + + const writeChunk = (chunk: string) => transformStream.write(chunk); + const emitError = (error: unknown) => readableStream.emit('error', error); + const endStream = () => { + readableStream.end(); + transformStream.end(); + pipedStream?.abort(); + } + return { readableStream: readableStream as Readable, pipeToTransform, writeChunk, emitError, endStream }; +} + +const streamRenderReactComponen = (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 = e instanceof Error ? e : new Error(String(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 = e instanceof Error ? e : new Error(String(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 hasErrors = false; - let isShellReady = false; - let previouslyReplayedConsoleMessages: number = 0; - let consoleHistory: typeof console['history'] | undefined; - try { const componentObj = ComponentRegistry.get(componentName); validateComponent(componentObj, componentName); @@ -225,69 +317,17 @@ export const streamServerRenderedReactComponent = (options: RenderParams): Reada throw new Error('Server rendering of streams is not supported for server render hashes or promises.'); } - consoleHistory = console.history; - const transformStream = new PassThrough({ - transform(chunk, _, callback) { - const htmlChunk = chunk.toString(); - const consoleReplayScript = buildConsoleReplay(consoleHistory, previouslyReplayedConsoleMessages); - previouslyReplayedConsoleMessages = consoleHistory?.length || 0; - - const jsonChunk = JSON.stringify({ - html: htmlChunk, - consoleReplayScript, - hasErrors, - isShellReady, - }); - - this.push(jsonChunk); - callback(); - } - }); - - const renderingStream = ReactDOMServer.renderToPipeableStream(reactRenderingResult, { - onShellError(e) { - // Can't through error here if throwJsErrors is true because the error will happen inside the stream - // And will not be handled by any catch clause - hasErrors = true; - const error = e instanceof Error ? e : new Error(String(e)); - transformStream.write(handleError({ - e: error, - name: componentName, - serverSide: true, - })); - transformStream.end(); - }, - onShellReady() { - isShellReady = true; - renderingStream.pipe(transformStream); - }, - onError() { - // Can't through error here if throwJsErrors is true because the error will happen inside the stream - // And will not be handled by any catch clause - hasErrors = true; - }, - }); - - renderResult = transformStream; + return streamRenderReactComponen(reactRenderingResult, options); } catch (e) { if (throwJsErrors) { throw e; } const error = e instanceof Error ? e : new Error(String(e)); - renderResult = stringToStream(JSON.stringify({ - html: handleError({ - e: error, - name: componentName, - serverSide: true, - }), - consoleReplayScript: buildConsoleReplay(consoleHistory, previouslyReplayedConsoleMessages), - hasErrors: true, - isShellReady, - })); + 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; diff --git a/node_package/src/types/index.ts b/node_package/src/types/index.ts index 2f808dc06..a8f7ddffc 100644 --- a/node_package/src/types/index.ts +++ b/node_package/src/types/index.ts @@ -138,6 +138,7 @@ export interface RenderResult { consoleReplayScript: string; hasErrors: boolean; renderingError?: RenderingError; + isShellReady?: boolean; } // from react-dom 18 From cc05983d183590b2462e4cda5f7b6d8151b10524 Mon Sep 17 00:00:00 2001 From: Abanoub Ghadban Date: Thu, 31 Oct 2024 13:11:27 +0300 Subject: [PATCH 05/16] add unit tests for streamServerRenderedReactComponent --- .../src/serverRenderReactComponent.ts | 10 +- ...treamServerRenderedReactComponent.test.jsx | 152 ++++++++++++++++++ package.json | 4 +- yarn.lock | 33 ++-- 4 files changed, 171 insertions(+), 28 deletions(-) create mode 100644 node_package/tests/streamServerRenderedReactComponent.test.jsx diff --git a/node_package/src/serverRenderReactComponent.ts b/node_package/src/serverRenderReactComponent.ts index 1dbab56e3..9443d1073 100644 --- a/node_package/src/serverRenderReactComponent.ts +++ b/node_package/src/serverRenderReactComponent.ts @@ -218,7 +218,7 @@ const transformRenderStreamChunksToResultObject = (renderState: StreamRenderStat const jsonChunk = JSON.stringify(createResultObject(htmlChunk, consoleReplayScript, renderState)); - this.push(jsonChunk); + this.push(`${jsonChunk}\n`); callback(); } }); @@ -232,16 +232,12 @@ const transformRenderStreamChunksToResultObject = (renderState: StreamRenderStat // 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 - // Also, can't use Readable.from(transformStream) because if transformStream has multiple chunks, they will be merged into a single chunk in the readableStream - const readableStream = new PassThrough(); - transformStream.on('data', (chunk) => readableStream.push(chunk)); - transformStream.on('end', () => readableStream.push(null)); - transformStream.on('error', (error) => readableStream.emit('error', error)); + // 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 = () => { - readableStream.end(); transformStream.end(); pipedStream?.abort(); } diff --git a/node_package/tests/streamServerRenderedReactComponent.test.jsx b/node_package/tests/streamServerRenderedReactComponent.test.jsx new file mode 100644 index 000000000..93bdc49eb --- /dev/null +++ b/node_package/tests/streamServerRenderedReactComponent.test.jsx @@ -0,0 +1,152 @@ +/** + * @jest-environment node + */ + +import React, { Suspense } from 'react'; +import PropTypes from 'prop-types'; +import { streamServerRenderedReactComponent } from '../src/serverRenderReactComponent'; +import ComponentRegistry from '../src/ComponentRegistry'; + +const AsyncContent = async ({ throwAsyncError }) => { + await new Promise((resolve) => setTimeout(resolve, 0)); + if (throwAsyncError) { + throw new Error('Async Error'); + } + return
Async Content
; +}; + +const TestComponentForStreaming = ({ throwSyncError, throwAsyncError }) => { + if (throwSyncError) { + throw new Error('Sync Error'); + } + + return ( +
+

Header In The Shell

+ Loading...
}> + + + + ); +} + +TestComponentForStreaming.propTypes = { + throwSyncError: PropTypes.bool, + throwAsyncError: PropTypes.bool, +}; + +describe('streamServerRenderedReactComponent', () => { + beforeEach(() => { + ComponentRegistry.components().clear(); + }); + + const expectStreamChunk = (chunk) => { + expect(typeof chunk).toBe('string'); + const jsonChunk = JSON.parse(chunk); + expect(typeof jsonChunk.html).toBe('string'); + expect(typeof jsonChunk.consoleReplayScript).toBe('string'); + expect(typeof jsonChunk.hasErrors).toBe('boolean'); + expect(typeof jsonChunk.isShellReady).toBe('boolean'); + return jsonChunk; + } + + const setupStreamTest = ({ throwSyncError = false, throwJsErrors = false, throwAsyncError = false } = {}) => { + ComponentRegistry.register({ TestComponentForStreaming }); + const renderResult = streamServerRenderedReactComponent({ + name: 'TestComponentForStreaming', + domNodeId: 'myDomId', + trace: false, + props: { throwSyncError, throwAsyncError }, + throwJsErrors + }); + + const chunks = []; + renderResult.on('data', (chunk) => { + const decodedText = new TextDecoder().decode(chunk); + chunks.push(expectStreamChunk(decodedText)); + }); + + return { renderResult, chunks }; + } + + it('streamServerRenderedReactComponent streams the rendered component', async () => { + const { renderResult, chunks } = setupStreamTest(); + await new Promise(resolve => renderResult.on('end', resolve)); + + expect(chunks).toHaveLength(2); + expect(chunks[0].html).toContain('Header In The Shell'); + expect(chunks[0].consoleReplayScript).toBe(''); + expect(chunks[0].hasErrors).toBe(false); + expect(chunks[0].isShellReady).toBe(true); + expect(chunks[1].html).toContain('Async Content'); + expect(chunks[1].consoleReplayScript).toBe(''); + expect(chunks[1].hasErrors).toBe(false); + expect(chunks[1].isShellReady).toBe(true); + }); + + it('emits an error if there is an error in the shell and throwJsErrors is true', async () => { + const { renderResult, chunks } = setupStreamTest({ throwSyncError: true, throwJsErrors: true }); + const onError = jest.fn(); + renderResult.on('error', onError); + await new Promise(resolve => renderResult.on('end', resolve)); + + expect(onError).toHaveBeenCalled(); + expect(chunks).toHaveLength(1); + expect(chunks[0].html).toMatch(/
Exception in rendering[.\s\S]*Sync Error[.\s\S]*<\/pre>/);
+    expect(chunks[0].consoleReplayScript).toBe('');
+    expect(chunks[0].hasErrors).toBe(true);
+    expect(chunks[0].isShellReady).toBe(false);
+  });
+
+  it("doesn't emit an error if there is an error in the shell and throwJsErrors is false", async () => {
+    const { renderResult, chunks } = setupStreamTest({ throwSyncError: true, throwJsErrors: false });
+    const onError = jest.fn();
+    renderResult.on('error', onError);
+    await new Promise(resolve => renderResult.on('end', resolve));
+
+    expect(onError).not.toHaveBeenCalled();
+    expect(chunks).toHaveLength(1);
+    expect(chunks[0].html).toMatch(/
Exception in rendering[.\s\S]*Sync Error[.\s\S]*<\/pre>/);
+    expect(chunks[0].consoleReplayScript).toBe('');
+    expect(chunks[0].hasErrors).toBe(true);
+    expect(chunks[0].isShellReady).toBe(false);
+  });
+
+  it('emits an error if there is an error in the async content and throwJsErrors is true', async () => {
+    const { renderResult, chunks } = setupStreamTest({ throwAsyncError: true, throwJsErrors: true });
+    const onError = jest.fn();
+    renderResult.on('error', onError);
+    await new Promise(resolve => renderResult.on('end', resolve));
+
+    expect(onError).toHaveBeenCalled();
+    expect(chunks).toHaveLength(2);
+    expect(chunks[0].html).toContain('Header In The Shell');
+    expect(chunks[0].consoleReplayScript).toBe('');
+    expect(chunks[0].hasErrors).toBe(false);
+    expect(chunks[0].isShellReady).toBe(true);
+    // Script that fallbacks the render to client side
+    expect(chunks[1].html).toMatch(/