Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@
// https://www.shakacode.com/react-on-rails-pro/docs/react-server-components/

import React from 'react';
import LikeButton from './LikeButton';

// Simulate an async data fetch (replace with a real API call or DB query)
async function fetchGreeting(name) {
Expand All @@ -39,7 +38,7 @@ async function fetchGreeting(name) {
facts: [
'This component rendered entirely on the server — zero JS was sent to the browser for it.',
'The date formatting above used server-side Intl APIs — no date library shipped to the client.',
'The "Like" button below IS a client component — only its JS is sent to the browser.',
'This starter keeps the example server-only so the default scaffold runs cleanly.',
],
};
}
Expand Down Expand Up @@ -70,9 +69,6 @@ const HelloServer = async ({ name = 'World' }) => {
))}
</ul>
</div>

{/* LikeButton is a client component — it ships JS for interactivity */}
<LikeButton />
</div>
);
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@
// https://www.shakacode.com/react-on-rails-pro/docs/react-server-components/

import React from 'react';
import LikeButton from './LikeButton';

interface HelloServerProps {
name?: string;
Expand Down Expand Up @@ -49,7 +48,7 @@ async function fetchGreeting(name: string): Promise<GreetingData> {
facts: [
'This component rendered entirely on the server — zero JS was sent to the browser for it.',
'The date formatting above used server-side Intl APIs — no date library shipped to the client.',
'The "Like" button below IS a client component — only its JS is sent to the browser.',
'This starter keeps the example server-only so the default scaffold runs cleanly.',
],
};
}
Expand Down Expand Up @@ -80,9 +79,6 @@ const HelloServer = async ({ name = 'World' }: HelloServerProps) => {
))}
</ul>
</div>

{/* LikeButton is a client component — it ships JS for interactivity */}
<LikeButton />
</div>
);
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
- prerender: true enables server-side rendering with streaming
- Props are passed from the controller (@hello_server_props)
- The component streams as it resolves (async data fetching happens server-side)
- Only the interactive LikeButton client component ships JS to the browser
- The starter example is server-only to keep the initial scaffold deterministic
- Open DevTools Network tab to verify — no HelloServer.js in the client bundle
%>
<%= stream_react_component('HelloServer', props: @hello_server_props, prerender: true) %>
Expand Down
73 changes: 59 additions & 14 deletions react_on_rails/lib/react_on_rails/system_checker.rb
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
# frozen_string_literal: true

require "open3"
require "erb"
require "yaml"

module ReactOnRails
# SystemChecker provides validation methods for React on Rails setup
Expand Down Expand Up @@ -186,17 +188,17 @@ def check_react_on_rails_npm_package
return unless File.exist?(package_json_path)

package_json = JSON.parse(File.read(package_json_path))
npm_version = package_json.dig("dependencies", "react-on-rails") ||
package_json.dig("devDependencies", "react-on-rails")
package_name, npm_version = react_on_rails_npm_package_details(package_json)

if npm_version
add_success("✅ react-on-rails NPM package #{npm_version} is declared")
add_success("✅ #{package_name} NPM package #{npm_version} is declared")
else
add_warning(<<~MSG.strip)
⚠️ react-on-rails NPM package not found in package.json.
⚠️ Neither react-on-rails nor react-on-rails-pro NPM package was found in package.json.

Install it with:
npm install react-on-rails
Install one with:
npm install react-on-rails # Open source gem
npm install react-on-rails-pro # Pro gem
MSG
end
rescue JSON::ParserError
Expand All @@ -208,8 +210,7 @@ def check_package_version_sync

begin
package_json = JSON.parse(File.read("package.json"))
npm_version = package_json.dig("dependencies", "react-on-rails") ||
package_json.dig("devDependencies", "react-on-rails")
package_name, npm_version = react_on_rails_npm_package_details(package_json)

return unless npm_version && defined?(ReactOnRails::VERSION)

Expand All @@ -221,7 +222,7 @@ def check_package_version_sync
gem_version = ReactOnRails::VERSION

if normalized_npm_version == gem_version
add_success("✅ React on Rails gem and NPM package versions match (#{gem_version})")
add_success("✅ React on Rails gem and #{package_name} NPM package versions match (#{gem_version})")
check_version_patterns(npm_version, gem_version)
else
# Check for major version differences
Expand All @@ -232,7 +233,7 @@ def check_package_version_sync
add_error(<<~MSG.strip)
🚫 Major version mismatch detected:
• Gem version: #{gem_version} (major: #{gem_major})
NPM version: #{npm_version} (major: #{npm_major})
#{package_name} version: #{npm_version} (major: #{npm_major})

