react_on_rails 16.6.0 → 16.7.0.rc.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +1 -0
  3. data/Gemfile.development_dependencies +2 -2
  4. data/Gemfile.lock +2 -14
  5. data/Rakefile +0 -6
  6. data/Steepfile +4 -0
  7. data/lib/generators/react_on_rails/base_generator.rb +4 -4
  8. data/lib/generators/react_on_rails/demo_page_config.rb +3 -3
  9. data/lib/generators/react_on_rails/dev_tests_generator.rb +1 -1
  10. data/lib/generators/react_on_rails/generator_helper.rb +6 -65
  11. data/lib/generators/react_on_rails/generator_messages/ci_section.rb +42 -0
  12. data/lib/generators/react_on_rails/generator_messages/package_manager_detection.rb +194 -0
  13. data/lib/generators/react_on_rails/generator_messages/shakapacker_status_section.rb +61 -0
  14. data/lib/generators/react_on_rails/generator_messages.rb +22 -79
  15. data/lib/generators/react_on_rails/install_generator.rb +243 -28
  16. data/lib/generators/react_on_rails/js_dependency_manager.rb +7 -4
  17. data/lib/generators/react_on_rails/pro/USAGE +1 -1
  18. data/lib/generators/react_on_rails/pro_generator.rb +206 -183
  19. data/lib/generators/react_on_rails/pro_setup.rb +102 -26
  20. data/lib/generators/react_on_rails/react_with_redux_generator.rb +3 -2
  21. data/lib/generators/react_on_rails/templates/base/base/.env.example +25 -0
  22. data/lib/generators/react_on_rails/templates/base/base/.github/workflows/ci.yml.tt +86 -0
  23. data/lib/generators/react_on_rails/templates/base/base/Procfile.dev +4 -3
  24. data/lib/generators/react_on_rails/templates/base/base/babel.config.js.tt +1 -1
  25. data/lib/generators/react_on_rails/templates/base/base/bin/switch-bundler +2 -2
  26. data/lib/generators/react_on_rails/templates/base/base/config/webpack/ServerClientOrBoth.js.tt +1 -1
  27. data/lib/generators/react_on_rails/templates/base/base/config/webpack/clientWebpackConfig.js.tt +1 -1
  28. data/lib/generators/react_on_rails/templates/base/base/config/webpack/commonWebpackConfig.js.tt +2 -2
  29. data/lib/generators/react_on_rails/templates/base/base/config/webpack/development.js.tt +1 -1
  30. data/lib/generators/react_on_rails/templates/base/base/config/webpack/production.js.tt +1 -1
  31. data/lib/generators/react_on_rails/templates/base/base/config/webpack/serverWebpackConfig.js.tt +6 -5
  32. data/lib/generators/react_on_rails/templates/base/base/config/webpack/test.js.tt +1 -1
  33. data/lib/generators/react_on_rails/templates/pro/base/config/initializers/react_on_rails_pro.rb.tt +1 -1
  34. data/lib/generators/react_on_rails/templates/pro/base/{client → renderer}/node-renderer.js +1 -0
  35. data/lib/react_on_rails/config_path_resolver.rb +101 -4
  36. data/lib/react_on_rails/configuration.rb +22 -0
  37. data/lib/react_on_rails/dev/file_manager.rb +135 -8
  38. data/lib/react_on_rails/dev/port_selector.rb +259 -7
  39. data/lib/react_on_rails/dev/process_manager.rb +29 -2
  40. data/lib/react_on_rails/dev/server_manager.rb +607 -39
  41. data/lib/react_on_rails/doctor.rb +513 -45
  42. data/lib/react_on_rails/helper.rb +3 -11
  43. data/lib/react_on_rails/js_code_builder.rb +66 -0
  44. data/lib/react_on_rails/length_prefixed_parser.rb +142 -0
  45. data/lib/react_on_rails/packs_generator.rb +65 -12
  46. data/lib/react_on_rails/pro_migration.rb +175 -0
  47. data/lib/react_on_rails/render_request.rb +74 -0
  48. data/lib/react_on_rails/rendering_strategy/exec_js_strategy.rb +29 -0
  49. data/lib/react_on_rails/rendering_strategy.rb +44 -0
  50. data/lib/react_on_rails/server_rendering_pool/ruby_embedded_java_script.rb +33 -22
  51. data/lib/react_on_rails/system_checker.rb +44 -23
  52. data/lib/react_on_rails/utils.rb +5 -0
  53. data/lib/react_on_rails/version.rb +1 -1
  54. data/lib/react_on_rails.rb +3 -0
  55. data/rakelib/run_rspec.rake +0 -5
  56. data/rakelib/shakapacker_examples.rake +66 -23
  57. data/react_on_rails.gemspec +18 -8
  58. data/sig/react_on_rails/js_code_builder.rbs +11 -0
  59. data/sig/react_on_rails/render_request.rbs +28 -0
  60. data/sig/react_on_rails/rendering_strategy/exec_js_strategy.rbs +11 -0
  61. data/sig/react_on_rails/rendering_strategy.rbs +7 -0
  62. data/sig/react_on_rails.rbs +6 -0
  63. metadata +31 -10
