From cffaed8495046d3aa1c129c946b22b55caf27e27 Mon Sep 17 00:00:00 2001 From: Abanoub Ghadban Date: Tue, 29 Oct 2024 14:54:50 +0300 Subject: [PATCH] Add support for console replay while streaming component (#1647) * pass console messages from server to client and replay them * linting * add some comments and remove unneeded calls * fix syntax error * tiny changes * update CHANGELOG.md --- CHANGELOG.md | 3 + lib/react_on_rails/helper.rb | 38 ++++++------ .../ruby_embedded_java_script.rb | 58 +++++++++++-------- node_package/src/buildConsoleReplay.ts | 8 +-- .../src/serverRenderReactComponent.ts | 26 +++++++-- 5 files changed, 80 insertions(+), 53 deletions(-) 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 diff --git a/lib/react_on_rails/helper.rb b/lib/react_on_rails/helper.rb index d16e77609..2a9e26c5d 100644 --- a/lib/react_on_rails/helper.rb +++ b/lib/react_on_rails/helper.rb @@ -430,30 +430,29 @@ 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: ) - 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.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 + 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 - chunk end - - rendered_html_stream.transform(&:html_safe) - # TODO: handle console logs end def build_react_component_result_for_server_rendered_hash( @@ -492,11 +491,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..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 @@ -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 @@ -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,32 +75,14 @@ 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 - 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 - # rubocop:enable Metrics/CyclomaticComplexity, Metrics/AbcSize, Metrics/PerceivedComplexity + return parse_result_and_replay_console_messages(result, render_options) unless render_options.stream? - # 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) + # 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 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,28 @@ 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") + # 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 + result + end end # rubocop:enable Metrics/ClassLength end diff --git a/node_package/src/buildConsoleReplay.ts b/node_package/src/buildConsoleReplay.ts index f39cec428..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): 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.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): string { - return RenderUtils.wrapInScriptTags('consoleReplayLog', consoleReplay(customConsoleHistory)); +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 ed392793a..dd21e92d3 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,11 +222,26 @@ 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 + renderResult = transformStream; } catch (e) { if (throwJsErrors) { throw e;