Skip to content
Merged
Show file tree
Hide file tree
Changes from 14 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,12 @@ After a release, run `/update-changelog` in Claude Code to analyze commits, writ

### [Unreleased]

#### Fixed
Comment thread
justin808 marked this conversation as resolved.

- **Ruby 3.4 compatibility for heredocs**: Replaced legacy `strip_heredoc` usage with native squiggly heredocs (`<<~`) and removed redundant chaining where indentation is already normalized by Ruby. [PR 2599](https://github.com/shakacode/react_on_rails/pull/2599) by [justin808](https://github.com/justin808).
- **Fix install generator load path for `ReactOnRails::GitUtils`**: Added an explicit `require "react_on_rails/git_utils"` so generator execution does not rely on broader app boot side effects for this constant to be available. [PR 2599](https://github.com/shakacode/react_on_rails/pull/2599) by [justin808](https://github.com/justin808).
- **`server_render_js` now handles non-Error throws safely**: Defensive error serialization now supports thrown primitives and `null` values without raising secondary `TypeError` exceptions while building SSR error payloads. [PR 2599](https://github.com/shakacode/react_on_rails/pull/2599) by [justin808](https://github.com/justin808).

Comment thread
coderabbitai[bot] marked this conversation as resolved.
### [16.4.0.rc.9] - 2026-03-12

#### Improved
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -183,7 +183,7 @@ def append_to_spec_rails_helper
add_configure_minitest_to_compile_assets(test_helper) if File.exist?(test_helper)
end

CONFIGURE_RSPEC_TO_COMPILE_ASSETS = <<-STR.strip_heredoc
CONFIGURE_RSPEC_TO_COMPILE_ASSETS = <<~STR
RSpec.configure do |config|
# Ensure that if we are running js tests, we are using latest webpack assets
# This will use the defaults of :js and :server_rendering meta tags
Expand All @@ -193,7 +193,7 @@ def append_to_spec_rails_helper
end
STR

CONFIGURE_MINITEST_TO_COMPILE_ASSETS = <<-STR.strip_heredoc
CONFIGURE_MINITEST_TO_COMPILE_ASSETS = <<~STR
# Ensure that tests run against fresh webpack assets.
ActiveSupport::TestCase.setup do
ReactOnRails::TestHelper.ensure_assets_compiled
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@
require_relative "js_dependency_manager"
require_relative "pro_setup"
require_relative "rsc_setup"
Comment thread
justin808 marked this conversation as resolved.
# Load-path require: git_utils lives under react_on_rails/lib, not relative to this generator directory.
require "react_on_rails/git_utils"
Comment thread
justin808 marked this conversation as resolved.
Comment thread
justin808 marked this conversation as resolved.

module ReactOnRails
module Generators
Expand Down
8 changes: 4 additions & 4 deletions react_on_rails/lib/react_on_rails/configuration.rb
Original file line number Diff line number Diff line change
Expand Up @@ -408,10 +408,10 @@ def check_server_render_method_is_only_execjs
return if server_render_method.blank? ||
server_render_method == "ExecJS"

msg = <<-MSG.strip_heredoc
Error configuring /config/initializers/react_on_rails.rb: invalid value for `config.server_render_method`.
If you wish to use a server render method other than ExecJS, contact [email protected]
for details.
msg = <<~MSG
Error configuring /config/initializers/react_on_rails.rb: invalid value for `config.server_render_method`.
If you wish to use a server render method other than ExecJS, contact [email protected]
for details.
MSG
raise ReactOnRails::Error, msg
end
Expand Down
88 changes: 50 additions & 38 deletions react_on_rails/lib/react_on_rails/helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -210,43 +210,55 @@ def server_render_js(js_expression, options = {})
render_options = ReactOnRails::ReactComponent::RenderOptions
.new(react_component_name: "generic-js", options: options)

js_code = <<-JS.strip_heredoc
(function() {
var htmlResult = '';
var consoleReplayScript = '';
var hasErrors = false;
var renderingError = null;
var renderingErrorObject = {};

try {
htmlResult =
(function() {
return #{js_expression};
})();
} catch(e) {
renderingError = e;
if (#{render_options.throw_js_errors}) {
throw e;
js_code = <<~JS
(function() {
var htmlResult = '';
var consoleReplayScript = '';
var hasErrors = false;
var renderingError = null;
var renderingErrorObject = {};

try {
htmlResult =
(function() {
return #{js_expression};
})();
} catch(e) {
renderingError = e;
if (#{render_options.throw_js_errors}) {
throw e;
Comment thread
justin808 marked this conversation as resolved.
}
htmlResult = ReactOnRails.handleError({e: e, name: null,
jsCode: '#{escape_javascript(js_expression)}', serverSide: true});
hasErrors = true;
var errorMessage = String(renderingError);
Comment thread
justin808 marked this conversation as resolved.
var errorStack = null;
// Guard against non-Error throws (e.g., throw null / throw "string").
// Boxed primitives (for example new Boolean(false)) are objects too.
if (renderingError && typeof renderingError === 'object') {
Comment thread
justin808 marked this conversation as resolved.
Comment thread
justin808 marked this conversation as resolved.
if ('message' in renderingError) {
errorMessage = String(renderingError.message);
}
if ('stack' in renderingError && renderingError.stack != null) {
Comment thread
justin808 marked this conversation as resolved.
errorStack = String(renderingError.stack);
}
}
renderingErrorObject = {
message: errorMessage,
stack: errorStack,
Comment thread
justin808 marked this conversation as resolved.
}
Comment thread
justin808 marked this conversation as resolved.
Outdated
}
htmlResult = ReactOnRails.handleError({e: e, name: null,
jsCode: '#{escape_javascript(js_expression)}', serverSide: true});
hasErrors = true;
renderingErrorObject = {
message: renderingError.message,
stack: renderingError.stack,
}
}

consoleReplayScript = ReactOnRails.getConsoleReplayScript();
consoleReplayScript = ReactOnRails.getConsoleReplayScript();

return JSON.stringify({
html: htmlResult,
consoleReplayScript: consoleReplayScript,
hasErrors: hasErrors,
renderingError: renderingErrorObject
});
return JSON.stringify({
html: htmlResult,
consoleReplayScript: consoleReplayScript,
hasErrors: hasErrors,
renderingError: renderingErrorObject
Comment thread
justin808 marked this conversation as resolved.
});

})()
})()
JS

Comment thread
justin808 marked this conversation as resolved.
result = ReactOnRails::ServerRenderingPool
Expand Down Expand Up @@ -767,11 +779,11 @@ def initialize_redux_stores(render_options)
result << store_objects.each_with_object(declarations) do |redux_store_data, memo|
store_name = redux_store_data[:store_name]
props = props_string(redux_store_data[:props])
memo << <<-JS.strip_heredoc
reduxProps = #{props};
storeGenerator = ReactOnRails.getStoreGenerator(#{store_name.to_json});
store = storeGenerator(reduxProps, railsContext);
ReactOnRails.setStore(#{store_name.to_json}, store);
memo << <<~JS
reduxProps = #{props};
storeGenerator = ReactOnRails.getStoreGenerator(#{store_name.to_json});
store = storeGenerator(reduxProps, railsContext);
ReactOnRails.setStore(#{store_name.to_json}, store);
JS
end
result
Expand Down
4 changes: 2 additions & 2 deletions react_on_rails/lib/react_on_rails/locales/base.rb
Original file line number Diff line number Diff line change
Expand Up @@ -181,13 +181,13 @@ def flatten(translations)
end

def template_translations
<<-JS.strip_heredoc
<<~JS
export const translations = #{@translations};
JS
end

def template_default
<<-JS.strip_heredoc
<<~JS
import { defineMessages } from 'react-intl';

const defaultLocale = '#{default_locale}';
Expand Down
4 changes: 2 additions & 2 deletions react_on_rails/lib/react_on_rails/locales/to_js.rb
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,13 @@ def file_format
end

def template_translations
<<-JS.strip_heredoc
<<~JS
export const translations = #{@translations};
JS
end

def template_default
<<-JS.strip_heredoc
<<~JS
import { defineMessages } from 'react-intl';

const defaultLocale = '#{default_locale}';
Expand Down
8 changes: 4 additions & 4 deletions react_on_rails/lib/react_on_rails/packer_utils.rb
Original file line number Diff line number Diff line change
Expand Up @@ -113,10 +113,10 @@ def self.packer_source_path_explicit?
def self.check_manifest_not_cached
return unless ::Shakapacker.config.cache_manifest?

msg = <<-MSG.strip_heredoc
ERROR: you have enabled cache_manifest in the #{Rails.env} env when using the
ReactOnRails::TestHelper.configure_rspec_to_compile_assets helper
To fix this: edit your config/shakapacker.yml file and set cache_manifest to false for test.
msg = <<~MSG
ERROR: you have enabled cache_manifest in the #{Rails.env} env when using the
ReactOnRails::TestHelper.configure_rspec_to_compile_assets helper
To fix this: edit your config/shakapacker.yml file and set cache_manifest to false for test.
MSG
puts wrap_message(msg)
exit!
Expand Down
2 changes: 1 addition & 1 deletion react_on_rails/lib/react_on_rails/pro_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ def generate_store_script(redux_store_data)
script_options = nonce.present? ? { nonce: nonce } : {}
immediate_script = content_tag(
:script,
<<~JS.strip_heredoc.html_safe,
<<~JS.html_safe,
typeof ReactOnRails === 'object' && ReactOnRails.reactOnRailsStoreLoaded('#{escaped_store_name}');
JS
script_options
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ def trace_js_code_used(msg, js_code, file_name = "tmp/server-generated.js", forc

# Set to anything to print generated code.
File.write(file_name, js_code)
msg = <<-MSG.strip_heredoc
msg = <<~MSG
#{'Z' * 80}
[react_on_rails] #{msg}
JavaScript code used: #{file_name}
Expand Down
2 changes: 1 addition & 1 deletion react_on_rails/lib/react_on_rails/test_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ def self.ensure_assets_compiled(webpack_assets_status_checker: nil,
@printed_once = true

if ReactOnRails::Utils.using_packer_source_path_is_not_defined_and_custom_node_modules?
msg = <<-MSG.strip_heredoc
msg = <<~MSG
WARNING: Define config/shakapacker.yml to include sourcePath to configure
the location of your JavaScript source for React on Rails.
Default location of #{source_path} is used.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -208,7 +208,7 @@ def helper.append_javascript_pack_tag(name, **options)
let(:id) { "App-react-component-0" }

let(:react_definition_script) do
<<-SCRIPT.strip_heredoc
<<~SCRIPT
<script type="application/json" class="js-react-on-rails-component" \
id="js-react-on-rails-component-App-react-component" \
data-component-name="App" data-dom-id="App-react-component"
Expand All @@ -217,7 +217,7 @@ def helper.append_javascript_pack_tag(name, **options)
end

let(:react_definition_script_no_params) do
<<-SCRIPT.strip_heredoc
<<~SCRIPT
<script type="application/json" class="js-react-on-rails-component" \
id="js-react-on-rails-component-App-react-component" \
data-component-name="App" data-dom-id="App-react-component"
Expand Down Expand Up @@ -331,7 +331,7 @@ def helper.append_javascript_pack_tag(name, **options)
subject(:react_app) { react_component("App", props: props, random_dom_id: false) }

let(:react_definition_script) do
<<-SCRIPT.strip_heredoc
<<~SCRIPT
<script type="application/json" class="js-react-on-rails-component" \
id="js-react-on-rails-component-App-react-component" \
data-component-name="App" data-dom-id="App-react-component"
Expand All @@ -347,7 +347,7 @@ def helper.append_javascript_pack_tag(name, **options)
subject(:react_app) { react_component("App", props: props, random_dom_id: true) }

let(:react_definition_script) do
<<-SCRIPT.strip_heredoc
<<~SCRIPT
<script type="application/json" class="js-react-on-rails-component" \
id="js-react-on-rails-component-App-react-component-0" \
data-component-name="App" data-dom-id="App-react-component-0"
Expand All @@ -369,7 +369,7 @@ def helper.append_javascript_pack_tag(name, **options)
end

let(:react_definition_script) do
<<-SCRIPT.strip_heredoc
<<~SCRIPT
<script type="application/json" class="js-react-on-rails-component" \
id="js-react-on-rails-component-App-react-component" \
data-component-name="App" data-dom-id="App-react-component"
Expand All @@ -387,7 +387,7 @@ def helper.append_javascript_pack_tag(name, **options)
let(:id) { "shaka_div" }

let(:react_definition_script) do
<<-SCRIPT.strip_heredoc
<<~SCRIPT
<script type="application/json" class="js-react-on-rails-component" \
id="js-react-on-rails-component-shaka_div" \
data-component-name="App" data-dom-id="shaka_div"
Expand Down Expand Up @@ -475,6 +475,89 @@ def helper.append_javascript_pack_tag(name, **options)
end
end

describe "#server_render_js error serialization" do
let(:runtime_available) do
ExecJS.runtime&.available?
rescue ExecJS::RuntimeUnavailable
false
end

let(:runtime_context) do
ExecJS.compile(<<~JS)
function runGeneratedCode(generatedCode) {
var ReactOnRails = {
handleError: function() { return ''; },
getConsoleReplayScript: function() { return ''; }
};
// Evaluate generated wrapper JS in a test sandbox before Ruby post-processing.
return eval(generatedCode);
Comment thread
justin808 marked this conversation as resolved.
Comment thread
justin808 marked this conversation as resolved.
}
JS
end

before do
skip "ExecJS runtime not available" unless runtime_available
end

it "generates JS with safe error property access for non-Error throws" do
captured_results = []

allow(ReactOnRails::ServerRenderingPool)
.to receive(:server_render_js_with_console_logging) do |js_code, _opts|
# Validate generated JS behavior directly before Ruby-side post-processing.
runtime_result = runtime_context.call("runGeneratedCode", js_code)
captured_results << JSON.parse(runtime_result)
{
"html" => "",
"consoleReplayScript" => "",
"hasErrors" => true,
"renderingError" => { "message" => "stub", "stack" => nil }
}
end

throw_cases = [
{ expression: "(function() { throw null; })()", message: "null", stack: nil },
{ expression: "(function() { throw { code: 42 }; })()", message: "[object Object]", stack: nil },
{ expression: "(function() { throw new Error(\"boom\"); })()", message: "boom", stack: :present }
]

throw_cases.each do |throw_case|
expect { server_render_js(throw_case[:expression]) }.not_to raise_error
captured_result = captured_results.last
expect(captured_result).to be_a(Hash)
expect(captured_result["hasErrors"]).to be(true)
expect(captured_result.dig("renderingError", "message")).to eq(throw_case[:message])

if throw_case[:stack] == :present
expect(captured_result.dig("renderingError", "stack")).to include("Error: boom")
else
expect(captured_result.dig("renderingError", "stack")).to be_nil
end
end
Comment thread
justin808 marked this conversation as resolved.

expect(captured_results.length).to eq(throw_cases.length)
end

it "raises PrerenderError when throw_js_errors is true and JS throws a non-Error value" do
Comment thread
justin808 marked this conversation as resolved.
allow(ReactOnRails::ServerRenderingPool)
.to receive(:server_render_js_with_console_logging) do |js_code, _opts|
runtime_context.call("runGeneratedCode", js_code)
# Defensive fallback: if runtime does not raise, force ProgramError path.
raise ExecJS::ProgramError, "Expected generated JS to throw"
Comment thread
justin808 marked this conversation as resolved.
Outdated
rescue ExecJS::ProgramError
# Preserve original ProgramError; must precede the StandardError rescue.
raise
rescue StandardError => e
# Normalize non-ExecJS exceptions so server_render_js rescues consistently.
raise ExecJS::ProgramError, e.message
end

expect do
server_render_js("(function() { throw 42; })()", throw_js_errors: true)
end.to raise_error(ReactOnRails::PrerenderError)
end
end
Comment thread
justin808 marked this conversation as resolved.

Comment thread
justin808 marked this conversation as resolved.
describe "#redux_store" do
subject(:store) { redux_store("reduxStore", props: props, immediate_hydration: true) }

Comment thread
justin808 marked this conversation as resolved.
Expand Down
Loading
Loading