@@ -253,10 +253,9 @@ module ReactOnRails
253
253
  js_code = <<~JS
254
254
  (function() {
255
255
  var htmlResult = '';
256
- var consoleReplayScript = '';
257
256
  var hasErrors = false;
258
257
  var renderingError = null;
259
- var renderingErrorObject = {};
258
+ var renderingErrorObject = null;
260
259
 
261
260
  try {
262
261
  htmlResult =
@@ -290,15 +289,8 @@ module ReactOnRails
290
289
  };
291
290
  }
292
291
 
293
- consoleReplayScript = ReactOnRails.getConsoleReplayScript();
294
-
295
- return JSON.stringify({
296
- html: htmlResult,
297
- consoleReplayScript: consoleReplayScript,
298
- hasErrors: hasErrors,
299
- renderingError: renderingErrorObject
300
- });
301
-
292
+ var consoleReplayScript = ReactOnRails.getConsoleReplayScript();
293
+ return ReactOnRails.prepareRenderResult(htmlResult, consoleReplayScript, hasErrors, renderingErrorObject);
302
294
  })()
303
295
  JS
304
296
 
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ReactOnRails
4
+ # Structured builder for generating JavaScript code used in server-side rendering.
5
+ # Replaces the heredoc-based JS code generation in ServerRenderingJsCode with
6
+ # overridable section methods that Pro can extend.
7
+ #
8
+ # Part of the strategy pattern refactoring (see issue #2905).
9
+ # Currently additive — not yet wired into the main rendering path.
10
+ class JsCodeBuilder
11
+ # Build the complete JS code for a render request.
12
+ # @param render_request [RenderRequest] The render request to build JS for
13
+ # @return [String] JavaScript code to evaluate for SSR
14
+ def build(render_request)
15
+ body = build_sections(render_request).compact.join("\n")
16
+ wrap_in_iife(body, render_request)
17
+ end
18
+
19
+ protected
20
+
21
+ # Returns an array of JS code sections. Override in subclasses to add/reorder sections.
22
+ #
23
+ # == JS variable contract
24
+ # props_section, render_call_section, and wrap_in_iife share JS variable names:
25
+ # - props_section declares a variable (base: `props`) used by render_call_section
26
+ # - wrap_in_iife may introduce IIFE parameters that shadow or supply those variables
27
+ # When overriding any one of these methods, verify the JS variables still align.
28
+ # See ReactOnRailsPro::JsCodeBuilder for an example that changes all three.
29
+ def build_sections(render_request)
30
+ [
31
+ rails_context_section(render_request),
32
+ store_initialization_section(render_request),
33
+ props_section(render_request),
34
+ render_call_section(render_request)
35
+ ]
36
+ end
37
+
38
+ def rails_context_section(render_request)
39
+ "var railsContext = #{render_request.rails_context_json};"
40
+ end
41
+
42
+ def store_initialization_section(render_request)
43
+ render_request.store_initializations
44
+ end
45
+
46
+ def props_section(render_request)
47
+ "var props = #{render_request.props_string};"
48
+ end
49
+
50
+ def render_call_section(render_request)
51
+ <<~JS.chomp
52
+ return ReactOnRails.serverRenderReactComponent({
53
+ name: #{render_request.component_name.to_json},
54
+ domNodeId: #{render_request.dom_id.to_json},
55
+ props: props,
56
+ trace: #{render_request.render_options.trace},
57
+ railsContext: railsContext
58
+ });
59
+ JS
60
+ end
61
+
62
+ def wrap_in_iife(body, _render_request)
63
+ "(function() {\n#{body}\n})()"
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,142 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module ReactOnRails
6
+ # Parses the length-prefixed wire format used between Node renderer and Ruby.
7
+ #
8
+ # Wire format per chunk:
9
+ # <metadata JSON>\t<content byte length hex>\n<raw content bytes>
10
+ #
11
+ # Used by both streaming (Pro) and non-streaming (OSS) paths.
12
+ # Strict protocol parser — any format violation raises an error.
13
+ class LengthPrefixedParser
14
+ # Parses a complete length-prefixed result string that must contain exactly one chunk.
15
+ # Used by the non-streaming rendering path where ExecJS/node renderer returns a single result.
16
+ # Returns a single Hash: { "html" => String|nil, "consoleReplayScript" => "...", ... }
17
+ # Raises if the input contains zero or more than one chunk.
18
+ def self.parse_one_chunk_result(str)
19
+ parser = new
20
+ results = []
21
+ parser.feed(str.to_s.b) { |chunk| results << chunk }
22
+ if results.empty?
23
+ raise ReactOnRails::Error,
24
+ "Malformed render result: expected exactly one length-prefixed chunk but found none"
25
+ end
26
+ if results.size > 1
27
+ raise ReactOnRails::Error,
28
+ "Malformed render result: expected exactly one length-prefixed chunk but found #{results.size}"
29
+ end
30
+ results.first
31
+ end
32
+
33
+ def initialize
34
+ # Binary encoding so that `index` returns byte positions (not character positions).
35
+ # Needed because `byteindex` requires Ruby 3.2+ and we support 3.0+.
36
+ # force_encoding is O(1) (flips a flag, no copy). .b allocates a new object but
37
+ # shares the byte buffer via copy-on-write for strings over ~23 bytes.
38
+ @buf = "".b
39
+ @state = :header
40
+ @content_len = 0
41
+ @metadata = nil
42
+ end
43
+
44
+ # Appends bytes to buffer and yields complete chunks as they become available.
45
+ # Yields Hash: { "html" => content, ...metadata }
46
+ # Raises on protocol errors (bad JSON, bad hex, missing tab).
47
+ # After an error, the parser enters :error state and all subsequent calls are no-ops.
48
+ def feed(chunk, &block)
49
+ return if @state == :error
50
+
51
+ @buf << (chunk.encoding == Encoding::BINARY ? chunk : chunk.b)
52
+
53
+ loop do
54
+ case @state
55
+ when :header
56
+ break unless try_parse_header(&block)
57
+ when :content
58
+ break unless try_read_content(&block)
59
+ end
60
+ end
61
+ rescue StandardError
62
+ @state = :error
63
+ raise
64
+ end
65
+
66
+ # Called when the stream ends to detect truncated responses.
67
+ # Logs a warning if the buffer still has unconsumed bytes (partial header or content).
68
+ def flush
69
+ return if @state == :header && @buf.empty?
70
+
71
+ Rails.logger.warn(
72
+ "[react_on_rails] Incomplete length-prefixed stream: " \
73
+ "#{@buf.bytesize} bytes remaining in state :#{@state}"
74
+ )
75
+ end
76
+
77
+ # True if the parser encountered a protocol error.
78
+ def error?
79
+ @state == :error
80
+ end
81
+
82
+ private
83
+
84
+ def try_parse_header
85
+ idx = @buf.index("\n")
86
+ return false unless idx
87
+
88
+ header = @buf.byteslice(0, idx)
89
+ @buf = @buf.byteslice(idx + 1, @buf.bytesize - idx - 1)
90
+
91
+ tab_idx = header.index("\t")
92
+ unless tab_idx
93
+ header_str = header.force_encoding(Encoding::UTF_8).inspect
94
+ raise ReactOnRails::Error,
95
+ "Malformed length-prefixed header: missing tab separator in: #{header_str}"
96
+ end
97
+
98
+ parse_length_prefixed_header(header, tab_idx)
99
+ true
100
+ end
101
+
102
+ def parse_length_prefixed_header(header, tab_idx)
103
+ meta_json = header.byteslice(0, tab_idx)
104
+ len_hex = header.byteslice(tab_idx + 1, header.bytesize - tab_idx - 1)
105
+
106
+ begin
107
+ @content_len = Integer(len_hex, 16)
108
+ rescue ArgumentError
109
+ raise ReactOnRails::Error, "Invalid content length hex: #{len_hex.force_encoding(Encoding::UTF_8).inspect}"
110
+ end
111
+
112
+ begin
113
+ @metadata = JSON.parse(meta_json.force_encoding(Encoding::UTF_8))
114
+ rescue JSON::ParserError => e
115
+ meta_str = meta_json.force_encoding(Encoding::UTF_8).inspect
116
+ raise ReactOnRails::Error,
117
+ "Malformed length-prefixed header: invalid metadata JSON: #{meta_str} (#{e.message})"
118
+ end
119
+
120
+ @state = :content
121
+ end
122
+
123
+ def try_read_content
124
+ return false if @buf.bytesize < @content_len
125
+
126
+ raw_content = @buf.byteslice(0, @content_len).force_encoding(Encoding::UTF_8)
127
+ @buf = @buf.byteslice(@content_len, @buf.bytesize - @content_len)
128
+
129
+ # Reconstruct html type based on payloadType:
130
+ # "object" → JSON-serialized value (ServerRenderHash or null), needs JSON.parse
131
+ # "string" → raw HTML string, used as-is
132
+ payload_type = @metadata.delete("payloadType")
133
+ @metadata["html"] = payload_type == "object" ? JSON.parse(raw_content) : raw_content
134
+
135
+ result = @metadata
136
+ @metadata = nil
137
+ @state = :header
138
+ yield result
139
+ true
140
+ end
141
+ end
142
+ end
@@ -32,6 +32,8 @@ module ReactOnRails
32
32
  # Auto-registration requires nested_entries support which was added in 7.0.0
