Skip to content

Commit cffaed8

Browse files
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
1 parent 093247d commit cffaed8

File tree

5 files changed

+80
-53
lines changed

5 files changed

+80
-53
lines changed

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,9 @@ Please follow the recommendations outlined at [keepachangelog.com](http://keepac
1818
### [Unreleased]
1919
Changes since the last non-beta release.
2020

21+
### Added
22+
- 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).
23+
2124
### Added
2225
- Added streaming server rendering support:
2326
- New `stream_react_component` helper for adding streamed components to views

lib/react_on_rails/helper.rb

Lines changed: 19 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -430,30 +430,29 @@ def build_react_component_result_for_server_rendered_string(
430430
end
431431

432432
def build_react_component_result_for_server_streamed_content(
433-
rendered_html_stream: required("rendered_html_stream"),
434-
component_specification_tag: required("component_specification_tag"),
435-
render_options: required("render_options")
433+
rendered_html_stream:,
434+
component_specification_tag:,
435+
render_options:
436436
)
437-
content_tag_options_html_tag = render_options.html_options[:tag] || "div"
438-
# The component_specification_tag is appended to the first chunk
439-
# We need to pass it early with the first chunk because it's needed in hydration
440-
# We need to make sure that client can hydrate the app early even before all components are streamed
441437
is_first_chunk = true
442-
rendered_html_stream = rendered_html_stream.transform do |chunk|
438+
rendered_html_stream.transform do |chunk_json_result|
443439
if is_first_chunk
444440
is_first_chunk = false
445-
html_content = <<-HTML
446-
#{rails_context_if_not_already_rendered}
447-
#{component_specification_tag}
448-
<#{content_tag_options_html_tag} id="#{render_options.dom_id}">#{chunk}</#{content_tag_options_html_tag}>
449-
HTML
450-
next html_content.strip
441+
build_react_component_result_for_server_rendered_string(
442+
server_rendered_html: chunk_json_result["html"],
443+
component_specification_tag: component_specification_tag,
444+
console_script: chunk_json_result["consoleReplayScript"],
445+
render_options: render_options
446+
)
447+
else
448+
result_console_script = render_options.replay_console ? chunk_json_result["consoleReplayScript"] : ""
449+
# No need to prepend component_specification_tag or add rails context again
450+
# as they're already included in the first chunk
451+
compose_react_component_html_with_spec_and_console(
452+
"", chunk_json_result["html"], result_console_script
453+
)
451454
end
452-
chunk
453455
end
454-
455-
rendered_html_stream.transform(&:html_safe)
456-
# TODO: handle console logs
457456
end
458457

459458
def build_react_component_result_for_server_rendered_hash(
@@ -492,11 +491,12 @@ def build_react_component_result_for_server_rendered_hash(
492491

493492
def compose_react_component_html_with_spec_and_console(component_specification_tag, rendered_output, console_script)
494493
# IMPORTANT: Ensure that we mark string as html_safe to avoid escaping.
495-
<<~HTML.html_safe
494+
html_content = <<~HTML
496495
#{rendered_output}
497496
#{component_specification_tag}
498497
#{console_script}
499498
HTML
499+
html_content.strip.html_safe
500500
end
501501

502502
def rails_context_if_not_already_rendered

lib/react_on_rails/server_rendering_pool/ruby_embedded_java_script.rb

Lines changed: 33 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ def reset_pool_if_server_bundle_was_modified
4646
# Note, js_code does not have to be based on React.
4747
# js_code MUST RETURN json stringify Object
4848
# Calling code will probably call 'html_safe' on return value before rendering to the view.
49-
# rubocop:disable Metrics/CyclomaticComplexity, Metrics/AbcSize, Metrics/PerceivedComplexity
49+
# rubocop:disable Metrics/CyclomaticComplexity
5050
def exec_server_render_js(js_code, render_options, js_evaluator = nil)
5151
js_evaluator ||= self
5252
if render_options.trace
@@ -56,7 +56,11 @@ def exec_server_render_js(js_code, render_options, js_evaluator = nil)
5656
@file_index += 1
5757
end
5858
begin
59-
json_string = js_evaluator.eval_js(js_code, render_options)
59+
result = if render_options.stream?
60+
js_evaluator.eval_streaming_js(js_code, render_options)
61+
else
62+
js_evaluator.eval_js(js_code, render_options)
63+
end
6064
rescue StandardError => err
6165
msg = <<~MSG
6266
Error evaluating server bundle. Check your webpack configuration.
@@ -71,32 +75,14 @@ def exec_server_render_js(js_code, render_options, js_evaluator = nil)
7175
end
7276
raise ReactOnRails::Error, msg, err.backtrace
7377
end
74-
result = nil
75-
begin
76-
result = JSON.parse(json_string)
77-
rescue JSON::ParserError => e
78-
raise ReactOnRails::JsonParseError.new(parse_error: e, json: json_string)
79-
end
8078

81-
if render_options.logging_on_server
82-
console_script = result["consoleReplayScript"]
83-
console_script_lines = console_script.split("\n")
84-
console_script_lines = console_script_lines[2..-2]
85-
re = /console\.(?:log|error)\.apply\(console, \["\[SERVER\] (?<msg>.*)"\]\);/
86-
console_script_lines&.each do |line|
87-
match = re.match(line)
88-
Rails.logger.info { "[react_on_rails] #{match[:msg]}" } if match
89-
end
90-
end
91-
result
92-
end
93-
# rubocop:enable Metrics/CyclomaticComplexity, Metrics/AbcSize, Metrics/PerceivedComplexity
79+
return parse_result_and_replay_console_messages(result, render_options) unless render_options.stream?
9480

95-
# TODO: merge with exec_server_render_js
96-
def exec_server_render_streaming_js(js_code, render_options, js_evaluator = nil)
97-
js_evaluator ||= self
98-
js_evaluator.eval_streaming_js(js_code, render_options)
81+
# Streamed component is returned as stream of strings.
82+
# We need to parse each chunk and replay the console messages.
83+
result.transform { |chunk| parse_result_and_replay_console_messages(chunk, render_options) }
9984
end
85+
# rubocop:enable Metrics/CyclomaticComplexity
10086

10187
def trace_js_code_used(msg, js_code, file_name = "tmp/server-generated.js", force: false)
10288
return unless ReactOnRails.configuration.trace || force
@@ -239,6 +225,28 @@ def file_url_to_string(url)
239225
msg = "file_url_to_string #{url} failed\nError is: #{e}"
240226
raise ReactOnRails::Error, msg
241227
end
228+
229+
def parse_result_and_replay_console_messages(result_string, render_options)
230+
result = nil
231+
begin
232+
result = JSON.parse(result_string)
233+
rescue JSON::ParserError => e
234+
raise ReactOnRails::JsonParseError.new(parse_error: e, json: result_string)
235+
end
236+
237+
if render_options.logging_on_server
238+
console_script = result["consoleReplayScript"]
239+
console_script_lines = console_script.split("\n")
240+
# Regular expression to match console.log or console.error calls with SERVER prefix
241+
re = /console\.(?:log|error)\.apply\(console, \["\[SERVER\] (?<msg>.*)"\]\);/
242+
console_script_lines&.each do |line|
243+
match = re.match(line)
244+
# Log matched messages to Rails logger with react_on_rails prefix
245+
Rails.logger.info { "[react_on_rails] #{match[:msg]}" } if match
246+
end
247+
end
248+
result
249+
end
242250
end
243251
# rubocop:enable Metrics/ClassLength
244252
end

node_package/src/buildConsoleReplay.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,15 +9,15 @@ declare global {
99
}
1010
}
1111

12-
export function consoleReplay(customConsoleHistory: typeof console['history'] | undefined = undefined): string {
12+
export function consoleReplay(customConsoleHistory: typeof console['history'] | undefined = undefined, numberOfMessagesToSkip: number = 0): string {
1313
// console.history is a global polyfill used in server rendering.
1414
const consoleHistory = customConsoleHistory ?? console.history;
1515

1616
if (!(Array.isArray(consoleHistory))) {
1717
return '';
1818
}
1919

20-
const lines = consoleHistory.map(msg => {
20+
const lines = consoleHistory.slice(numberOfMessagesToSkip).map(msg => {
2121
const stringifiedList = msg.arguments.map(arg => {
2222
let val: string;
2323
try {
@@ -44,6 +44,6 @@ export function consoleReplay(customConsoleHistory: typeof console['history'] |
4444
return lines.join('\n');
4545
}
4646

47-
export default function buildConsoleReplay(customConsoleHistory: typeof console['history'] | undefined = undefined): string {
48-
return RenderUtils.wrapInScriptTags('consoleReplayLog', consoleReplay(customConsoleHistory));
47+
export default function buildConsoleReplay(customConsoleHistory: typeof console['history'] | undefined = undefined, numberOfMessagesToSkip: number = 0): string {
48+
return RenderUtils.wrapInScriptTags('consoleReplayLog', consoleReplay(customConsoleHistory, numberOfMessagesToSkip));
4949
}

node_package/src/serverRenderReactComponent.ts

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import ReactDOMServer from 'react-dom/server';
2-
import { PassThrough, Readable } from 'stream';
2+
import { PassThrough, Readable, Transform } from 'stream';
33
import type { ReactElement } from 'react';
44

55
import ComponentRegistry from './ComponentRegistry';
@@ -204,6 +204,7 @@ export const streamServerRenderedReactComponent = (options: RenderParams): Reada
204204
const { name: componentName, domNodeId, trace, props, railsContext, throwJsErrors } = options;
205205

206206
let renderResult: null | Readable = null;
207+
let previouslyReplayedConsoleMessages: number = 0;
207208

208209
try {
209210
const componentObj = ComponentRegistry.get(componentName);
@@ -221,11 +222,26 @@ export const streamServerRenderedReactComponent = (options: RenderParams): Reada
221222
throw new Error('Server rendering of streams is not supported for server render hashes or promises.');
222223
}
223224

224-
const renderStream = new PassThrough();
225-
ReactDOMServer.renderToPipeableStream(reactRenderingResult).pipe(renderStream);
226-
renderResult = renderStream;
225+
const consoleHistory = console.history;
226+
const transformStream = new Transform({
227+
transform(chunk, _, callback) {
228+
const htmlChunk = chunk.toString();
229+
const consoleReplayScript = buildConsoleReplay(consoleHistory, previouslyReplayedConsoleMessages);
230+
previouslyReplayedConsoleMessages = consoleHistory?.length || 0;
231+
232+
const jsonChunk = JSON.stringify({
233+
html: htmlChunk,
234+
consoleReplayScript,
235+
});
236+
237+
this.push(jsonChunk);
238+
callback();
239+
}
240+
});
241+
242+
ReactDOMServer.renderToPipeableStream(reactRenderingResult).pipe(transformStream);
227243

228-
// TODO: Add console replay script to the stream
244+
renderResult = transformStream;
229245
} catch (e) {
230246
if (throwJsErrors) {
231247
throw e;

0 commit comments

Comments
 (0)