From 2e4f94c49c453172468558113e913b1795888c6f Mon Sep 17 00:00:00 2001 From: Abanoub Ghadban Date: Sun, 25 Aug 2024 20:59:20 +0300 Subject: [PATCH 1/6] pass console messages from server to client and replay them --- lib/react_on_rails/helper.rb | 31 +++++------ .../ruby_embedded_java_script.rb | 55 +++++++++++-------- node_package/src/buildConsoleReplay.ts | 8 +-- .../src/serverRenderReactComponent.ts | 29 ++++++++-- 4 files changed, 73 insertions(+), 50 deletions(-) diff --git a/lib/react_on_rails/helper.rb b/lib/react_on_rails/helper.rb index d16e77609..3d4c0ae39 100644 --- a/lib/react_on_rails/helper.rb +++ b/lib/react_on_rails/helper.rb @@ -434,26 +434,24 @@ def build_react_component_result_for_server_streamed_content( component_specification_tag: required("component_specification_tag"), render_options: required("render_options") ) - content_tag_options_html_tag = render_options.html_options[:tag] || "div" - # The component_specification_tag is appended to the first chunk - # We need to pass it early with the first chunk because it's needed in hydration - # We need to make sure that client can hydrate the app early even before all components are streamed is_first_chunk = true - rendered_html_stream = rendered_html_stream.transform do |chunk| + rendered_html_stream = rendered_html_stream.transform do |chunk_json_result| if is_first_chunk is_first_chunk = false - html_content = <<-HTML - #{rails_context_if_not_already_rendered} - #{component_specification_tag} - <#{content_tag_options_html_tag} id="#{render_options.dom_id}">#{chunk} - HTML - next html_content.strip + next build_react_component_result_for_server_rendered_string( + server_rendered_html: chunk_json_result["html"], + component_specification_tag: component_specification_tag, + console_script: chunk_json_result["consoleReplayScript"], + render_options: render_options + ) end - chunk - end - rendered_html_stream.transform(&:html_safe) - # TODO: handle console logs + result_console_script = render_options.replay_console ? chunk_json_result["consoleReplayScript"] : "" + # No need to prepend component_specification_tag or add rails context again as they're already included in the first chunk + compose_react_component_html_with_spec_and_console( + "", chunk_json_result["html"], result_console_script + ) + end end def build_react_component_result_for_server_rendered_hash( @@ -492,11 +490,12 @@ def build_react_component_result_for_server_rendered_hash( def compose_react_component_html_with_spec_and_console(component_specification_tag, rendered_output, console_script) # IMPORTANT: Ensure that we mark string as html_safe to avoid escaping. - <<~HTML.html_safe + html_content = <<~HTML #{rendered_output} #{component_specification_tag} #{console_script} HTML + html_content.strip.html_safe end def rails_context_if_not_already_rendered diff --git a/lib/react_on_rails/server_rendering_pool/ruby_embedded_java_script.rb b/lib/react_on_rails/server_rendering_pool/ruby_embedded_java_script.rb index 2dcd3eb80..3b03b21ae 100644 --- a/lib/react_on_rails/server_rendering_pool/ruby_embedded_java_script.rb +++ b/lib/react_on_rails/server_rendering_pool/ruby_embedded_java_script.rb @@ -56,7 +56,11 @@ def exec_server_render_js(js_code, render_options, js_evaluator = nil) @file_index += 1 end begin - json_string = js_evaluator.eval_js(js_code, render_options) + result = if render_options.stream? + js_evaluator.eval_streaming_js(js_code, render_options) + else + js_evaluator.eval_js(js_code, render_options) + end rescue StandardError => err msg = <<~MSG Error evaluating server bundle. Check your webpack configuration. @@ -71,33 +75,15 @@ def exec_server_render_js(js_code, render_options, js_evaluator = nil) end raise ReactOnRails::Error, msg, err.backtrace end - result = nil - begin - result = JSON.parse(json_string) - rescue JSON::ParserError => e - raise ReactOnRails::JsonParseError.new(parse_error: e, json: json_string) - end + + return parse_result_and_replay_console_messages(result, render_options) unless render_options.stream? - if render_options.logging_on_server - console_script = result["consoleReplayScript"] - console_script_lines = console_script.split("\n") - console_script_lines = console_script_lines[2..-2] - re = /console\.(?:log|error)\.apply\(console, \["\[SERVER\] (?.*)"\]\);/ - console_script_lines&.each do |line| - match = re.match(line) - Rails.logger.info { "[react_on_rails] #{match[:msg]}" } if match - end - end - result + # Streamed component is returned as stream of strings. + # We need to parse each chunk and replay the console messages. + result.transform { |chunk| parse_result_and_replay_console_messages(chunk, render_options) } end # rubocop:enable Metrics/CyclomaticComplexity, Metrics/AbcSize, Metrics/PerceivedComplexity - # TODO: merge with exec_server_render_js - def exec_server_render_streaming_js(js_code, render_options, js_evaluator = nil) - js_evaluator ||= self - js_evaluator.eval_streaming_js(js_code, render_options) - end - def trace_js_code_used(msg, js_code, file_name = "tmp/server-generated.js", force: false) return unless ReactOnRails.configuration.trace || force @@ -239,6 +225,27 @@ def file_url_to_string(url) msg = "file_url_to_string #{url} failed\nError is: #{e}" raise ReactOnRails::Error, msg end + + def parse_result_and_replay_console_messages(result_string, render_options) + result = nil + begin + result = JSON.parse(result_string) + rescue JSON::ParserError => e + raise ReactOnRails::JsonParseError.new(parse_error: e, json: result_string) + end + + if render_options.logging_on_server + console_script = result["consoleReplayScript"] + console_script_lines = console_script.split("\n") + console_script_lines = console_script_lines[2..-2] + re = /console\.(?:log|error)\.apply\(console, \["\[SERVER\] (?.*)"\]\);/ + console_script_lines&.each do |line| + match = re.match(line) + Rails.logger.info { "[react_on_rails] #{match[:msg]}" } if match + end + end + result + end end # rubocop:enable Metrics/ClassLength end diff --git a/node_package/src/buildConsoleReplay.ts b/node_package/src/buildConsoleReplay.ts index f39cec428..8071b1574 100644 --- a/node_package/src/buildConsoleReplay.ts +++ b/node_package/src/buildConsoleReplay.ts @@ -9,7 +9,7 @@ declare global { } } -export function consoleReplay(customConsoleHistory: typeof console['history'] | undefined = undefined): string { +export function consoleReplay(customConsoleHistory: typeof console['history'] | undefined = undefined, skipFirstNumberOfMessages: number = 0): string { // console.history is a global polyfill used in server rendering. const consoleHistory = customConsoleHistory ?? console.history; @@ -17,7 +17,7 @@ export function consoleReplay(customConsoleHistory: typeof console['history'] | return ''; } - const lines = consoleHistory.map(msg => { + const lines = consoleHistory.slice(skipFirstNumberOfMessages).map(msg => { const stringifiedList = msg.arguments.map(arg => { let val: string; try { @@ -44,6 +44,6 @@ export function consoleReplay(customConsoleHistory: typeof console['history'] | return lines.join('\n'); } -export default function buildConsoleReplay(customConsoleHistory: typeof console['history'] | undefined = undefined): string { - return RenderUtils.wrapInScriptTags('consoleReplayLog', consoleReplay(customConsoleHistory)); +export default function buildConsoleReplay(customConsoleHistory: typeof console['history'] | undefined = undefined, skipFirstNumberOfMessages: number = 0): string { + return RenderUtils.wrapInScriptTags('consoleReplayLog', consoleReplay(customConsoleHistory, skipFirstNumberOfMessages)); } diff --git a/node_package/src/serverRenderReactComponent.ts b/node_package/src/serverRenderReactComponent.ts index ed392793a..710314058 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 } from 'stream'; +import { PassThrough, Readable, Transform } from 'stream'; import type { ReactElement } from 'react'; import ComponentRegistry from './ComponentRegistry'; @@ -204,6 +204,7 @@ export const streamServerRenderedReactComponent = (options: RenderParams): Reada const { name: componentName, domNodeId, trace, props, railsContext, throwJsErrors } = options; let renderResult: null | Readable = null; + let previouslyReplayedConsoleMessages: number = 0; try { const componentObj = ComponentRegistry.get(componentName); @@ -221,12 +222,28 @@ export const streamServerRenderedReactComponent = (options: RenderParams): Reada throw new Error('Server rendering of streams is not supported for server render hashes or promises.'); } - const renderStream = new PassThrough(); - ReactDOMServer.renderToPipeableStream(reactRenderingResult).pipe(renderStream); - renderResult = renderStream; + 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); - // TODO: Add console replay script to the stream - } catch (e) { + renderResult = transformStream; + } catch (e: unknown) { if (throwJsErrors) { throw e; } From 05138f292e77c9d41e082977edf54043df575d20 Mon Sep 17 00:00:00 2001 From: Abanoub Ghadban Date: Sun, 25 Aug 2024 21:17:35 +0300 Subject: [PATCH 2/6] linting --- lib/react_on_rails/helper.rb | 5 +++-- .../ruby_embedded_java_script.rb | 14 +++++++------- 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/lib/react_on_rails/helper.rb b/lib/react_on_rails/helper.rb index 3d4c0ae39..a49e58cbe 100644 --- a/lib/react_on_rails/helper.rb +++ b/lib/react_on_rails/helper.rb @@ -435,7 +435,7 @@ def build_react_component_result_for_server_streamed_content( render_options: required("render_options") ) is_first_chunk = true - rendered_html_stream = rendered_html_stream.transform do |chunk_json_result| + rendered_html_stream.transform do |chunk_json_result| if is_first_chunk is_first_chunk = false next build_react_component_result_for_server_rendered_string( @@ -447,7 +447,8 @@ def build_react_component_result_for_server_streamed_content( end result_console_script = render_options.replay_console ? chunk_json_result["consoleReplayScript"] : "" - # No need to prepend component_specification_tag or add rails context again as they're already included in the first chunk + # No need to prepend component_specification_tag or add rails context again + # as they're already included in the first chunk compose_react_component_html_with_spec_and_console( "", chunk_json_result["html"], result_console_script ) diff --git a/lib/react_on_rails/server_rendering_pool/ruby_embedded_java_script.rb b/lib/react_on_rails/server_rendering_pool/ruby_embedded_java_script.rb index 3b03b21ae..d1136c231 100644 --- a/lib/react_on_rails/server_rendering_pool/ruby_embedded_java_script.rb +++ b/lib/react_on_rails/server_rendering_pool/ruby_embedded_java_script.rb @@ -46,7 +46,7 @@ def reset_pool_if_server_bundle_was_modified # Note, js_code does not have to be based on React. # js_code MUST RETURN json stringify Object # Calling code will probably call 'html_safe' on return value before rendering to the view. - # rubocop:disable Metrics/CyclomaticComplexity, Metrics/AbcSize, Metrics/PerceivedComplexity + # rubocop:disable Metrics/CyclomaticComplexity def exec_server_render_js(js_code, render_options, js_evaluator = nil) js_evaluator ||= self if render_options.trace @@ -57,10 +57,10 @@ def exec_server_render_js(js_code, render_options, js_evaluator = nil) end begin result = if render_options.stream? - js_evaluator.eval_streaming_js(js_code, render_options) - else - js_evaluator.eval_js(js_code, render_options) - end + js_evaluator.eval_streaming_js(js_code, render_options) + else + js_evaluator.eval_js(js_code, render_options) + end rescue StandardError => err msg = <<~MSG Error evaluating server bundle. Check your webpack configuration. @@ -75,14 +75,14 @@ def exec_server_render_js(js_code, render_options, js_evaluator = nil) end raise ReactOnRails::Error, msg, err.backtrace end - + return parse_result_and_replay_console_messages(result, render_options) unless render_options.stream? # Streamed component is returned as stream of strings. # We need to parse each chunk and replay the console messages. result.transform { |chunk| parse_result_and_replay_console_messages(chunk, render_options) } end - # rubocop:enable Metrics/CyclomaticComplexity, Metrics/AbcSize, Metrics/PerceivedComplexity + # rubocop:enable Metrics/CyclomaticComplexity def trace_js_code_used(msg, js_code, file_name = "tmp/server-generated.js", force: false) return unless ReactOnRails.configuration.trace || force From 9366c8050a46f321f40ae3cf29eb0fe134e20f48 Mon Sep 17 00:00:00 2001 From: Abanoub Ghadban Date: Sun, 20 Oct 2024 17:34:41 +0300 Subject: [PATCH 3/6] add some comments and remove unneeded calls --- lib/react_on_rails/helper.rb | 24 +++++++++---------- .../ruby_embedded_java_script.rb | 3 +++ 2 files changed, 15 insertions(+), 12 deletions(-) diff --git a/lib/react_on_rails/helper.rb b/lib/react_on_rails/helper.rb index a49e58cbe..3c177843e 100644 --- a/lib/react_on_rails/helper.rb +++ b/lib/react_on_rails/helper.rb @@ -430,29 +430,28 @@ def build_react_component_result_for_server_rendered_string( end def build_react_component_result_for_server_streamed_content( - rendered_html_stream: required("rendered_html_stream"), - component_specification_tag: required("component_specification_tag"), - render_options: required("render_options") + rendered_html_stream:, + component_specification_tag:, + render_options: ) is_first_chunk = true rendered_html_stream.transform do |chunk_json_result| if is_first_chunk is_first_chunk = false - next build_react_component_result_for_server_rendered_string( + build_react_component_result_for_server_rendered_string( server_rendered_html: chunk_json_result["html"], component_specification_tag: component_specification_tag, console_script: chunk_json_result["consoleReplayScript"], render_options: render_options ) + else + result_console_script = render_options.replay_console ? chunk_json_result["consoleReplayScript"] : "" + # No need to prepend component_specification_tag or add rails context again + # as they're already included in the first chunk + compose_react_component_html_with_spec_and_console( + "", chunk_json_result["html"], result_console_script + ) end - - result_console_script = render_options.replay_console ? chunk_json_result["consoleReplayScript"] : "" - # No need to prepend component_specification_tag or add rails context again - # as they're already included in the first chunk - compose_react_component_html_with_spec_and_console( - "", chunk_json_result["html"], result_console_script - ) - end end def build_react_component_result_for_server_rendered_hash( @@ -687,3 +686,4 @@ def raise_missing_autoloaded_bundle(react_component_name) end # rubocop:enable Metrics/ModuleLength # rubocop:enable Metrics/MethodLength + diff --git a/lib/react_on_rails/server_rendering_pool/ruby_embedded_java_script.rb b/lib/react_on_rails/server_rendering_pool/ruby_embedded_java_script.rb index d1136c231..21dcf2b62 100644 --- a/lib/react_on_rails/server_rendering_pool/ruby_embedded_java_script.rb +++ b/lib/react_on_rails/server_rendering_pool/ruby_embedded_java_script.rb @@ -237,10 +237,13 @@ def parse_result_and_replay_console_messages(result_string, render_options) if render_options.logging_on_server console_script = result["consoleReplayScript"] console_script_lines = console_script.split("\n") + # Skip the first two lines (new line and opening tag) console_script_lines = console_script_lines[2..-2] + # Regular expression to match console.log or console.error calls with SERVER prefix re = /console\.(?:log|error)\.apply\(console, \["\[SERVER\] (?.*)"\]\);/ console_script_lines&.each do |line| match = re.match(line) + # Log matched messages to Rails logger with react_on_rails prefix Rails.logger.info { "[react_on_rails] #{match[:msg]}" } if match end end From c8b869955de690d9dee5544d949887a05703e1ef Mon Sep 17 00:00:00 2001 From: Abanoub Ghadban Date: Sun, 20 Oct 2024 17:36:40 +0300 Subject: [PATCH 4/6] fix syntax error --- lib/react_on_rails/helper.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/react_on_rails/helper.rb b/lib/react_on_rails/helper.rb index 3c177843e..2a9e26c5d 100644 --- a/lib/react_on_rails/helper.rb +++ b/lib/react_on_rails/helper.rb @@ -452,6 +452,7 @@ def build_react_component_result_for_server_streamed_content( "", chunk_json_result["html"], result_console_script ) end + end end def build_react_component_result_for_server_rendered_hash( @@ -686,4 +687,3 @@ def raise_missing_autoloaded_bundle(react_component_name) end # rubocop:enable Metrics/ModuleLength # rubocop:enable Metrics/MethodLength - From b0f03600da5190daa92a954261c7c08c91b867b0 Mon Sep 17 00:00:00 2001 From: Abanoub Ghadban Date: Sun, 20 Oct 2024 21:36:54 +0300 Subject: [PATCH 5/6] tiny changes --- .../server_rendering_pool/ruby_embedded_java_script.rb | 2 -- node_package/src/buildConsoleReplay.ts | 8 ++++---- node_package/src/serverRenderReactComponent.ts | 5 ++--- 3 files changed, 6 insertions(+), 9 deletions(-) diff --git a/lib/react_on_rails/server_rendering_pool/ruby_embedded_java_script.rb b/lib/react_on_rails/server_rendering_pool/ruby_embedded_java_script.rb index 21dcf2b62..169c81d7d 100644 --- a/lib/react_on_rails/server_rendering_pool/ruby_embedded_java_script.rb +++ b/lib/react_on_rails/server_rendering_pool/ruby_embedded_java_script.rb @@ -237,8 +237,6 @@ def parse_result_and_replay_console_messages(result_string, render_options) if render_options.logging_on_server console_script = result["consoleReplayScript"] console_script_lines = console_script.split("\n") - # Skip the first two lines (new line and opening tag) - console_script_lines = console_script_lines[2..-2] # Regular expression to match console.log or console.error calls with SERVER prefix re = /console\.(?:log|error)\.apply\(console, \["\[SERVER\] (?.*)"\]\);/ console_script_lines&.each do |line| diff --git a/node_package/src/buildConsoleReplay.ts b/node_package/src/buildConsoleReplay.ts index 8071b1574..d1feb6eb8 100644 --- a/node_package/src/buildConsoleReplay.ts +++ b/node_package/src/buildConsoleReplay.ts @@ -9,7 +9,7 @@ declare global { } } -export function consoleReplay(customConsoleHistory: typeof console['history'] | undefined = undefined, skipFirstNumberOfMessages: number = 0): string { +export function consoleReplay(customConsoleHistory: typeof console['history'] | undefined = undefined, numberOfMessagesToSkip: number = 0): string { // console.history is a global polyfill used in server rendering. const consoleHistory = customConsoleHistory ?? console.history; @@ -17,7 +17,7 @@ export function consoleReplay(customConsoleHistory: typeof console['history'] | return ''; } - const lines = consoleHistory.slice(skipFirstNumberOfMessages).map(msg => { + const lines = consoleHistory.slice(numberOfMessagesToSkip).map(msg => { const stringifiedList = msg.arguments.map(arg => { let val: string; try { @@ -44,6 +44,6 @@ export function consoleReplay(customConsoleHistory: typeof console['history'] | return lines.join('\n'); } -export default function buildConsoleReplay(customConsoleHistory: typeof console['history'] | undefined = undefined, skipFirstNumberOfMessages: number = 0): string { - return RenderUtils.wrapInScriptTags('consoleReplayLog', consoleReplay(customConsoleHistory, skipFirstNumberOfMessages)); +export default function buildConsoleReplay(customConsoleHistory: typeof console['history'] | undefined = undefined, numberOfMessagesToSkip: number = 0): string { + return RenderUtils.wrapInScriptTags('consoleReplayLog', consoleReplay(customConsoleHistory, numberOfMessagesToSkip)); } diff --git a/node_package/src/serverRenderReactComponent.ts b/node_package/src/serverRenderReactComponent.ts index 710314058..dd21e92d3 100644 --- a/node_package/src/serverRenderReactComponent.ts +++ b/node_package/src/serverRenderReactComponent.ts @@ -239,11 +239,10 @@ export const streamServerRenderedReactComponent = (options: RenderParams): Reada } }); - ReactDOMServer.renderToPipeableStream(reactRenderingResult) - .pipe(transformStream); + ReactDOMServer.renderToPipeableStream(reactRenderingResult).pipe(transformStream); renderResult = transformStream; - } catch (e: unknown) { + } catch (e) { if (throwJsErrors) { throw e; } From 49547bb4c952b48e81121d7298ac8a96770692dd Mon Sep 17 00:00:00 2001 From: Abanoub Ghadban Date: Mon, 28 Oct 2024 14:54:57 +0300 Subject: [PATCH 6/6] update CHANGELOG.md --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index e6eb24e6e..e82c3d1fe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,9 @@ 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 streaming server rendering support: - New `stream_react_component` helper for adding streamed components to views