33
33
  # Note: The gemspec requires Shakapacker >= 6.0 for basic functionality
34
34
  MINIMUM_SHAKAPACKER_VERSION_FOR_AUTO_BUNDLING = "7.0.0"
35
+ # Longer than any realistic pack generation; LOCK_NB still gates the actual clear.
36
+ GENERATED_PACKS_LOCK_TTL_SECONDS = 120
35
37
 
36
38
  def self.instance
37
39
  @instance ||= PacksGenerator.new
@@ -48,25 +50,76 @@ module ReactOnRails
48
50
 
49
51
  verbose = ENV["REACT_ON_RAILS_VERBOSE"] == "true"
50
52
 
51
- add_generated_pack_to_server_bundle
53
+ with_generated_packs_lock(verbose: verbose) do
54
+ add_generated_pack_to_server_bundle
52
55
 
53
- # Clean any non-generated files from directories
54
- clean_non_generated_files_with_feedback(verbose: verbose)
56
+ if generated_files_present_and_up_to_date?
57
+ clean_non_generated_files_with_feedback(verbose: verbose)
58
+ puts Rainbow("✅ Generated packs are up to date, no regeneration needed").green if verbose
59
+ else
60
+ clean_generated_directories_with_feedback(verbose: verbose)
61
+ generate_packs(verbose: verbose)
62
+ end
63
+ end
64
+ end
55
65
 
