From 1b8cbf0e7dd73988a9e37018d5df698109d90e53 Mon Sep 17 00:00:00 2001 From: Abanoub Ghadban Date: Sun, 25 Aug 2024 20:59:20 +0300 Subject: [PATCH] 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 b7a86f64c..0a01b7722 100644 --- a/lib/react_on_rails/helper.rb +++ b/lib/react_on_rails/helper.rb @@ -435,26 +435,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( @@ -493,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..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; }