Major version differences can cause serious compatibility issues.
Update both packages to use the same major version immediately.
Expand All @@ -241,7 +242,7 @@ def check_package_version_sync
add_warning(<<~MSG.strip)
⚠️ Version mismatch detected:
• Gem version: #{gem_version}
NPM version: #{npm_version}
#{package_name} version: #{npm_version}

Consider updating to exact, fixed matching versions of gem and npm package for best compatibility.
MSG
Expand Down Expand Up @@ -449,11 +450,55 @@ def normalize_config_content(content)
end

def required_react_dependencies
{
deps = {
"react" => "React library",
"react-dom" => "React DOM library",
"@babel/preset-react" => "Babel React preset"
"react-dom" => "React DOM library"
}

deps["@babel/preset-react"] = "Babel React preset" if using_babel_transpiler?
deps
end

def react_on_rails_npm_package_details(package_json)
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

merge here gives devDependencies precedence over dependencies when the same package key exists in both. The original code checked dependencies first (higher precedence). To restore that behavior:

Suggested change
def react_on_rails_npm_package_details(package_json)
all_deps = (package_json["devDependencies"] || {}).merge(package_json["dependencies"] || {})

This is a minor edge case (unlikely anyone has the same package in both sections), but the intent should be explicit.

all_deps = package_json["dependencies"]&.merge(package_json["devDependencies"] || {}) || {}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pre-existing: nil dependencies drops devDependencies

If package_json["dependencies"] is nil (e.g., a project that only has devDependencies), the safe-navigation &.merge(...) returns nil, and nil || {} yields an empty hash — silently dropping everything in devDependencies. A package listed only in devDependencies would not be found.

This is a pre-existing pattern used throughout the file (7 occurrences), so not a regression from this PR, but worth noting since this method is now the single source of truth for Pro/OSS detection. A safer alternative would be:

all_deps = (package_json["dependencies"] || {}).merge(package_json["devDependencies"] || {})

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Merge pattern loses devDependencies when dependencies key absent

Medium Severity

The react_on_rails_npm_package_details method uses package_json["dependencies"]&.merge(package_json["devDependencies"] || {}) || {} to combine deps. When a package.json has no "dependencies" key at all (only "devDependencies"), the safe navigation operator returns nil, and nil || {} discards all devDependencies. This is a regression from the old code which used independent dig calls for each section, correctly finding packages in either location regardless of whether the other key existed.

Fix in Cursor Fix in Web

return ["react-on-rails-pro", all_deps["react-on-rails-pro"]] if all_deps["react-on-rails-pro"]
return ["react-on-rails", all_deps["react-on-rails"]] if all_deps["react-on-rails"]

[nil, nil]
end

def using_babel_transpiler?
transpiler = detected_javascript_transpiler
return true if transpiler.nil?

transpiler == "babel"
end

def detected_javascript_transpiler
config = parsed_shakapacker_config
return nil unless config

rails_env = ENV["RAILS_ENV"] || ENV["RACK_ENV"] || "development"
env_config = config[rails_env] || {}
default_config = config["default"] || {}
transpiler = env_config["javascript_transpiler"] || default_config["javascript_transpiler"]
normalize_transpiler_value(transpiler)
end

def parsed_shakapacker_config
shakapacker_config_path = "config/shakapacker.yml"
return nil unless File.exist?(shakapacker_config_path)

raw_content = File.read(shakapacker_config_path)
rendered_content = ERB.new(raw_content).result
YAML.safe_load(rendered_content, aliases: true) || {}
rescue StandardError
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The broad rescue StandardError here will silently swallow meaningful errors. If shakapacker.yml contains invalid ERB (e.g., <%= undefined_method %>), the ERB::SyntaxError is caught, nil is returned, and using_babel_transpiler? falls back to true — causing a spurious @babel/preset-react warning with no indication of why. Consider rescuing specific, expected exceptions instead:

Suggested change
rescue StandardError
rescue Errno::ENOENT, Errno::EACCES, Psych::SyntaxError, YAML::SyntaxError
nil
rescue ERB::SyntaxError, SyntaxError
# ERB evaluation failed — fall back to requiring babel but don't crash doctor
nil

Or at minimum log the error via warn so developers can diagnose the issue.

nil
end

def normalize_transpiler_value(transpiler)
normalized = transpiler.to_s.strip.downcase
normalized.empty? ? nil : normalized
end

def additional_build_dependencies
Expand Down
97 changes: 96 additions & 1 deletion react_on_rails/spec/lib/react_on_rails/system_checker_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -304,7 +304,25 @@
it "adds a warning message" do
checker.check_react_on_rails_npm_package
expect(checker.warnings?).to be true
expect(checker.messages.last[:content]).to include("react-on-rails NPM package not found")
expect(checker.messages.last[:content]).to include("Neither react-on-rails nor react-on-rails-pro")
end
end

context "when package.json exists with react-on-rails-pro" do
let(:package_json_content) do
{ "dependencies" => { "react-on-rails-pro" => "^16.0.0" } }.to_json
end

before do
allow(File).to receive(:exist?).with("package.json").and_return(true)
allow(File).to receive(:read).with("package.json").and_return(package_json_content)
end

it "adds a success message for pro package" do
checker.check_react_on_rails_npm_package
expect(checker.messages.any? do |msg|
msg[:type] == :success && msg[:content].include?("react-on-rails-pro NPM package")
end).to be true
end
end

Expand Down Expand Up @@ -356,6 +374,24 @@
end
end

context "when package.json exists with matching versions for react-on-rails-pro" do
let(:package_json_content) do
{ "dependencies" => { "react-on-rails-pro" => "16.2.0-beta.10" } }.to_json
end

before do
allow(File).to receive(:exist?).with("package.json").and_return(true)
allow(File).to receive(:read).with("package.json").and_return(package_json_content)
end

it "adds a success message" do
checker.send(:check_package_version_sync)
expect(checker.messages.any? do |msg|
msg[:type] == :success && msg[:content].include?("versions match")
end).to be true
end
end

context "when package.json has beta version with caret prefix" do
let(:package_json_content) do
{ "dependencies" => { "react-on-rails" => "^16.2.0-beta.10" } }.to_json
Expand Down Expand Up @@ -480,6 +516,65 @@
end
end

describe "#check_react_dependencies" do
let(:base_package_json) do
{
"dependencies" => {
"react" => "^19.0.0",
"react-dom" => "^19.0.0"
}
}
end

before do
allow(File).to receive(:exist?).and_call_original
allow(File).to receive(:exist?).with("package.json").and_return(true)
allow(File).to receive(:read).with("package.json").and_return(base_package_json.to_json)
end

context "when shakapacker javascript_transpiler is swc" do
let(:shakapacker_content) do
<<~YAML
default:
javascript_transpiler: swc
YAML
end

before do
allow(File).to receive(:exist?).with("config/shakapacker.yml").and_return(true)
allow(File).to receive(:read).with("config/shakapacker.yml").and_return(shakapacker_content)
end

it "does not warn about missing @babel/preset-react" do
checker.check_react_dependencies
expect(checker.messages.none? do |msg|
msg[:type] == :warning && msg[:content].include?("@babel/preset-react")
end).to be true
end
end

context "when shakapacker javascript_transpiler is babel" do
let(:shakapacker_content) do
<<~YAML
default:
javascript_transpiler: babel
YAML
end

before do
allow(File).to receive(:exist?).with("config/shakapacker.yml").and_return(true)
allow(File).to receive(:read).with("config/shakapacker.yml").and_return(shakapacker_content)
end

it "warns when @babel/preset-react is missing" do
checker.check_react_dependencies
expect(checker.messages.any? do |msg|
msg[:type] == :warning && msg[:content].include?("@babel/preset-react")
end).to be true
end
end
end

describe "private methods" do
describe "#cli_exists?" do
it "returns true when command exists" do
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -782,6 +782,13 @@
end
end

it "keeps default HelloServer component server-only" do
assert_file "app/javascript/src/HelloServer/components/HelloServer.jsx" do |content|
expect(content).not_to include("import LikeButton")
expect(content).not_to include("<LikeButton />")
end
end

it "adds HelloServer route" do
assert_file "config/routes.rb" do |content|
expect(content).to include("hello_server")
Expand Down Expand Up @@ -863,6 +870,13 @@
assert_file "app/javascript/src/HelloServer/components/LikeButton.tsx"
end

it "keeps TypeScript HelloServer component server-only by default" do
assert_file "app/javascript/src/HelloServer/components/HelloServer.tsx" do |content|
expect(content).not_to include("import LikeButton")
expect(content).not_to include("<LikeButton />")
end
end

it "creates tsconfig.json file" do
assert_file "tsconfig.json" do |content|
config = JSON.parse(content)
Expand Down
Loading