56
- are_generated_files_present_and_up_to_date = Dir.exist?(generated_packs_directory_path) &&
57
- File.exist?(generated_server_bundle_file_path) &&
58
- !stale_or_missing_packs?
66
+ private
59
67
 
60
- if are_generated_files_present_and_up_to_date
61
- puts Rainbow("✅ Generated packs are up to date, no regeneration needed").green if verbose
62
- return
68
+ def generated_files_present_and_up_to_date?
69
+ server_bundle_ready =
70
+ ReactOnRails.configuration.server_bundle_js_file.blank? ||
71
+ File.exist?(generated_server_bundle_file_path)
72
+
73
+ Dir.exist?(generated_packs_directory_path) &&
74
+ server_bundle_ready &&
75
+ !stale_or_missing_packs?
76
+ end
77
+
78
+ def with_generated_packs_lock(verbose: false)
79
+ lock_path = generated_packs_lock_path
80
+ FileUtils.mkdir_p(lock_path.dirname)
81
+ clear_stale_generated_packs_lock(lock_path, verbose: verbose)
82
+
83
+ File.open(lock_path, File::RDWR | File::CREAT, 0o644) do |lock_file|
84
+ puts Rainbow("🔒 Acquiring generated packs lock at #{lock_path}").yellow if verbose
85
+ # flock waits until the holder releases; keep Rails tmp local so serialization is reliable.
86
+ lock_file.flock(File::LOCK_EX)
87
+ puts Rainbow("🔒 Generated packs lock acquired at #{lock_path}").yellow if verbose
88
+ lock_file.rewind
89
+ lock_file.truncate(0)
90
+ lock_file.write("pid=#{Process.pid}\nstarted_at=#{Time.now.utc}\n")
91
+ lock_file.flush
92
+
93
+ yield
94
+ ensure
95
+ # Release early so the next waiter can proceed even if the block raised.
96
+ lock_file.flock(File::LOCK_UN)
63
97
  end
