Skip to content

Commit

Permalink
Add support for console replay while streaming component (#1647)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
AbanoubGhadban authored Oct 29, 2024
1 parent 093247d commit cffaed8
Show file tree
Hide file tree
Showing 5 changed files with 80 additions and 53 deletions.
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
38 changes: 19 additions & 19 deletions lib/react_on_rails/helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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}</#{content_tag_options_html_tag}>
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(
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Expand All @@ -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\] (?<msg>.*)"\]\);/
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
Expand Down Expand Up @@ -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\] (?<msg>.*)"\]\);/
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
Expand Down
8 changes: 4 additions & 4 deletions node_package/src/buildConsoleReplay.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,15 +9,15 @@ 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;

if (!(Array.isArray(consoleHistory))) {
return '';
}

const lines = consoleHistory.map(msg => {
const lines = consoleHistory.slice(numberOfMessagesToSkip).map(msg => {
const stringifiedList = msg.arguments.map(arg => {
let val: string;
try {
Expand All @@ -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));
}
26 changes: 21 additions & 5 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 } from 'stream';
import { PassThrough, Readable, Transform } from 'stream';
import type { ReactElement } from 'react';

import ComponentRegistry from './ComponentRegistry';
Expand Down Expand Up @@ -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);
Expand All @@ -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;
Expand Down

0 comments on commit cffaed8

Please sign in to comment.