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.
- checksums.yaml +4 -4
- data/.rubocop.yml +1 -0
- data/Gemfile.development_dependencies +2 -2
- data/Gemfile.lock +2 -14
- data/Rakefile +0 -6
- data/Steepfile +4 -0
- data/lib/generators/react_on_rails/base_generator.rb +4 -4
- data/lib/generators/react_on_rails/demo_page_config.rb +3 -3
- data/lib/generators/react_on_rails/dev_tests_generator.rb +1 -1
- data/lib/generators/react_on_rails/generator_helper.rb +6 -65
- data/lib/generators/react_on_rails/generator_messages/ci_section.rb +42 -0
- data/lib/generators/react_on_rails/generator_messages/package_manager_detection.rb +194 -0
- data/lib/generators/react_on_rails/generator_messages/shakapacker_status_section.rb +61 -0
- data/lib/generators/react_on_rails/generator_messages.rb +22 -79
- data/lib/generators/react_on_rails/install_generator.rb +243 -28
- data/lib/generators/react_on_rails/js_dependency_manager.rb +7 -4
- data/lib/generators/react_on_rails/pro/USAGE +1 -1
- data/lib/generators/react_on_rails/pro_generator.rb +206 -183
- data/lib/generators/react_on_rails/pro_setup.rb +102 -26
- data/lib/generators/react_on_rails/react_with_redux_generator.rb +3 -2
- data/lib/generators/react_on_rails/templates/base/base/.env.example +25 -0
- data/lib/generators/react_on_rails/templates/base/base/.github/workflows/ci.yml.tt +86 -0
- data/lib/generators/react_on_rails/templates/base/base/Procfile.dev +4 -3
- data/lib/generators/react_on_rails/templates/base/base/babel.config.js.tt +1 -1
- data/lib/generators/react_on_rails/templates/base/base/bin/switch-bundler +2 -2
- data/lib/generators/react_on_rails/templates/base/base/config/webpack/ServerClientOrBoth.js.tt +1 -1
- data/lib/generators/react_on_rails/templates/base/base/config/webpack/clientWebpackConfig.js.tt +1 -1
- data/lib/generators/react_on_rails/templates/base/base/config/webpack/commonWebpackConfig.js.tt +2 -2
- data/lib/generators/react_on_rails/templates/base/base/config/webpack/development.js.tt +1 -1
- data/lib/generators/react_on_rails/templates/base/base/config/webpack/production.js.tt +1 -1
- data/lib/generators/react_on_rails/templates/base/base/config/webpack/serverWebpackConfig.js.tt +6 -5
- data/lib/generators/react_on_rails/templates/base/base/config/webpack/test.js.tt +1 -1
- data/lib/generators/react_on_rails/templates/pro/base/config/initializers/react_on_rails_pro.rb.tt +1 -1
- data/lib/generators/react_on_rails/templates/pro/base/{client → renderer}/node-renderer.js +1 -0
- data/lib/react_on_rails/config_path_resolver.rb +101 -4
- data/lib/react_on_rails/configuration.rb +22 -0
- data/lib/react_on_rails/dev/file_manager.rb +135 -8
- data/lib/react_on_rails/dev/port_selector.rb +259 -7
- data/lib/react_on_rails/dev/process_manager.rb +29 -2
- data/lib/react_on_rails/dev/server_manager.rb +607 -39
- data/lib/react_on_rails/doctor.rb +513 -45
- data/lib/react_on_rails/helper.rb +3 -11
- data/lib/react_on_rails/js_code_builder.rb +66 -0
- data/lib/react_on_rails/length_prefixed_parser.rb +142 -0
- data/lib/react_on_rails/packs_generator.rb +65 -12
- data/lib/react_on_rails/pro_migration.rb +175 -0
- data/lib/react_on_rails/render_request.rb +74 -0
- data/lib/react_on_rails/rendering_strategy/exec_js_strategy.rb +29 -0
- data/lib/react_on_rails/rendering_strategy.rb +44 -0
- data/lib/react_on_rails/server_rendering_pool/ruby_embedded_java_script.rb +33 -22
- data/lib/react_on_rails/system_checker.rb +44 -23
- data/lib/react_on_rails/utils.rb +5 -0
- data/lib/react_on_rails/version.rb +1 -1
- data/lib/react_on_rails.rb +3 -0
- data/rakelib/run_rspec.rake +0 -5
- data/rakelib/shakapacker_examples.rake +66 -23
- data/react_on_rails.gemspec +18 -8
- data/sig/react_on_rails/js_code_builder.rbs +11 -0
- data/sig/react_on_rails/render_request.rbs +28 -0
- data/sig/react_on_rails/rendering_strategy/exec_js_strategy.rbs +11 -0
- data/sig/react_on_rails/rendering_strategy.rbs +7 -0
- data/sig/react_on_rails.rbs +6 -0
- 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
|
-
|
|
53
|
+
with_generated_packs_lock(verbose: verbose) do
|
|
54
|
+
add_generated_pack_to_server_bundle
|
|
52
55
|
|
|
53
|
-
|
|
54
|
-
|
|
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
|
-
|
|
57
|
-
File.exist?(generated_server_bundle_file_path) &&
|
|
58
|
-
!stale_or_missing_packs?
|
|
66
|
+
private
|
|
59
67
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
-
|
|
66
|
-
|
|
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
|
-
|
|
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
|