98
+ end
99
+
100
+ def clear_stale_generated_packs_lock(lock_path, verbose: false)
101
+ return unless File.exist?(lock_path)
102
+ return unless File.mtime(lock_path) < Time.now - GENERATED_PACKS_LOCK_TTL_SECONDS
103
+
104
+ # Kernel locks are released when the holder exits; this only clears stale metadata.
105
+ lock_acquired = false
106
+ File.open(lock_path, File::RDWR) do |lock_file|
107
+ lock_acquired = lock_file.flock(File::LOCK_EX | File::LOCK_NB) != false
108
+ next unless lock_acquired
64
109
 
65
- clean_generated_directories_with_feedback(verbose: verbose)
66
- generate_packs(verbose: verbose)
110
+ lock_file.rewind
111
+ lock_file.truncate(0)
112
+ puts Rainbow("🧹 Cleared stale generated packs lock at #{lock_path}").yellow if verbose
113
+ ensure
114
+ lock_file.flock(File::LOCK_UN) if lock_acquired
115
+ end
116
+ rescue Errno::ENOENT, Errno::EACCES
117
+ nil
67
118
  end
68
119
 
69
- private
120
+ def generated_packs_lock_path
121
+ Rails.root.join("tmp", "react_on_rails_generate_packs.lock")
122
+ end
70
123
 
71
124
  def generate_packs(verbose: false)
72
125
  # Check for name conflicts between components and stores
@@ -0,0 +1,175 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ReactOnRails
4
+ # Shared Pro migration facts and Gemfile parsing used by the generator and doctor.
5
+ module ProMigration
6
+ JS_SOURCE_ROOTS = %w[app/javascript app/frontend frontend javascript client].freeze
7
+ JS_SOURCE_EXTENSIONS = %w[js jsx ts tsx mjs cjs vue svelte].freeze
8
+
9
+ JEST_MODULE_SPECIFIER_METHOD_NAMES = %w[
10
+ createMockFromModule
11
+ mock unmock deepUnmock doMock dontMock setMock
12
+ requireActual requireMock unstable_mockModule unstable_unmockModule
13
+ ].freeze
14
+ VITEST_MODULE_SPECIFIER_METHOD_NAMES = %w[
15
+ mock unmock doMock doUnmock
16
+ importActual importMock
17
+ ].freeze
18
+ JEST_MODULE_SPECIFIER_METHOD_PATTERN = Regexp.union(JEST_MODULE_SPECIFIER_METHOD_NAMES)
19
+ VITEST_MODULE_SPECIFIER_METHOD_PATTERN = Regexp.union(VITEST_MODULE_SPECIFIER_METHOD_NAMES)
20
+
21
+ PRO_GEM_PATTERN = /^\s*gem(?:\s+|\(\s*(?:#.*\n\s*)*)["']react_on_rails_pro["']/
22
+ BASE_GEM_PATTERN = /^(\s*)gem(?:\s+|\(\s*)(["'])react_on_rails\2(?=\s*(?:,|\)|#|$))/
23
+ STRING_LITERAL_PATTERN = /"(?:\\.|[^"\\])*+"|'(?:\\.|[^'\\])*+'|`(?:\\.|[^`\\])*+`/
24
+
25
+ module_function
26
+
27
+ def pro_gem_entry?(gemfile_content)
28
+ gemfile_content.match?(PRO_GEM_PATTERN)
29
+ end
30
+
31
+ def base_gem_entry?(gemfile_content)
32
+ gemfile_lines = gemfile_content.lines
33
+ line_index = 0
34
+
35
+ while line_index < gemfile_lines.length
36
+ return true if base_gem_declaration_at(gemfile_lines, line_index)
37
+
38
+ line_index += 1
39
+ end
40
+
41
+ false
42
+ end
43
+
44
+ def base_gem_declaration_at(lines, start_index)
45
+ match_multiline_parenthesized_base_gem(lines, start_index) ||
46
+ match_non_parenthesized_base_gem(lines, start_index)
47
+ end
48
+
49
+ def match_multiline_parenthesized_base_gem(lines, start_index)
50
+ start_line = lines[start_index]
51
+ start_match = start_line.match(/^(\s*)gem\s*\(/)
52
+ return nil unless start_match
53
+
54
+ line_index = start_index
55
+ gem_name = nil
56
+ paren_depth = 0
57
+
58
+ while line_index < lines.length
59
+ line = lines[line_index]
60
+ gem_name_fragment_offset = line_index == start_index ? start_match.end(0) : 0
61
+ gem_name ||= parenthesized_base_gem_name(line, gem_name_fragment_offset, line_index)
62
+ return nil if gem_name == false
63
+
64
+ line_without_literals = line_without_string_literals_and_inline_comments(line, strip_ruby_comments: true)
65
+ paren_depth += parenthesis_delta(line_without_literals)
66
+
67
+ return parenthesized_base_gem_declaration(lines, start_match, gem_name, line_index) if paren_depth <= 0
68
+
69
+ line_index += 1
70
+ end
71
+
72
+ nil
73
+ end
74
+
75
+ def parenthesized_base_gem_name(line, fragment_offset, line_index)
76
+ gem_name_fragment = line[fragment_offset..].to_s
77
+ return nil if strip_ruby_inline_comment(gem_name_fragment).strip.empty?
78
+
79
+ gem_name_match = gem_name_fragment.match(/\A\s*(["'])react_on_rails\1(?=\s*(?:,|\)|#|$))/)
80
+ return false unless gem_name_match
81
+
82
+ {
83
+ quote: gem_name_match[1],
84
+ line_index: line_index,
85
+ match_end: fragment_offset + gem_name_match.end(0)
86
+ }
87
+ end
88
+
89
+ def parenthesized_base_gem_declaration(lines, start_match, gem_name, end_line_index)
90
+ return nil unless gem_name
91
+
92
+ declaration_fragment = lines[gem_name[:line_index]..end_line_index].join
93
+ suffix = declaration_fragment[gem_name[:match_end]..]
94
+ suffix = "\n" if suffix.nil? || suffix.empty?
95
+ {
96
+ indentation: start_match[1],
97
+ quote: gem_name[:quote],
98
+ next_index: end_line_index + 1,
99
+ trailing_suffix: suffix,
100
+ parenthesized_gem_call: true
101
+ }
102
+ end
103
+
104
+ def parenthesis_delta(line)
105
+ line.count("(") - line.count(")")
106
+ end
107
+
108
+ def match_non_parenthesized_base_gem(lines, start_index)
109
+ line = lines[start_index]
110
+ match = line.match(BASE_GEM_PATTERN)
111
+ return nil unless match
112
+
113
+ declaration = consume_non_parenthesized_base_gem_declaration(lines, start_index, match.end(0))
114
+ {
115
+ indentation: match[1],
116
+ quote: match[2],
117
+ next_index: declaration[:next_index],
118
+ trailing_suffix: declaration[:trailing_suffix],
119
+ parenthesized_gem_call: match[0].include?("(")
120
+ }
121
+ end
122
+
123
+ def consume_non_parenthesized_base_gem_declaration(lines, start_index, match_end)
124
+ line_index = start_index
125
+ current_line = lines[line_index]
126
+ declaration_lines = [current_line]
127
+ line_index += 1
128
+
129
+ while line_index < lines.length &&
130
+ line_continues_with_comma?(current_line) &&
131
+ gem_declaration_continues_on_next_line?(lines[line_index])
132
+ next_line = lines[line_index]
133
+ declaration_lines << next_line
134
+ current_line = next_line unless comment_or_blank_line?(next_line)
135
+ line_index += 1
136
+ end
137
+
138
+ trailing_suffix = lines[start_index][match_end..].to_s + declaration_lines.drop(1).join
139
+ { trailing_suffix: trailing_suffix, next_index: line_index }
140
+ end
141
+
142
+ def line_continues_with_comma?(line)
143
+ line_without_comment = strip_ruby_inline_comment(line).rstrip
144
+ line_without_comment.end_with?(",")
145
+ end
146
+
147
+ def gem_declaration_continues_on_next_line?(line)
148
+ stripped = line.lstrip
149
+ return true if stripped.empty?
150
+
151
+ !stripped.match?(/\Agem(?:\s|\()/)
152
+ end
153
+
154
+ def comment_or_blank_line?(line)
155
+ stripped = line.lstrip
156
+ stripped.empty? || stripped.start_with?("#")
157
+ end
158
+
159
+ def line_without_string_literals_and_inline_comments(line, strip_ruby_comments: false)
160
+ line_without_strings = line.gsub(STRING_LITERAL_PATTERN, "")
161
+ return line_without_strings unless strip_ruby_comments
162
+
163
+ strip_ruby_inline_comment(line_without_strings)
164
+ end
165
+
166
+ def strip_ruby_inline_comment(line)
167
+ hash_index = line.index("#")
168
+ return line unless hash_index
169
+
170
+ prefix = line[0, hash_index].rstrip
171
+ newline_index = line.index("\n", hash_index)
172
+ newline_index ? prefix + line[newline_index..] : prefix
173
+ end
174
+ end
175
+ end
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ReactOnRails
4
+ # Structured data object encapsulating everything needed for a server render.
5
+ # Replaces the ad-hoc parameter passing of js_code strings and render_options
6
+ # through the delegation chain.
7
+ #
8
+ # Part of the strategy pattern refactoring (see issue #2905).
9
+ # Currently additive — not yet wired into the main rendering path.
10
+ class RenderRequest
11
+ attr_reader :component_name, :props, :rails_context, :store_initializations,
12
+ :render_options
13
+
14
+ # @param component_name [String] React component name
15
+ # @param props [Hash, String] Component props (Hash or JSON string)
16
+ # @param rails_context [Hash] Rails context data for the render
17
+ # @param store_initializations [String] JavaScript code for Redux store initialization
18
+ # @param render_options [ReactOnRails::ReactComponent::RenderOptions] Render configuration
19
+ def initialize(component_name:, props:, rails_context:, store_initializations:, render_options:)
20
+ @component_name = component_name
21
+ @props = props
22
+ @rails_context = rails_context
23
+ @store_initializations = store_initializations
24
+ @render_options = render_options
25
+ end
26
+
27
+ # Serialize to JS code via configured builder.
28
+ # Raises if server_bundle_js_file is not configured when prerender is true.
29
+ def to_js
30
+ validate_server_bundle_configured!
31
+ ReactOnRails.js_code_builder.build(self)
32
+ end
33
+
34
+ # Returns props as a JSON string, with unicode line/paragraph separators escaped
35
+ # for safe embedding in JavaScript.
36
+ def props_string
37
+ json = props.is_a?(String) ? props : props.to_json
38
+ json.gsub("\u2028", '\u2028').gsub("\u2029", '\u2029')
39
+ end
40
+
41
+ def rails_context_json
42
+ raise ArgumentError, "rails_context must be a Hash, got #{rails_context.class}" unless rails_context.is_a?(Hash)
43
+
44
+ rails_context.to_json.gsub("\u2028", '\u2028').gsub("\u2029", '\u2029')
45
+ end
46
+
47
+ def dom_id
48
+ render_options.dom_id
49
+ end
50
+
51
+ def streaming?
52
+ render_options.streaming?
53
+ end
54
+
55
+ def rsc_payload_streaming?
56
+ render_options.rsc_payload_streaming?
57
+ end
58
+
59
+ private
60
+
61
+ def validate_server_bundle_configured!
62
+ config_server_bundle_js = ReactOnRails.configuration.server_bundle_js_file
63
+ return unless render_options.prerender == true && config_server_bundle_js.blank?
64
+
65
+ msg = <<~MSG
66
+ The `prerender` option to allow Server Side Rendering is marked as true but the ReactOnRails configuration
67
+ for `server_bundle_js_file` is nil or not present in `config/initializers/react_on_rails.rb`.
68
+ Set `config.server_bundle_js_file` to your javascript bundle to allow server side rendering.
69
+ Read more at https://reactonrails.com/docs/core-concepts/react-server-rendering/
70
+ MSG
71
+ raise ReactOnRails::Error, msg
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ReactOnRails
4
+ module RenderingStrategy
5
+ # ExecJS-based rendering strategy for the open-source React on Rails gem.
6
+ # Wraps the existing RubyEmbeddedJavaScript connection pool.
7
+ #
8
+ # Part of the strategy pattern refactoring (see issue #2905).
9
+ # Currently additive — not yet wired into the main rendering path.
10
+ class ExecJsStrategy
11
+ include ReactOnRails::RenderingStrategy
12
+
13
+ def execute(render_request)
14
+ js_code = render_request.to_js
15
+ ReactOnRails::ServerRenderingPool::RubyEmbeddedJavaScript
16
+ .exec_server_render_js(js_code, render_request.render_options)
17
+ end
18
+
19
+ def reset
20
+ ReactOnRails::ServerRenderingPool::RubyEmbeddedJavaScript.reset_pool
21
+ end
22
+
23
+ def reset_if_bundle_changed
24
+ ReactOnRails::ServerRenderingPool::RubyEmbeddedJavaScript
25
+ .reset_pool_if_server_bundle_was_modified
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "rendering_strategy/exec_js_strategy"
4
+
5
+ module ReactOnRails
6
+ # @abstract Include this module and implement all instance methods in your
7
+ # concrete strategy class. Methods are instance methods, not class methods.
8
+ #
9
+ # Strategy interface for server-side rendering. Concrete strategies implement
10
+ # the rendering pipeline (JS execution, caching, streaming) for a specific
11
+ # runtime (ExecJS, Node renderer, etc.).
12
+ #
13
+ # Configured once at boot time via engine initializers, replacing runtime
14
+ # `react_on_rails_pro?` checks.
15
+ #
16
+ # Part of the strategy pattern refactoring (see issue #2905).
17
+ # Currently additive — not yet wired into the main rendering path.
18
+ module RenderingStrategy
19
+ # Execute a server render.
20
+ # @param render_request [RenderRequest] The render request to execute
21
+ # @return [Hash, Stream] Result hash with "html", "consoleReplayScript", "hasErrors" keys,
22
+ # or a stream for streaming renders.
23
+ # :nocov:
24
+ def execute(render_request)
25
+ raise NotImplementedError, "#{self.class}#execute must be implemented"
26
+ end
27
+ # :nocov:
28
+
29
+ # Reset the rendering pool (e.g., after configuration changes).
30
+ # :nocov:
31
+ def reset
32
+ raise NotImplementedError, "#{self.class}#reset must be implemented"
33
+ end
34
+ # :nocov:
35
+
36
+ # Check if the server bundle has changed and reset the pool if so.
37
+ # Used in development mode to pick up bundle changes without restarting.
38
+ # :nocov:
39
+ def reset_if_bundle_changed
40
+ raise NotImplementedError, "#{self.class}#reset_if_bundle_changed must be implemented"
41
+ end
42
+ # :nocov:
43
+ end
44
+ end