From c14431e5250eb7d60d2e018df31e2e04dccbb682 Mon Sep 17 00:00:00 2001 From: Justin Gordon Date: Mon, 23 Mar 2026 17:48:25 -1000 Subject: [PATCH 01/37] Add first-class --rsc-pro install mode --- .../react_on_rails/generator_helper.rb | 18 ++- .../react_on_rails/install_generator.rb | 34 +++++- .../react_on_rails/js_dependency_manager.rb | 25 +++- .../generators/react_on_rails/pro_setup.rb | 31 +++-- .../generators/generator_helper_spec.rb | 31 +++++ .../generators/install_generator_spec.rb | 111 ++++++++++++++++++ .../generators/js_dependency_manager_spec.rb | 46 ++++++++ 7 files changed, 279 insertions(+), 17 deletions(-) diff --git a/react_on_rails/lib/generators/react_on_rails/generator_helper.rb b/react_on_rails/lib/generators/react_on_rails/generator_helper.rb index b5c375eb31..503e684052 100644 --- a/react_on_rails/lib/generators/react_on_rails/generator_helper.rb +++ b/react_on_rails/lib/generators/react_on_rails/generator_helper.rb @@ -151,20 +151,28 @@ def mark_pro_gem_installed! @pro_gem_installed = true end - # Check if Pro features should be enabled - # Returns true if --pro flag is set OR --rsc flag is set (RSC implies Pro) + # Check if first-class RSC Pro mode should be enabled. + # Returns true when --rsc-pro is set, or when users explicitly pass both --rsc and --pro. + # + # @return [Boolean] true if RSC Pro mode semantics should be applied + def use_rsc_pro_mode? + options[:rsc_pro] || (options[:rsc] && options[:pro]) + end + + # Check if Pro features should be enabled. + # Returns true if --pro, --rsc, or --rsc-pro is set (RSC implies Pro). # # @return [Boolean] true if Pro setup should be included def use_pro? - options[:pro] || options[:rsc] + options[:pro] || options[:rsc] || options[:rsc_pro] end # Check if RSC (React Server Components) should be enabled - # Returns true only if --rsc flag is explicitly set + # Returns true if --rsc or --rsc-pro is explicitly set # # @return [Boolean] true if RSC setup should be included def use_rsc? - options[:rsc] + options[:rsc] || options[:rsc_pro] end # Determine if the project is using rspack as the bundler. diff --git a/react_on_rails/lib/generators/react_on_rails/install_generator.rb b/react_on_rails/lib/generators/react_on_rails/install_generator.rb index f900ba37ce..9c1d88fda8 100644 --- a/react_on_rails/lib/generators/react_on_rails/install_generator.rb +++ b/react_on_rails/lib/generators/react_on_rails/install_generator.rb @@ -65,6 +65,12 @@ class InstallGenerator < Rails::Generators::Base default: false, desc: "Install React Server Components support (includes Pro). Default: false" + # --rsc-pro + class_option :rsc_pro, + type: :boolean, + default: false, + desc: "Install first-class Pro RSC mode with matched Pro/RSC defaults. Default: false" + # Hidden option: allows tests (and advanced users) to signal that Shakapacker # was just installed, triggering force-overwrite of shakapacker.yml with RoR's template. # The CLI flag takes precedence over runtime detection (@shakapacker_just_installed): @@ -190,7 +196,7 @@ def invoke_generators # --pretend/--force/--skip must be forwarded explicitly at each boundary. invoke "react_on_rails:base", [], { typescript: options.typescript?, redux: options.redux?, rspack: options.rspack?, - pro: options.pro?, rsc: options.rsc?, new_app: options.new_app?, + pro: use_pro?, rsc: use_rsc?, new_app: options.new_app?, shakapacker_just_installed: shakapacker_just_installed?, force: options[:force], skip: options[:skip], pretend: options[:pretend] } @@ -274,8 +280,9 @@ def installation_prerequisites_met? # it on a clean worktree. On a dirty tree, use the read-only pro_gem_installed? # check to catch a missing gem without triggering auto-install. if has_worktree_issues && use_pro? && !pro_gem_installed? + required_flag = missing_pro_required_flag GeneratorMessages.add_error(<<~MSG.strip) - đŸšĢ react_on_rails_pro gem is required for #{options[:rsc] ? '--rsc' : '--pro'} but is not installed. + đŸšĢ react_on_rails_pro gem is required for #{required_flag} but is not installed. Auto-install was skipped because the worktree has uncommitted changes. Please add it manually: gem 'react_on_rails_pro', '~> #{recommended_pro_gem_version}' @@ -478,6 +485,7 @@ def add_post_install_message shakapacker_just_installed: shakapacker_just_installed?, landing_page: options.new_app? && new_app_root_route_available? )) + GeneratorMessages.add_info(rsc_pro_verification_message) if use_rsc_pro_mode? end def shakapacker_setup_incomplete? @@ -491,7 +499,9 @@ def recovery_install_command flags << "--typescript" if options.typescript? flags << "--rspack" if options.rspack? - if use_rsc? + if use_rsc_pro_mode? + flags << "--rsc-pro" + elsif use_rsc? flags << "--rsc" elsif options.pro? flags << "--pro" @@ -500,6 +510,24 @@ def recovery_install_command ["rails generate react_on_rails:install", *flags].join(" ") end + def rsc_pro_verification_message + <<~MSG + + 🔎 RSC Pro Verification: + ───────────────────────────────────────────────────────────────────────── + 1. Start all processes: #{Rainbow('bin/dev').cyan} + 2. Visit: #{Rainbow('http://localhost:3000/hello_server').cyan.underline} + 3. Confirm the page streams and the Like button hydrates on click. + MSG + end + + def missing_pro_required_flag + return "--rsc-pro" if use_rsc_pro_mode? + return "--rsc" if use_rsc? + + "--pro" + end + def recovery_working_tree_lines [ "If this run created or changed files, clean up your working tree before rerunning", diff --git a/react_on_rails/lib/generators/react_on_rails/js_dependency_manager.rb b/react_on_rails/lib/generators/react_on_rails/js_dependency_manager.rb index 48f8c4a648..0aa76628aa 100644 --- a/react_on_rails/lib/generators/react_on_rails/js_dependency_manager.rb +++ b/react_on_rails/lib/generators/react_on_rails/js_dependency_manager.rb @@ -383,13 +383,24 @@ def pro_packages_with_version def add_rsc_dependencies say "Installing React Server Components dependencies..." - return if add_packages(RSC_DEPENDENCIES) + rsc_packages = rsc_packages_with_version + return if add_packages(rsc_packages) + + manual_install_packages = rsc_packages + if rsc_packages != RSC_DEPENDENCIES + say_status :warning, + "Could not install version-pinned RSC dependency. Retrying latest available package.", + :yellow + return if add_packages(RSC_DEPENDENCIES) + + manual_install_packages = RSC_DEPENDENCIES + end GeneratorMessages.add_warning(<<~MSG.strip) âš ī¸ Failed to add React Server Components dependencies. You can install them manually by running: - npm install #{RSC_DEPENDENCIES.join(' ')} + npm install #{manual_install_packages.join(' ')} MSG rescue StandardError => e GeneratorMessages.add_warning(<<~MSG.strip) @@ -400,6 +411,16 @@ def add_rsc_dependencies MSG end + # Returns RSC package names pinned to the same version as the gem. + # Falls back to unversioned package names when version resolution fails. + def rsc_packages_with_version + npm_version = ReactOnRails::VersionSyntaxConverter.new.rubygem_to_npm(ReactOnRails::VERSION) + RSC_DEPENDENCIES.map { |pkg| "#{pkg}@#{npm_version}" } + rescue StandardError + say_status :warning, "Could not determine RSC package version. Installing latest.", :yellow + RSC_DEPENDENCIES + end + def remove_base_package_if_present pj = package_json return unless pj diff --git a/react_on_rails/lib/generators/react_on_rails/pro_setup.rb b/react_on_rails/lib/generators/react_on_rails/pro_setup.rb index 7075a9c893..0454e866db 100644 --- a/react_on_rails/lib/generators/react_on_rails/pro_setup.rb +++ b/react_on_rails/lib/generators/react_on_rails/pro_setup.rb @@ -60,12 +60,7 @@ def missing_pro_gem?(force: false) return false if pro_gem_installed? return false if attempt_pro_gem_auto_install - context_line = if options.key?(:pro) || options.key?(:rsc) - flag = options[:rsc] ? "--rsc" : "--pro" - "You specified #{flag}, which requires the react_on_rails_pro gem." - else - "This generator requires the react_on_rails_pro gem." - end + context_line = pro_gem_requirement_context_line GeneratorMessages.add_error(<<~MSG.strip) đŸšĢ Failed to auto-install #{PRO_GEM_NAME} gem. @@ -86,6 +81,23 @@ def missing_pro_gem?(force: false) private + def pro_gem_requirement_context_line + return "This generator requires the react_on_rails_pro gem." unless pro_flag_specified_for_context? + + "You specified #{pro_requirement_flag}, which requires the react_on_rails_pro gem." + end + + def pro_flag_specified_for_context? + options.key?(:pro) || options.key?(:rsc) || options.key?(:rsc_pro) + end + + def pro_requirement_flag + return "--rsc-pro" if respond_to?(:use_rsc_pro_mode?) && use_rsc_pro_mode? + return "--rsc" if options[:rsc] + + "--pro" + end + # Attempt to auto-install the Pro gem via bundle add. # Uses Process.spawn instead of Timeout.timeout to avoid Thread#raise corrupting # Bundler.with_unbundled_env's ENV restoration. @@ -459,7 +471,12 @@ def server_client_import_ready? end def pro_gem_auto_install_command - "bundle add #{PRO_GEM_NAME} --version='~> #{recommended_pro_gem_version}' --strict" + version_requirement = if respond_to?(:use_rsc_pro_mode?) && use_rsc_pro_mode? + recommended_pro_gem_version + else + "~> #{recommended_pro_gem_version}" + end + "bundle add #{PRO_GEM_NAME} --version='#{version_requirement}' --strict" end # Keep manual fallback pinned to the latest stable release (drop pre-release suffixes like .rc.N). diff --git a/react_on_rails/spec/react_on_rails/generators/generator_helper_spec.rb b/react_on_rails/spec/react_on_rails/generators/generator_helper_spec.rb index db69dcf642..9184ef9887 100644 --- a/react_on_rails/spec/react_on_rails/generators/generator_helper_spec.rb +++ b/react_on_rails/spec/react_on_rails/generators/generator_helper_spec.rb @@ -27,6 +27,11 @@ def shell @shell ||= Thor::Shell::Color.new end + # GeneratorHelper methods expect an options hash as provided by Thor generators. + def options + @options ||= {} + end + let(:destination_root) { File.expand_path("../dummy-for-generators", __dir__) } describe "#print_generator_messages" do @@ -184,6 +189,32 @@ def self.read end end + describe "RSC Pro mode helpers" do + it "enables rsc-pro mode for explicit --rsc-pro flag" do + allow(self).to receive(:options).and_return({ rsc_pro: true, rsc: false, pro: false }) + + expect(use_rsc_pro_mode?).to be(true) + expect(use_rsc?).to be(true) + expect(use_pro?).to be(true) + end + + it "enables rsc-pro mode when --rsc and --pro are both set" do + allow(self).to receive(:options).and_return({ rsc_pro: false, rsc: true, pro: true }) + + expect(use_rsc_pro_mode?).to be(true) + expect(use_rsc?).to be(true) + expect(use_pro?).to be(true) + end + + it "does not enable rsc-pro mode for standalone --pro" do + allow(self).to receive(:options).and_return({ rsc_pro: false, rsc: false, pro: true }) + + expect(use_rsc_pro_mode?).to be(false) + expect(use_rsc?).to be(false) + expect(use_pro?).to be(true) + end + end + describe "#using_swc?" do let(:shakapacker_yml_path) { File.join(destination_root, "config/shakapacker.yml") } diff --git a/react_on_rails/spec/react_on_rails/generators/install_generator_spec.rb b/react_on_rails/spec/react_on_rails/generators/install_generator_spec.rb index 3b4beee8d1..13d563d72d 100644 --- a/react_on_rails/spec/react_on_rails/generators/install_generator_spec.rb +++ b/react_on_rails/spec/react_on_rails/generators/install_generator_spec.rb @@ -1703,6 +1703,30 @@ class ActiveSupport::TestCase end end + context "with --rsc-pro" do + before(:all) { run_generator_test_with_args(%w[--rsc-pro], package_json: true) } + + include_examples "rsc_common_files" + include_examples "rsc_hello_server_files" + + it "pins Pro dependencies and installs the RSC dependency" do + expected_npm_version = ReactOnRails::VersionSyntaxConverter.new.rubygem_to_npm(ReactOnRails::VERSION) + + assert_file "package.json" do |content| + package_json = JSON.parse(content) + deps = package_json["dependencies"] || {} + expect(deps["react-on-rails-pro"]).to eq(expected_npm_version) + expect(deps["react-on-rails-pro-node-renderer"]).to eq(expected_npm_version) + end + end + + it "sets DEFAULT_ROUTE to hello_server in bin/dev" do + assert_file "bin/dev" do |content| + expect(content).to include('DEFAULT_ROUTE = "hello_server"') + end + end + end + context "with --rsc --redux" do before(:all) { run_generator_test_with_args(%w[--rsc --redux], package_json: true) } @@ -1995,6 +2019,16 @@ class ActiveSupport::TestCase expect(command).to eq("rails generate react_on_rails:install --pro") end + specify "recovery_install_command prefers --rsc-pro over --rsc/--pro" do + install_generator = described_class.new([], { rsc_pro: true, rsc: true, pro: true }) + + command = install_generator.send(:recovery_install_command) + + expect(command).to eq("rails generate react_on_rails:install --rsc-pro") + expect(command).not_to match(/\s--rsc(\s|$)/) + expect(command).not_to match(/\s--pro(\s|$)/) + end + specify "shakapacker install error preserves original install flags" do install_generator = described_class.new([], { redux: true, typescript: true, ignore_warnings: true }) @@ -2016,6 +2050,20 @@ class ActiveSupport::TestCase expect(output_text).to include("clean up your working tree before rerunning") expect(output_text).to include("Then re-run: rails generate react_on_rails:install --rspack --pro") end + + specify "rsc-pro installs include a dedicated verification checklist message" do + run_generator_test_with_args(%w[--rsc-pro], package_json: true) do + simulate_existing_file("bin/shakapacker", "") + simulate_existing_file("bin/shakapacker-dev-server", "") + simulate_existing_file("config/shakapacker.yml", "default: {}\n") + simulate_existing_file("config/webpack/webpack.config.js", "// mock webpack config\n") + end + + output_text = GeneratorMessages.output.join("\n") + expect(output_text).to include("RSC Pro Verification") + expect(output_text).to include("http://localhost:3000/hello_server") + expect(output_text).to include("Like button hydrates on click") + end end describe "--pretend mode behavior" do @@ -2114,6 +2162,22 @@ class ActiveSupport::TestCase redux_pro_rsc_install_generator.send(:invoke_generators) end + + it "treats --rsc-pro as pro+rsc when invoking sub-generators" do + rsc_pro_install_generator = described_class.new([], { pretend: true, rsc_pro: true }) + + allow(rsc_pro_install_generator).to receive(:ensure_shakapacker_installed) + allow(rsc_pro_install_generator).to receive(:setup_react_dependencies) + + expect(rsc_pro_install_generator).to receive(:invoke) + .with("react_on_rails:base", [], hash_including(pro: true, rsc: true, pretend: true)) + expect(rsc_pro_install_generator).to receive(:invoke) + .with("react_on_rails:pro", [], hash_including(pretend: true)) + expect(rsc_pro_install_generator).to receive(:invoke) + .with("react_on_rails:rsc", [], hash_including(pretend: true)) + + rsc_pro_install_generator.send(:invoke_generators) + end end context "when detecting existing bin-files on *nix" do @@ -2778,6 +2842,33 @@ class ActiveSupport::TestCase end end + context "when using --rsc-pro flag without Pro gem installed" do + let(:install_generator) { described_class.new([], { rsc_pro: true }) } + let(:expected_pro_version) { Gem::Version.new(ReactOnRails::VERSION).release.to_s } + let(:fake_pid) { 12_345 } + + before do + allow(Gem).to receive(:loaded_specs).and_return({}) + allow(install_generator).to receive(:gem_in_lockfile?).with("react_on_rails_pro").and_return(false) + allow(Bundler).to receive(:with_unbundled_env).and_yield + allow(Process).to receive(:spawn).and_return(fake_pid) + allow(install_generator).to receive(:wait_for_bundle_process) + .with(fake_pid).and_return(instance_double(Process::Status, success?: false)) + end + + specify "missing_pro_gem? uses rsc-pro flag context and exact bundle-add version" do + expect(install_generator.send(:missing_pro_gem?)).to be true + expect(Bundler).to have_received(:with_unbundled_env) + expect(Process).to have_received(:spawn) + .with("bundle add react_on_rails_pro --version='#{expected_pro_version}' --strict", + out: anything, + err: anything) + error_text = GeneratorMessages.messages.join("\n") + expect(error_text).to include("--rsc-pro") + expect(error_text).to include("~> #{expected_pro_version}") + end + end + context "when auto-installing Pro gem succeeds" do let(:install_generator) { described_class.new([], { pro: true }) } let(:fake_pid) { 12_345 } @@ -2903,6 +2994,26 @@ class ActiveSupport::TestCase end end + context "when --rsc-pro flag used on a dirty worktree without pro gem" do + let(:install_generator) { described_class.new([], { rsc_pro: true }) } + + before do + allow(ReactOnRails::GitUtils).to receive(:warn_if_uncommitted_changes).and_return(true) + allow(install_generator).to receive(:cli_exists?).with("git").and_return(true) + allow(install_generator).to receive_messages(missing_node?: false, missing_package_manager?: false) + allow(Gem).to receive(:loaded_specs).and_return({}) + allow(install_generator).to receive(:gem_in_lockfile?).with("react_on_rails_pro").and_return(false) + end + + specify "installation_prerequisites_met? returns false with clear error" do + expect(install_generator.send(:installation_prerequisites_met?)).to be false + error_text = GeneratorMessages.messages.join("\n") + expect(error_text).to include("react_on_rails_pro") + expect(error_text).to include("uncommitted changes") + expect(error_text).to include("--rsc-pro") + end + end + context "when --pro flag used on a dirty worktree with pro gem installed" do let(:install_generator) { described_class.new([], { pro: true }) } diff --git a/react_on_rails/spec/react_on_rails/generators/js_dependency_manager_spec.rb b/react_on_rails/spec/react_on_rails/generators/js_dependency_manager_spec.rb index b064450c61..af5086fce9 100644 --- a/react_on_rails/spec/react_on_rails/generators/js_dependency_manager_spec.rb +++ b/react_on_rails/spec/react_on_rails/generators/js_dependency_manager_spec.rb @@ -58,6 +58,12 @@ def using_rspack? attr_writer :using_rspack + def use_rsc? + @use_rsc == true + end + + attr_writer :use_rsc + # Test helpers attr_writer :add_npm_dependencies_result @@ -508,6 +514,46 @@ def errors end end + describe "#rsc_packages_with_version" do + it "pins react-on-rails-rsc to the current gem version" do + stub_const("ReactOnRails::VERSION", "16.4.0.rc.5") + converter = instance_double(ReactOnRails::VersionSyntaxConverter, rubygem_to_npm: "16.4.0-rc.5") + allow(ReactOnRails::VersionSyntaxConverter).to receive(:new).and_return(converter) + + expect(instance.send(:rsc_packages_with_version)).to eq(["react-on-rails-rsc@16.4.0-rc.5"]) + end + + it "falls back to unversioned package when conversion fails" do + allow(ReactOnRails::VersionSyntaxConverter).to receive(:new).and_raise(StandardError, "conversion failed") + + expect(instance.send(:rsc_packages_with_version)).to eq(["react-on-rails-rsc"]) + end + end + + describe "#add_rsc_dependencies" do + it "installs version-pinned rsc dependency" do + allow(instance).to receive(:rsc_packages_with_version).and_return(["react-on-rails-rsc@16.4.0"]) + + instance.send(:add_rsc_dependencies) + + expect(instance.add_npm_dependencies_calls).to include( + a_hash_including(packages: ["react-on-rails-rsc@16.4.0"], dev: false) + ) + end + + it "falls back to unversioned package when pinned install fails" do + allow(instance).to receive(:rsc_packages_with_version).and_return(["react-on-rails-rsc@16.4.0"]) + + allow(instance).to receive(:add_packages).with(["react-on-rails-rsc@16.4.0"]).and_return(false) + allow(instance).to receive(:add_packages).with(["react-on-rails-rsc"]).and_return(true) + + instance.send(:add_rsc_dependencies) + + expect(instance).to have_received(:add_packages).with(["react-on-rails-rsc@16.4.0"]) + expect(instance).to have_received(:add_packages).with(["react-on-rails-rsc"]) + end + end + describe "#add_babel_react_dependencies" do it "adds Babel React preset as dev dependency" do instance.send(:add_babel_react_dependencies) From 4343d86915e9d4b04314e1c2264e4e137d18d9e8 Mon Sep 17 00:00:00 2001 From: Justin Gordon Date: Mon, 23 Mar 2026 17:54:31 -1000 Subject: [PATCH 02/37] Automate Pro generator gem and import upgrade steps --- .../react_on_rails/pro_generator.rb | 67 +++++++++++++++++++ .../generators/pro_generator_spec.rb | 64 ++++++++++++++++++ 2 files changed, 131 insertions(+) diff --git a/react_on_rails/lib/generators/react_on_rails/pro_generator.rb b/react_on_rails/lib/generators/react_on_rails/pro_generator.rb index aca2df41d8..0d74a1c199 100644 --- a/react_on_rails/lib/generators/react_on_rails/pro_generator.rb +++ b/react_on_rails/lib/generators/react_on_rails/pro_generator.rb @@ -29,8 +29,10 @@ def self.usage_path def run_generator # When invoked by install_generator, skip prerequisites (parent already validated) if options[:invoked_by_install] || prerequisites_met? + swap_base_gem_for_pro_in_gemfile unless options[:invoked_by_install] setup_pro add_pro_npm_dependencies + update_imports_to_pro_package unless options[:invoked_by_install] print_success_message unless options[:invoked_by_install] else GeneratorMessages.add_error(<<~MSG.strip) @@ -75,6 +77,71 @@ def add_pro_npm_dependencies say "✅ Pro npm dependencies added", :green end + def swap_base_gem_for_pro_in_gemfile + gemfile_path = File.join(destination_root, "Gemfile") + return unless File.exist?(gemfile_path) + + gemfile_content = File.read(gemfile_path) + updated_content = gemfile_content.gsub( + /^\s*gem\s+["']react_on_rails["'][^\n]*$/, + "gem 'react_on_rails_pro', '#{recommended_pro_gem_version}'" + ) + return if updated_content == gemfile_content + + File.write(gemfile_path, updated_content) + say "✅ Replaced react_on_rails with react_on_rails_pro in Gemfile", :green + bundle_install_after_gem_swap + end + + def bundle_install_after_gem_swap + say "đŸ“Ļ Running bundle install after Gemfile update...", :yellow + install_succeeded = Dir.chdir(destination_root) do + Bundler.with_unbundled_env { system("bundle install") } + end + + return if install_succeeded + + GeneratorMessages.add_warning(<<~MSG.strip) + âš ī¸ Automatic bundle install failed after swapping Gemfile entries. + + Please run manually: + bundle install + MSG + rescue StandardError => e + GeneratorMessages.add_warning(<<~MSG.strip) + âš ī¸ Could not run automatic bundle install: #{e.message} + + Please run manually: + bundle install + MSG + end + + def update_imports_to_pro_package + files = js_files_for_import_update + updated_files = files.count do |file| + content = File.read(file) + updated_content = content.gsub(/react-on-rails(?!-pro)/, "react-on-rails-pro") + next false if updated_content == content + + File.write(file, updated_content) + true + end + + return if updated_files.zero? + + say "✅ Updated react-on-rails imports in #{updated_files} file(s)", :green + end + + def js_files_for_import_update + js_extensions = %w[js jsx ts tsx mjs cjs].join(",") + %w[app/javascript client].flat_map do |root| + root_path = File.join(destination_root, root) + next [] unless Dir.exist?(root_path) + + Dir.glob(File.join(root_path, "**", "*.{#{js_extensions}}")) + end + end + def print_success_message route = if File.exist?(File.join(destination_root, "app/controllers/hello_server_controller.rb")) "hello_server" diff --git a/react_on_rails/spec/react_on_rails/generators/pro_generator_spec.rb b/react_on_rails/spec/react_on_rails/generators/pro_generator_spec.rb index 3af272013b..b798c71d17 100644 --- a/react_on_rails/spec/react_on_rails/generators/pro_generator_spec.rb +++ b/react_on_rails/spec/react_on_rails/generators/pro_generator_spec.rb @@ -55,6 +55,70 @@ end end + describe "#swap_base_gem_for_pro_in_gemfile" do + let(:generator) { described_class.new } + let(:gemfile_path) { File.join(destination_root, "Gemfile") } + + before do + prepare_destination + allow(generator).to receive(:destination_root).and_return(destination_root) + end + + it "replaces react_on_rails with react_on_rails_pro and runs bundle install" do + simulate_existing_file("Gemfile", <<~RUBY) + source "https://rubygems.org" + gem "react_on_rails", "~> 16.0" + RUBY + allow(generator).to receive(:bundle_install_after_gem_swap) + + generator.send(:swap_base_gem_for_pro_in_gemfile) + + gemfile_content = File.read(gemfile_path) + expected_version = Gem::Version.new(ReactOnRails::VERSION).release.to_s + expect(gemfile_content).to include("gem 'react_on_rails_pro', '#{expected_version}'") + expect(gemfile_content).not_to match(/gem\s+["']react_on_rails["']/) + expect(generator).to have_received(:bundle_install_after_gem_swap) + end + + it "does nothing when Gemfile has no react_on_rails entry" do + simulate_existing_file("Gemfile", <<~RUBY) + source "https://rubygems.org" + gem "rails" + RUBY + allow(generator).to receive(:bundle_install_after_gem_swap) + + original_content = File.read(gemfile_path) + generator.send(:swap_base_gem_for_pro_in_gemfile) + + expect(File.read(gemfile_path)).to eq(original_content) + expect(generator).not_to have_received(:bundle_install_after_gem_swap) + end + end + + describe "#update_imports_to_pro_package" do + let(:generator) { described_class.new } + let(:application_js_path) { File.join(destination_root, "app/javascript/packs/application.js") } + let(:server_js_path) { File.join(destination_root, "client/server.js") } + + before do + prepare_destination + allow(generator).to receive(:destination_root).and_return(destination_root) + simulate_existing_file("app/javascript/packs/application.js", <<~JS) + import ReactOnRails from "react-on-rails"; + const ror = require("react-on-rails"); + JS + simulate_existing_file("client/server.js", "import ReactOnRails from \"react-on-rails-pro\";\n") + end + + it "updates react-on-rails imports and requires to react-on-rails-pro" do + generator.send(:update_imports_to_pro_package) + + expect(File.read(application_js_path)).to include('import ReactOnRails from "react-on-rails-pro";') + expect(File.read(application_js_path)).to include('require("react-on-rails-pro")') + expect(File.read(server_js_path)).to include('import ReactOnRails from "react-on-rails-pro";') + end + end + # Integration test for standalone happy path # Uses before (not before(:all)) to allow mocking the Pro gem check From 6fda382fc454fa106ab5d7023e9e6ee25598dc6b Mon Sep 17 00:00:00 2001 From: Justin Gordon Date: Mon, 23 Mar 2026 20:28:22 -1000 Subject: [PATCH 03/37] Handle failed npm add results so RSC installs recover --- .../lib/generators/react_on_rails/install_generator.rb | 4 ++-- .../react_on_rails/generators/generator_helper_spec.rb | 8 +++++--- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/react_on_rails/lib/generators/react_on_rails/install_generator.rb b/react_on_rails/lib/generators/react_on_rails/install_generator.rb index 9c1d88fda8..f75e1d90cb 100644 --- a/react_on_rails/lib/generators/react_on_rails/install_generator.rb +++ b/react_on_rails/lib/generators/react_on_rails/install_generator.rb @@ -499,9 +499,9 @@ def recovery_install_command flags << "--typescript" if options.typescript? flags << "--rspack" if options.rspack? - if use_rsc_pro_mode? + if options.rsc_pro? flags << "--rsc-pro" - elsif use_rsc? + elsif options.rsc? flags << "--rsc" elsif options.pro? flags << "--pro" diff --git a/react_on_rails/spec/react_on_rails/generators/generator_helper_spec.rb b/react_on_rails/spec/react_on_rails/generators/generator_helper_spec.rb index 9184ef9887..8c9a2067c3 100644 --- a/react_on_rails/spec/react_on_rails/generators/generator_helper_spec.rb +++ b/react_on_rails/spec/react_on_rails/generators/generator_helper_spec.rb @@ -103,12 +103,14 @@ def options end end - context "when manager.add returns false" do - it "returns false so fallback installation can run" do - packages = ["react"] + context "when package manager add returns false" do + it "returns false so callers can fall back" do + packages = ["react-on-rails-rsc@99.99.99"] + allow(mock_manager).to receive(:add).with(packages, exact: true).and_return(false) result = add_npm_dependencies(packages) + expect(mock_manager).to have_received(:add).with(packages, exact: true) expect(result).to be false end end From 97ba6ee7388c2f023d70973bbdf45d5e56bee8cb Mon Sep 17 00:00:00 2001 From: Justin Gordon Date: Mon, 23 Mar 2026 21:04:00 -1000 Subject: [PATCH 04/37] Fix rsc-pro gem pinning and generator review issues --- .../react_on_rails/install_generator.rb | 9 +++------ .../react_on_rails/js_dependency_manager.rb | 4 ++-- .../lib/generators/react_on_rails/pro_setup.rb | 17 +++++++++-------- .../generators/install_generator_spec.rb | 17 +++++++++++++++-- .../generators/js_dependency_manager_spec.rb | 3 +++ 5 files changed, 32 insertions(+), 18 deletions(-) diff --git a/react_on_rails/lib/generators/react_on_rails/install_generator.rb b/react_on_rails/lib/generators/react_on_rails/install_generator.rb index f75e1d90cb..1f92cd3d58 100644 --- a/react_on_rails/lib/generators/react_on_rails/install_generator.rb +++ b/react_on_rails/lib/generators/react_on_rails/install_generator.rb @@ -285,7 +285,7 @@ def installation_prerequisites_met? đŸšĢ react_on_rails_pro gem is required for #{required_flag} but is not installed. Auto-install was skipped because the worktree has uncommitted changes. Please add it manually: - gem 'react_on_rails_pro', '~> #{recommended_pro_gem_version}' + gem 'react_on_rails_pro', '#{pro_gem_version_requirement}' Then run: bundle install MSG return false @@ -516,16 +516,13 @@ def rsc_pro_verification_message 🔎 RSC Pro Verification: ───────────────────────────────────────────────────────────────────────── 1. Start all processes: #{Rainbow('bin/dev').cyan} - 2. Visit: #{Rainbow('http://localhost:3000/hello_server').cyan.underline} + 2. Visit: #{Rainbow('http://localhost:3000/hello_server').cyan.underline} (or your configured port) 3. Confirm the page streams and the Like button hydrates on click. MSG end def missing_pro_required_flag - return "--rsc-pro" if use_rsc_pro_mode? - return "--rsc" if use_rsc? - - "--pro" + pro_requirement_flag end def recovery_working_tree_lines diff --git a/react_on_rails/lib/generators/react_on_rails/js_dependency_manager.rb b/react_on_rails/lib/generators/react_on_rails/js_dependency_manager.rb index 0aa76628aa..d547ff1b4a 100644 --- a/react_on_rails/lib/generators/react_on_rails/js_dependency_manager.rb +++ b/react_on_rails/lib/generators/react_on_rails/js_dependency_manager.rb @@ -416,8 +416,8 @@ def add_rsc_dependencies def rsc_packages_with_version npm_version = ReactOnRails::VersionSyntaxConverter.new.rubygem_to_npm(ReactOnRails::VERSION) RSC_DEPENDENCIES.map { |pkg| "#{pkg}@#{npm_version}" } - rescue StandardError - say_status :warning, "Could not determine RSC package version. Installing latest.", :yellow + rescue StandardError => e + say_status :warning, "Could not determine RSC package version (#{e.message}). Installing latest.", :yellow RSC_DEPENDENCIES end diff --git a/react_on_rails/lib/generators/react_on_rails/pro_setup.rb b/react_on_rails/lib/generators/react_on_rails/pro_setup.rb index 0454e866db..252f415604 100644 --- a/react_on_rails/lib/generators/react_on_rails/pro_setup.rb +++ b/react_on_rails/lib/generators/react_on_rails/pro_setup.rb @@ -68,7 +68,7 @@ def missing_pro_gem?(force: false) #{context_line} Please add manually to your Gemfile: - gem '#{PRO_GEM_NAME}', '~> #{recommended_pro_gem_version}' + gem '#{PRO_GEM_NAME}', '#{pro_gem_version_requirement}' Then run: bundle install @@ -92,7 +92,7 @@ def pro_flag_specified_for_context? end def pro_requirement_flag - return "--rsc-pro" if respond_to?(:use_rsc_pro_mode?) && use_rsc_pro_mode? + return "--rsc-pro" if use_rsc_pro_mode? return "--rsc" if options[:rsc] "--pro" @@ -471,12 +471,13 @@ def server_client_import_ready? end def pro_gem_auto_install_command - version_requirement = if respond_to?(:use_rsc_pro_mode?) && use_rsc_pro_mode? - recommended_pro_gem_version - else - "~> #{recommended_pro_gem_version}" - end - "bundle add #{PRO_GEM_NAME} --version='#{version_requirement}' --strict" + "bundle add #{PRO_GEM_NAME} --version='#{pro_gem_version_requirement}' --strict" + end + + def pro_gem_version_requirement + return ReactOnRails::VERSION if use_rsc_pro_mode? + + "~> #{recommended_pro_gem_version}" end # Keep manual fallback pinned to the latest stable release (drop pre-release suffixes like .rc.N). diff --git a/react_on_rails/spec/react_on_rails/generators/install_generator_spec.rb b/react_on_rails/spec/react_on_rails/generators/install_generator_spec.rb index 13d563d72d..f57bdbdd51 100644 --- a/react_on_rails/spec/react_on_rails/generators/install_generator_spec.rb +++ b/react_on_rails/spec/react_on_rails/generators/install_generator_spec.rb @@ -2844,7 +2844,7 @@ class ActiveSupport::TestCase context "when using --rsc-pro flag without Pro gem installed" do let(:install_generator) { described_class.new([], { rsc_pro: true }) } - let(:expected_pro_version) { Gem::Version.new(ReactOnRails::VERSION).release.to_s } + let(:expected_pro_version) { ReactOnRails::VERSION } let(:fake_pid) { 12_345 } before do @@ -2865,7 +2865,20 @@ class ActiveSupport::TestCase err: anything) error_text = GeneratorMessages.messages.join("\n") expect(error_text).to include("--rsc-pro") - expect(error_text).to include("~> #{expected_pro_version}") + expect(error_text).to include("gem 'react_on_rails_pro', '#{expected_pro_version}'") + expect(error_text).not_to include("~> #{expected_pro_version}") + end + + specify "missing_pro_gem? keeps prerelease suffix when rsc-pro exact pinning is used" do + stub_const("ReactOnRails::VERSION", "16.4.0.rc.5") + + expect(install_generator.send(:missing_pro_gem?)).to be true + expect(Process).to have_received(:spawn) + .with("bundle add react_on_rails_pro --version='16.4.0.rc.5' --strict", + out: anything, + err: anything) + error_text = GeneratorMessages.messages.join("\n") + expect(error_text).to include("gem 'react_on_rails_pro', '16.4.0.rc.5'") end end diff --git a/react_on_rails/spec/react_on_rails/generators/js_dependency_manager_spec.rb b/react_on_rails/spec/react_on_rails/generators/js_dependency_manager_spec.rb index af5086fce9..871dd7d02d 100644 --- a/react_on_rails/spec/react_on_rails/generators/js_dependency_manager_spec.rb +++ b/react_on_rails/spec/react_on_rails/generators/js_dependency_manager_spec.rb @@ -527,6 +527,9 @@ def errors allow(ReactOnRails::VersionSyntaxConverter).to receive(:new).and_raise(StandardError, "conversion failed") expect(instance.send(:rsc_packages_with_version)).to eq(["react-on-rails-rsc"]) + expect(instance.say_status_calls).to include( + a_hash_including(message: a_string_including("conversion failed")) + ) end end From eb33f31db1ff8de56e8b07535b32c4cd4db5effa Mon Sep 17 00:00:00 2001 From: Justin Gordon Date: Mon, 23 Mar 2026 21:14:10 -1000 Subject: [PATCH 05/37] Harden Pro gem swap and import rewrite behavior --- .../react_on_rails/pro_generator.rb | 22 +++++++--- .../generators/pro_generator_spec.rb | 40 ++++++++++++++++++- 2 files changed, 56 insertions(+), 6 deletions(-) diff --git a/react_on_rails/lib/generators/react_on_rails/pro_generator.rb b/react_on_rails/lib/generators/react_on_rails/pro_generator.rb index 0d74a1c199..d98e9b2048 100644 --- a/react_on_rails/lib/generators/react_on_rails/pro_generator.rb +++ b/react_on_rails/lib/generators/react_on_rails/pro_generator.rb @@ -82,10 +82,22 @@ def swap_base_gem_for_pro_in_gemfile return unless File.exist?(gemfile_path) gemfile_content = File.read(gemfile_path) - updated_content = gemfile_content.gsub( - /^\s*gem\s+["']react_on_rails["'][^\n]*$/, - "gem 'react_on_rails_pro', '#{recommended_pro_gem_version}'" - ) + pro_gem_pattern = /^\s*gem\s+["']react_on_rails_pro["']/ + base_gem_pattern = /^(\s*)gem\s+["']react_on_rails["'][^\n]*$/ + + has_pro_gem_entry = gemfile_content.match?(pro_gem_pattern) + updated_lines = gemfile_content.lines.filter_map do |line| + match = line.match(base_gem_pattern) + next line unless match + + if has_pro_gem_entry + nil + else + "#{match[1]}gem 'react_on_rails_pro', '~> #{recommended_pro_gem_version}'\n" + end + end + + updated_content = updated_lines.join return if updated_content == gemfile_content File.write(gemfile_path, updated_content) @@ -120,7 +132,7 @@ def update_imports_to_pro_package files = js_files_for_import_update updated_files = files.count do |file| content = File.read(file) - updated_content = content.gsub(/react-on-rails(?!-pro)/, "react-on-rails-pro") + updated_content = content.gsub(%r{react-on-rails(?!-pro)(?=['"/])}, "react-on-rails-pro") next false if updated_content == content File.write(file, updated_content) diff --git a/react_on_rails/spec/react_on_rails/generators/pro_generator_spec.rb b/react_on_rails/spec/react_on_rails/generators/pro_generator_spec.rb index b798c71d17..ec991915c2 100644 --- a/react_on_rails/spec/react_on_rails/generators/pro_generator_spec.rb +++ b/react_on_rails/spec/react_on_rails/generators/pro_generator_spec.rb @@ -75,11 +75,45 @@ gemfile_content = File.read(gemfile_path) expected_version = Gem::Version.new(ReactOnRails::VERSION).release.to_s - expect(gemfile_content).to include("gem 'react_on_rails_pro', '#{expected_version}'") + expect(gemfile_content).to include("gem 'react_on_rails_pro', '~> #{expected_version}'") expect(gemfile_content).not_to match(/gem\s+["']react_on_rails["']/) expect(generator).to have_received(:bundle_install_after_gem_swap) end + it "preserves indentation when replacing a grouped Gemfile entry" do + simulate_existing_file("Gemfile", <<~RUBY) + source "https://rubygems.org" + + group :default do + gem "react_on_rails", "~> 16.0" + end + RUBY + allow(generator).to receive(:bundle_install_after_gem_swap) + + generator.send(:swap_base_gem_for_pro_in_gemfile) + + gemfile_content = File.read(gemfile_path) + expected_version = Gem::Version.new(ReactOnRails::VERSION).release.to_s + expect(gemfile_content).to include(" gem 'react_on_rails_pro', '~> #{expected_version}'") + expect(generator).to have_received(:bundle_install_after_gem_swap) + end + + it "removes base gem without adding duplicate react_on_rails_pro entries" do + simulate_existing_file("Gemfile", <<~RUBY) + source "https://rubygems.org" + gem "react_on_rails", "~> 16.0" + gem "react_on_rails_pro", "~> 16.0" + RUBY + allow(generator).to receive(:bundle_install_after_gem_swap) + + generator.send(:swap_base_gem_for_pro_in_gemfile) + + gemfile_content = File.read(gemfile_path) + expect(gemfile_content).not_to match(/gem\s+["']react_on_rails["']/) + expect(gemfile_content.scan(/gem\s+["']react_on_rails_pro["']/).size).to eq(1) + expect(generator).to have_received(:bundle_install_after_gem_swap) + end + it "does nothing when Gemfile has no react_on_rails entry" do simulate_existing_file("Gemfile", <<~RUBY) source "https://rubygems.org" @@ -106,6 +140,8 @@ simulate_existing_file("app/javascript/packs/application.js", <<~JS) import ReactOnRails from "react-on-rails"; const ror = require("react-on-rails"); + import ReactOnRailsClient from "react-on-rails/client"; + import CustomPackage from "react-on-rails-utils"; JS simulate_existing_file("client/server.js", "import ReactOnRails from \"react-on-rails-pro\";\n") end @@ -115,6 +151,8 @@ expect(File.read(application_js_path)).to include('import ReactOnRails from "react-on-rails-pro";') expect(File.read(application_js_path)).to include('require("react-on-rails-pro")') + expect(File.read(application_js_path)).to include('import ReactOnRailsClient from "react-on-rails-pro/client";') + expect(File.read(application_js_path)).to include('import CustomPackage from "react-on-rails-utils";') expect(File.read(server_js_path)).to include('import ReactOnRails from "react-on-rails-pro";') end end From 55d2b396e87c05adbdc5e48caaa2e1a9bb5cbcb5 Mon Sep 17 00:00:00 2001 From: Justin Gordon Date: Mon, 23 Mar 2026 21:38:05 -1000 Subject: [PATCH 06/37] Harden Pro generator Gemfile swap and import rewrites --- .../react_on_rails/pro_generator.rb | 80 ++++++++++++++++--- .../generators/pro_generator_spec.rb | 71 +++++++++++++++- 2 files changed, 137 insertions(+), 14 deletions(-) diff --git a/react_on_rails/lib/generators/react_on_rails/pro_generator.rb b/react_on_rails/lib/generators/react_on_rails/pro_generator.rb index d98e9b2048..81d2d8b653 100644 --- a/react_on_rails/lib/generators/react_on_rails/pro_generator.rb +++ b/react_on_rails/lib/generators/react_on_rails/pro_generator.rb @@ -8,6 +8,7 @@ module ReactOnRails module Generators + # rubocop:disable Metrics/ClassLength class ProGenerator < Rails::Generators::Base include GeneratorHelper include JsDependencyManager @@ -77,23 +78,45 @@ def add_pro_npm_dependencies say "✅ Pro npm dependencies added", :green end + # rubocop:disable Metrics/AbcSize def swap_base_gem_for_pro_in_gemfile gemfile_path = File.join(destination_root, "Gemfile") return unless File.exist?(gemfile_path) gemfile_content = File.read(gemfile_path) pro_gem_pattern = /^\s*gem\s+["']react_on_rails_pro["']/ - base_gem_pattern = /^(\s*)gem\s+["']react_on_rails["'][^\n]*$/ + base_gem_pattern = /^(\s*)gem\s+(["'])react_on_rails\2(?=\s*(?:,|$))/ has_pro_gem_entry = gemfile_content.match?(pro_gem_pattern) - updated_lines = gemfile_content.lines.filter_map do |line| + gemfile_lines = gemfile_content.lines + updated_lines = [] + pro_entry_added = has_pro_gem_entry + line_index = 0 + + while line_index < gemfile_lines.length + line = gemfile_lines[line_index] match = line.match(base_gem_pattern) - next line unless match - if has_pro_gem_entry - nil - else - "#{match[1]}gem 'react_on_rails_pro', '~> #{recommended_pro_gem_version}'\n" + unless match + updated_lines << line + line_index += 1 + next + end + + unless pro_entry_added + indentation = match[1] + quote = match[2] + updated_lines << "#{indentation}gem #{quote}react_on_rails_pro#{quote}, " \ + "#{quote}~> #{recommended_pro_gem_version}#{quote}\n" + pro_entry_added = true + end + + # Consume multiline gem declarations that continue with trailing commas. + line_index += 1 + current_line = line + while line_index < gemfile_lines.length && current_line.rstrip.end_with?(",") + current_line = gemfile_lines[line_index] + line_index += 1 end end @@ -104,14 +127,28 @@ def swap_base_gem_for_pro_in_gemfile say "✅ Replaced react_on_rails with react_on_rails_pro in Gemfile", :green bundle_install_after_gem_swap end + # rubocop:enable Metrics/AbcSize def bundle_install_after_gem_swap say "đŸ“Ļ Running bundle install after Gemfile update...", :yellow - install_succeeded = Dir.chdir(destination_root) do - Bundler.with_unbundled_env { system("bundle install") } + install_status = Dir.chdir(destination_root) do + Bundler.with_unbundled_env do + pid = Process.spawn("bundle install", out: $stdout, err: $stderr) + wait_for_bundle_process(pid) + end end - return if install_succeeded + return if install_status&.success? + + if install_status.nil? + GeneratorMessages.add_warning(<<~MSG.strip) + âš ī¸ Automatic bundle install timed out after #{ProSetup::AUTO_INSTALL_TIMEOUT} seconds. + + Please run manually: + bundle install + MSG + return + end GeneratorMessages.add_warning(<<~MSG.strip) âš ī¸ Automatic bundle install failed after swapping Gemfile entries. @@ -132,7 +169,7 @@ def update_imports_to_pro_package files = js_files_for_import_update updated_files = files.count do |file| content = File.read(file) - updated_content = content.gsub(%r{react-on-rails(?!-pro)(?=['"/])}, "react-on-rails-pro") + updated_content = rewrite_react_on_rails_module_specifiers(content) next false if updated_content == content File.write(file, updated_content) @@ -146,11 +183,29 @@ def update_imports_to_pro_package def js_files_for_import_update js_extensions = %w[js jsx ts tsx mjs cjs].join(",") - %w[app/javascript client].flat_map do |root| + %w[app/javascript app/frontend frontend javascript client].flat_map do |root| root_path = File.join(destination_root, root) next [] unless Dir.exist?(root_path) Dir.glob(File.join(root_path, "**", "*.{#{js_extensions}}")) + end.uniq + end + + def rewrite_react_on_rails_module_specifiers(content) + module_specifier_pattern = %r{ + (? + \bfrom\s+| + \bimport\s*\(\s*| + \brequire\s*\(\s*| + \bimport\s+ + ) + (?["']) + react-on-rails(?!-pro) + (?=(?:["']|/)) + }x + + content.gsub(module_specifier_pattern) do + "#{Regexp.last_match[:prefix]}#{Regexp.last_match[:quote]}react-on-rails-pro" end end @@ -172,5 +227,6 @@ def print_success_message MSG end end + # rubocop:enable Metrics/ClassLength end end diff --git a/react_on_rails/spec/react_on_rails/generators/pro_generator_spec.rb b/react_on_rails/spec/react_on_rails/generators/pro_generator_spec.rb index ec991915c2..1eb7047bde 100644 --- a/react_on_rails/spec/react_on_rails/generators/pro_generator_spec.rb +++ b/react_on_rails/spec/react_on_rails/generators/pro_generator_spec.rb @@ -75,7 +75,7 @@ gemfile_content = File.read(gemfile_path) expected_version = Gem::Version.new(ReactOnRails::VERSION).release.to_s - expect(gemfile_content).to include("gem 'react_on_rails_pro', '~> #{expected_version}'") + expect(gemfile_content).to include("gem \"react_on_rails_pro\", \"~> #{expected_version}\"") expect(gemfile_content).not_to match(/gem\s+["']react_on_rails["']/) expect(generator).to have_received(:bundle_install_after_gem_swap) end @@ -94,7 +94,40 @@ gemfile_content = File.read(gemfile_path) expected_version = Gem::Version.new(ReactOnRails::VERSION).release.to_s - expect(gemfile_content).to include(" gem 'react_on_rails_pro', '~> #{expected_version}'") + expect(gemfile_content).to include(" gem \"react_on_rails_pro\", \"~> #{expected_version}\"") + expect(generator).to have_received(:bundle_install_after_gem_swap) + end + + it "replaces multiline react_on_rails declaration without leaving orphan lines" do + simulate_existing_file("Gemfile", <<~RUBY) + source "https://rubygems.org" + gem "react_on_rails", + "~> 16.0" + RUBY + allow(generator).to receive(:bundle_install_after_gem_swap) + + generator.send(:swap_base_gem_for_pro_in_gemfile) + + gemfile_content = File.read(gemfile_path) + expected_version = Gem::Version.new(ReactOnRails::VERSION).release.to_s + expect(gemfile_content).to include("gem \"react_on_rails_pro\", \"~> #{expected_version}\"") + expect(gemfile_content).not_to include("gem \"react_on_rails\",") + expect(gemfile_content).not_to include(" \"~> 16.0\"") + expect(generator).to have_received(:bundle_install_after_gem_swap) + end + + it "preserves single quote style when replacing single-quoted Gemfile entries" do + simulate_existing_file("Gemfile", <<~RUBY) + source "https://rubygems.org" + gem 'react_on_rails', '~> 16.0' + RUBY + allow(generator).to receive(:bundle_install_after_gem_swap) + + generator.send(:swap_base_gem_for_pro_in_gemfile) + + gemfile_content = File.read(gemfile_path) + expected_version = Gem::Version.new(ReactOnRails::VERSION).release.to_s + expect(gemfile_content).to include("gem 'react_on_rails_pro', '~> #{expected_version}'") expect(generator).to have_received(:bundle_install_after_gem_swap) end @@ -129,10 +162,36 @@ end end + describe "#bundle_install_after_gem_swap" do + let(:generator) { described_class.new } + let(:fake_pid) { 23_456 } + + before do + prepare_destination + allow(generator).to receive(:destination_root).and_return(destination_root) + GeneratorMessages.clear + allow(Bundler).to receive(:with_unbundled_env).and_yield + allow(Process).to receive(:spawn).and_return(fake_pid) + end + + it "uses bounded process waiting and warns on timeout" do + allow(generator).to receive(:wait_for_bundle_process).with(fake_pid).and_return(nil) + + generator.send(:bundle_install_after_gem_swap) + + expect(Process).to have_received(:spawn).with("bundle install", out: $stdout, err: $stderr) + expect(generator).to have_received(:wait_for_bundle_process).with(fake_pid) + warning_text = GeneratorMessages.messages.join("\n") + expect(warning_text).to include("timed out") + expect(warning_text).to include("bundle install") + end + end + describe "#update_imports_to_pro_package" do let(:generator) { described_class.new } let(:application_js_path) { File.join(destination_root, "app/javascript/packs/application.js") } let(:server_js_path) { File.join(destination_root, "client/server.js") } + let(:frontend_js_path) { File.join(destination_root, "app/frontend/entrypoints/client.ts") } before do prepare_destination @@ -141,9 +200,13 @@ import ReactOnRails from "react-on-rails"; const ror = require("react-on-rails"); import ReactOnRailsClient from "react-on-rails/client"; + import "react-on-rails"; import CustomPackage from "react-on-rails-utils"; + const scoped = "@scope/react-on-rails"; + const url = "https://cdn.example.com/react-on-rails/client.js"; JS simulate_existing_file("client/server.js", "import ReactOnRails from \"react-on-rails-pro\";\n") + simulate_existing_file("app/frontend/entrypoints/client.ts", "import ReactOnRails from \"react-on-rails\";\n") end it "updates react-on-rails imports and requires to react-on-rails-pro" do @@ -152,8 +215,12 @@ expect(File.read(application_js_path)).to include('import ReactOnRails from "react-on-rails-pro";') expect(File.read(application_js_path)).to include('require("react-on-rails-pro")') expect(File.read(application_js_path)).to include('import ReactOnRailsClient from "react-on-rails-pro/client";') + expect(File.read(application_js_path)).to include('import "react-on-rails-pro";') expect(File.read(application_js_path)).to include('import CustomPackage from "react-on-rails-utils";') + expect(File.read(application_js_path)).to include('const scoped = "@scope/react-on-rails";') + expect(File.read(application_js_path)).to include('const url = "https://cdn.example.com/react-on-rails/client.js";') expect(File.read(server_js_path)).to include('import ReactOnRails from "react-on-rails-pro";') + expect(File.read(frontend_js_path)).to include('import ReactOnRails from "react-on-rails-pro";') end end From 90a48c3a2d5c63c58b79754b11ea1f342ecfe50e Mon Sep 17 00:00:00 2001 From: Justin Gordon Date: Mon, 23 Mar 2026 22:16:31 -1000 Subject: [PATCH 07/37] Harden Pro Gemfile swap and bundle install flow --- .../react_on_rails/pro_generator.rb | 23 +++++++---- .../generators/pro_generator_spec.rb | 41 ++++++++++++++++++- 2 files changed, 56 insertions(+), 8 deletions(-) diff --git a/react_on_rails/lib/generators/react_on_rails/pro_generator.rb b/react_on_rails/lib/generators/react_on_rails/pro_generator.rb index 81d2d8b653..1a7d4f3b70 100644 --- a/react_on_rails/lib/generators/react_on_rails/pro_generator.rb +++ b/react_on_rails/lib/generators/react_on_rails/pro_generator.rb @@ -85,7 +85,7 @@ def swap_base_gem_for_pro_in_gemfile gemfile_content = File.read(gemfile_path) pro_gem_pattern = /^\s*gem\s+["']react_on_rails_pro["']/ - base_gem_pattern = /^(\s*)gem\s+(["'])react_on_rails\2(?=\s*(?:,|$))/ + base_gem_pattern = /^(\s*)gem\s+(["'])react_on_rails\2(?=\s*(?:,|#|$))/ has_pro_gem_entry = gemfile_content.match?(pro_gem_pattern) gemfile_lines = gemfile_content.lines @@ -114,7 +114,7 @@ def swap_base_gem_for_pro_in_gemfile # Consume multiline gem declarations that continue with trailing commas. line_index += 1 current_line = line - while line_index < gemfile_lines.length && current_line.rstrip.end_with?(",") + while line_index < gemfile_lines.length && line_continues_with_comma?(current_line) current_line = gemfile_lines[line_index] line_index += 1 end @@ -131,11 +131,16 @@ def swap_base_gem_for_pro_in_gemfile def bundle_install_after_gem_swap say "đŸ“Ļ Running bundle install after Gemfile update...", :yellow - install_status = Dir.chdir(destination_root) do - Bundler.with_unbundled_env do - pid = Process.spawn("bundle install", out: $stdout, err: $stderr) - wait_for_bundle_process(pid) - end + install_status = Bundler.with_unbundled_env do + gemfile_path = File.join(destination_root, "Gemfile") + pid = Process.spawn( + { "BUNDLE_GEMFILE" => gemfile_path }, + "bundle install", + out: $stdout, + err: $stderr, + chdir: destination_root + ) + wait_for_bundle_process(pid) end return if install_status&.success? @@ -209,6 +214,10 @@ def rewrite_react_on_rails_module_specifiers(content) end end + def line_continues_with_comma?(line) + line.rstrip.match?(/,\s*(?:#.*)?\z/) + end + def print_success_message route = if File.exist?(File.join(destination_root, "app/controllers/hello_server_controller.rb")) "hello_server" diff --git a/react_on_rails/spec/react_on_rails/generators/pro_generator_spec.rb b/react_on_rails/spec/react_on_rails/generators/pro_generator_spec.rb index 1eb7047bde..a3ec40e52d 100644 --- a/react_on_rails/spec/react_on_rails/generators/pro_generator_spec.rb +++ b/react_on_rails/spec/react_on_rails/generators/pro_generator_spec.rb @@ -116,6 +116,23 @@ expect(generator).to have_received(:bundle_install_after_gem_swap) end + it "replaces multiline declarations that have an inline comment after the trailing comma" do + simulate_existing_file("Gemfile", <<~RUBY) + source "https://rubygems.org" + gem "react_on_rails", # pinned for compatibility + "~> 16.0" + RUBY + allow(generator).to receive(:bundle_install_after_gem_swap) + + generator.send(:swap_base_gem_for_pro_in_gemfile) + + gemfile_content = File.read(gemfile_path) + expected_version = Gem::Version.new(ReactOnRails::VERSION).release.to_s + expect(gemfile_content).to include("gem \"react_on_rails_pro\", \"~> #{expected_version}\"") + expect(gemfile_content).not_to include("gem \"react_on_rails\", # pinned for compatibility") + expect(gemfile_content).not_to include(" \"~> 16.0\"") + end + it "preserves single quote style when replacing single-quoted Gemfile entries" do simulate_existing_file("Gemfile", <<~RUBY) source "https://rubygems.org" @@ -160,6 +177,22 @@ expect(File.read(gemfile_path)).to eq(original_content) expect(generator).not_to have_received(:bundle_install_after_gem_swap) end + + it "replaces base gem entries that include inline comments" do + simulate_existing_file("Gemfile", <<~RUBY) + source "https://rubygems.org" + gem "react_on_rails" # pinned for compatibility + RUBY + allow(generator).to receive(:bundle_install_after_gem_swap) + + generator.send(:swap_base_gem_for_pro_in_gemfile) + + gemfile_content = File.read(gemfile_path) + expected_version = Gem::Version.new(ReactOnRails::VERSION).release.to_s + expect(gemfile_content).to include("gem \"react_on_rails_pro\", \"~> #{expected_version}\"") + expect(gemfile_content).not_to include("gem \"react_on_rails\" # pinned for compatibility") + expect(generator).to have_received(:bundle_install_after_gem_swap) + end end describe "#bundle_install_after_gem_swap" do @@ -179,7 +212,13 @@ generator.send(:bundle_install_after_gem_swap) - expect(Process).to have_received(:spawn).with("bundle install", out: $stdout, err: $stderr) + expect(Process).to have_received(:spawn).with( + { "BUNDLE_GEMFILE" => File.join(destination_root, "Gemfile") }, + "bundle install", + out: $stdout, + err: $stderr, + chdir: destination_root + ) expect(generator).to have_received(:wait_for_bundle_process).with(fake_pid) warning_text = GeneratorMessages.messages.join("\n") expect(warning_text).to include("timed out") From bbce91ae6d07bf127f3d53a6f2be894b1a27832a Mon Sep 17 00:00:00 2001 From: Justin Gordon Date: Mon, 23 Mar 2026 22:28:17 -1000 Subject: [PATCH 08/37] Harden Pro generator gem swap and import rewrites --- .../react_on_rails/pro_generator.rb | 63 ++++++++++++++++--- .../generators/pro_generator_spec.rb | 38 ++++++++++- 2 files changed, 93 insertions(+), 8 deletions(-) diff --git a/react_on_rails/lib/generators/react_on_rails/pro_generator.rb b/react_on_rails/lib/generators/react_on_rails/pro_generator.rb index 1a7d4f3b70..74a3f09454 100644 --- a/react_on_rails/lib/generators/react_on_rails/pro_generator.rb +++ b/react_on_rails/lib/generators/react_on_rails/pro_generator.rb @@ -81,7 +81,10 @@ def add_pro_npm_dependencies # rubocop:disable Metrics/AbcSize def swap_base_gem_for_pro_in_gemfile gemfile_path = File.join(destination_root, "Gemfile") - return unless File.exist?(gemfile_path) + unless File.exist?(gemfile_path) + add_missing_gemfile_warning(gemfile_path) + return + end gemfile_content = File.read(gemfile_path) pro_gem_pattern = /^\s*gem\s+["']react_on_rails_pro["']/ @@ -135,7 +138,8 @@ def bundle_install_after_gem_swap gemfile_path = File.join(destination_root, "Gemfile") pid = Process.spawn( { "BUNDLE_GEMFILE" => gemfile_path }, - "bundle install", + "bundle", + "install", out: $stdout, err: $stderr, chdir: destination_root @@ -149,6 +153,7 @@ def bundle_install_after_gem_swap GeneratorMessages.add_warning(<<~MSG.strip) âš ī¸ Automatic bundle install timed out after #{ProSetup::AUTO_INSTALL_TIMEOUT} seconds. + Gemfile has been updated with react_on_rails_pro. Please run manually: bundle install MSG @@ -158,6 +163,7 @@ def bundle_install_after_gem_swap GeneratorMessages.add_warning(<<~MSG.strip) âš ī¸ Automatic bundle install failed after swapping Gemfile entries. + Gemfile has been updated with react_on_rails_pro. Please run manually: bundle install MSG @@ -187,7 +193,7 @@ def update_imports_to_pro_package end def js_files_for_import_update - js_extensions = %w[js jsx ts tsx mjs cjs].join(",") + js_extensions = %w[js jsx ts tsx mjs cjs vue svelte].join(",") %w[app/javascript app/frontend frontend javascript client].flat_map do |root| root_path = File.join(destination_root, root) next [] unless Dir.exist?(root_path) @@ -201,16 +207,28 @@ def rewrite_react_on_rails_module_specifiers(content) (? \bfrom\s+| \bimport\s*\(\s*| - \brequire\s*\(\s*| - \bimport\s+ + \brequire\s*\(\s* ) (?["']) react-on-rails(?!-pro) (?=(?:["']|/)) }x - content.gsub(module_specifier_pattern) do - "#{Regexp.last_match[:prefix]}#{Regexp.last_match[:quote]}react-on-rails-pro" + side_effect_import_pattern = %r{ + \A(?\s*import\s+) + (?["']) + react-on-rails(?!-pro) + (?=(?:["']|/)) + }x + + rewrite_non_comment_lines(content) do |line| + rewritten_line = line.gsub(module_specifier_pattern) do + "#{Regexp.last_match[:prefix]}#{Regexp.last_match[:quote]}react-on-rails-pro" + end + + rewritten_line.gsub(side_effect_import_pattern) do + "#{Regexp.last_match[:prefix]}#{Regexp.last_match[:quote]}react-on-rails-pro" + end end end @@ -218,6 +236,37 @@ def line_continues_with_comma?(line) line.rstrip.match?(/,\s*(?:#.*)?\z/) end + def add_missing_gemfile_warning(gemfile_path) + GeneratorMessages.add_warning(<<~MSG.strip) + âš ī¸ Could not find Gemfile at #{gemfile_path}. + + Skipping automatic react_on_rails -> react_on_rails_pro Gemfile swap. + Please update your Gemfile manually if your app uses a non-standard Gemfile path. + MSG + end + + def rewrite_non_comment_lines(content) + in_block_comment = false + + content.lines.map do |line| + stripped = line.lstrip + + if in_block_comment + in_block_comment = false if stripped.include?("*/") + line + elsif stripped.start_with?("/*") + in_block_comment = !stripped.include?("*/") + line + elsif stripped.start_with?("//", "*") + line + else + rewritten_line = yield line + in_block_comment = true if stripped.include?("/*") && !stripped.include?("*/") + rewritten_line + end + end.join + end + def print_success_message route = if File.exist?(File.join(destination_root, "app/controllers/hello_server_controller.rb")) "hello_server" diff --git a/react_on_rails/spec/react_on_rails/generators/pro_generator_spec.rb b/react_on_rails/spec/react_on_rails/generators/pro_generator_spec.rb index a3ec40e52d..51b748bbe1 100644 --- a/react_on_rails/spec/react_on_rails/generators/pro_generator_spec.rb +++ b/react_on_rails/spec/react_on_rails/generators/pro_generator_spec.rb @@ -178,6 +178,19 @@ expect(generator).not_to have_received(:bundle_install_after_gem_swap) end + it "warns when Gemfile is missing" do + allow(File).to receive(:exist?).and_call_original + allow(File).to receive(:exist?).with(gemfile_path).and_return(false) + allow(generator).to receive(:bundle_install_after_gem_swap) + + generator.send(:swap_base_gem_for_pro_in_gemfile) + + warning_text = GeneratorMessages.messages.join("\n") + expect(warning_text).to include("Could not find Gemfile") + expect(warning_text).to include("non-standard Gemfile path") + expect(generator).not_to have_received(:bundle_install_after_gem_swap) + end + it "replaces base gem entries that include inline comments" do simulate_existing_file("Gemfile", <<~RUBY) source "https://rubygems.org" @@ -214,7 +227,8 @@ expect(Process).to have_received(:spawn).with( { "BUNDLE_GEMFILE" => File.join(destination_root, "Gemfile") }, - "bundle install", + "bundle", + "install", out: $stdout, err: $stderr, chdir: destination_root @@ -231,6 +245,8 @@ let(:application_js_path) { File.join(destination_root, "app/javascript/packs/application.js") } let(:server_js_path) { File.join(destination_root, "client/server.js") } let(:frontend_js_path) { File.join(destination_root, "app/frontend/entrypoints/client.ts") } + let(:vue_component_path) { File.join(destination_root, "app/frontend/components/RorWidget.vue") } + let(:svelte_component_path) { File.join(destination_root, "frontend/components/RorWidget.svelte") } before do prepare_destination @@ -243,9 +259,24 @@ import CustomPackage from "react-on-rails-utils"; const scoped = "@scope/react-on-rails"; const url = "https://cdn.example.com/react-on-rails/client.js"; + // import "react-on-rails"; + /* + * import ReactOnRails from "react-on-rails"; + */ JS simulate_existing_file("client/server.js", "import ReactOnRails from \"react-on-rails-pro\";\n") simulate_existing_file("app/frontend/entrypoints/client.ts", "import ReactOnRails from \"react-on-rails\";\n") + simulate_existing_file("app/frontend/components/RorWidget.vue", <<~VUE) + + VUE + simulate_existing_file("frontend/components/RorWidget.svelte", <<~SVELTE) + + SVELTE end it "updates react-on-rails imports and requires to react-on-rails-pro" do @@ -258,8 +289,13 @@ expect(File.read(application_js_path)).to include('import CustomPackage from "react-on-rails-utils";') expect(File.read(application_js_path)).to include('const scoped = "@scope/react-on-rails";') expect(File.read(application_js_path)).to include('const url = "https://cdn.example.com/react-on-rails/client.js";') + expect(File.read(application_js_path)).to include('// import "react-on-rails";') + expect(File.read(application_js_path)).to include('* import ReactOnRails from "react-on-rails";') expect(File.read(server_js_path)).to include('import ReactOnRails from "react-on-rails-pro";') expect(File.read(frontend_js_path)).to include('import ReactOnRails from "react-on-rails-pro";') + expect(File.read(vue_component_path)).to include('import ReactOnRails from "react-on-rails-pro";') + expect(File.read(vue_component_path)).to include('require("react-on-rails-pro")') + expect(File.read(svelte_component_path)).to include('import ReactOnRails from "react-on-rails-pro";') end end From 3770f5ccee3bbff853bdaae692eb0e0448c2f3ee Mon Sep 17 00:00:00 2001 From: Justin Gordon Date: Mon, 23 Mar 2026 22:34:47 -1000 Subject: [PATCH 09/37] Fix Gemfile multiline parsing edge case in pro generator --- .../generators/react_on_rails/pro_generator.rb | 18 ++++++++++++++---- .../generators/pro_generator_spec.rb | 16 ++++++++++++++++ 2 files changed, 30 insertions(+), 4 deletions(-) diff --git a/react_on_rails/lib/generators/react_on_rails/pro_generator.rb b/react_on_rails/lib/generators/react_on_rails/pro_generator.rb index 74a3f09454..9cacc0bf61 100644 --- a/react_on_rails/lib/generators/react_on_rails/pro_generator.rb +++ b/react_on_rails/lib/generators/react_on_rails/pro_generator.rb @@ -78,7 +78,7 @@ def add_pro_npm_dependencies say "✅ Pro npm dependencies added", :green end - # rubocop:disable Metrics/AbcSize + # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength def swap_base_gem_for_pro_in_gemfile gemfile_path = File.join(destination_root, "Gemfile") unless File.exist?(gemfile_path) @@ -117,7 +117,9 @@ def swap_base_gem_for_pro_in_gemfile # Consume multiline gem declarations that continue with trailing commas. line_index += 1 current_line = line - while line_index < gemfile_lines.length && line_continues_with_comma?(current_line) + while line_index < gemfile_lines.length && + line_continues_with_comma?(current_line) && + gem_declaration_continues_on_next_line?(gemfile_lines[line_index]) current_line = gemfile_lines[line_index] line_index += 1 end @@ -130,7 +132,7 @@ def swap_base_gem_for_pro_in_gemfile say "✅ Replaced react_on_rails with react_on_rails_pro in Gemfile", :green bundle_install_after_gem_swap end - # rubocop:enable Metrics/AbcSize + # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength def bundle_install_after_gem_swap say "đŸ“Ļ Running bundle install after Gemfile update...", :yellow @@ -233,7 +235,15 @@ def rewrite_react_on_rails_module_specifiers(content) end def line_continues_with_comma?(line) - line.rstrip.match?(/,\s*(?:#.*)?\z/) + line_without_comment = line.sub(/\s+#.*$/, "").rstrip + line_without_comment.end_with?(",") + end + + def gem_declaration_continues_on_next_line?(line) + stripped = line.lstrip + return false if stripped.empty? + + !stripped.start_with?("gem ") end def add_missing_gemfile_warning(gemfile_path) diff --git a/react_on_rails/spec/react_on_rails/generators/pro_generator_spec.rb b/react_on_rails/spec/react_on_rails/generators/pro_generator_spec.rb index 51b748bbe1..0c1cf3b59d 100644 --- a/react_on_rails/spec/react_on_rails/generators/pro_generator_spec.rb +++ b/react_on_rails/spec/react_on_rails/generators/pro_generator_spec.rb @@ -133,6 +133,22 @@ expect(gemfile_content).not_to include(" \"~> 16.0\"") end + it "does not consume the next gem line when base declaration ends with a trailing comma" do + simulate_existing_file("Gemfile", <<~RUBY) + source "https://rubygems.org" + gem "react_on_rails", + gem "rails" + RUBY + allow(generator).to receive(:bundle_install_after_gem_swap) + + generator.send(:swap_base_gem_for_pro_in_gemfile) + + gemfile_content = File.read(gemfile_path) + expected_version = Gem::Version.new(ReactOnRails::VERSION).release.to_s + expect(gemfile_content).to include("gem \"react_on_rails_pro\", \"~> #{expected_version}\"") + expect(gemfile_content).to include("gem \"rails\"") + end + it "preserves single quote style when replacing single-quoted Gemfile entries" do simulate_existing_file("Gemfile", <<~RUBY) source "https://rubygems.org" From f6a42ec075128648e6b7e71b0cc2bd1cc366669c Mon Sep 17 00:00:00 2001 From: Justin Gordon Date: Mon, 23 Mar 2026 22:36:19 -1000 Subject: [PATCH 10/37] Handle magic-comment dynamic imports in pro import rewrite --- react_on_rails/lib/generators/react_on_rails/pro_generator.rb | 2 +- .../spec/react_on_rails/generators/pro_generator_spec.rb | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/react_on_rails/lib/generators/react_on_rails/pro_generator.rb b/react_on_rails/lib/generators/react_on_rails/pro_generator.rb index 9cacc0bf61..de31805e6f 100644 --- a/react_on_rails/lib/generators/react_on_rails/pro_generator.rb +++ b/react_on_rails/lib/generators/react_on_rails/pro_generator.rb @@ -208,7 +208,7 @@ def rewrite_react_on_rails_module_specifiers(content) module_specifier_pattern = %r{ (? \bfrom\s+| - \bimport\s*\(\s*| + \bimport\s*\(\s*(?:/\*[^*]*\*+(?:[^/*][^*]*\*+)*/\s*)*| \brequire\s*\(\s* ) (?["']) diff --git a/react_on_rails/spec/react_on_rails/generators/pro_generator_spec.rb b/react_on_rails/spec/react_on_rails/generators/pro_generator_spec.rb index 0c1cf3b59d..87c90bc44b 100644 --- a/react_on_rails/spec/react_on_rails/generators/pro_generator_spec.rb +++ b/react_on_rails/spec/react_on_rails/generators/pro_generator_spec.rb @@ -270,6 +270,7 @@ simulate_existing_file("app/javascript/packs/application.js", <<~JS) import ReactOnRails from "react-on-rails"; const ror = require("react-on-rails"); + const lazyRor = import(/* webpackChunkName: "ror" */ "react-on-rails"); import ReactOnRailsClient from "react-on-rails/client"; import "react-on-rails"; import CustomPackage from "react-on-rails-utils"; @@ -300,6 +301,7 @@ expect(File.read(application_js_path)).to include('import ReactOnRails from "react-on-rails-pro";') expect(File.read(application_js_path)).to include('require("react-on-rails-pro")') + expect(File.read(application_js_path)).to include('import(/* webpackChunkName: "ror" */ "react-on-rails-pro")') expect(File.read(application_js_path)).to include('import ReactOnRailsClient from "react-on-rails-pro/client";') expect(File.read(application_js_path)).to include('import "react-on-rails-pro";') expect(File.read(application_js_path)).to include('import CustomPackage from "react-on-rails-utils";') From aa86193be9b0d2538dc85bc3152131c5909544a4 Mon Sep 17 00:00:00 2001 From: Justin Gordon Date: Mon, 23 Mar 2026 22:39:51 -1000 Subject: [PATCH 11/37] Harden pro Gemfile swap and import rewrite edge cases --- .../react_on_rails/pro_generator.rb | 30 +++++++++++-- .../generators/pro_generator_spec.rb | 45 +++++++++++++++++++ 2 files changed, 71 insertions(+), 4 deletions(-) diff --git a/react_on_rails/lib/generators/react_on_rails/pro_generator.rb b/react_on_rails/lib/generators/react_on_rails/pro_generator.rb index de31805e6f..abee801b2b 100644 --- a/react_on_rails/lib/generators/react_on_rails/pro_generator.rb +++ b/react_on_rails/lib/generators/react_on_rails/pro_generator.rb @@ -87,8 +87,8 @@ def swap_base_gem_for_pro_in_gemfile end gemfile_content = File.read(gemfile_path) - pro_gem_pattern = /^\s*gem\s+["']react_on_rails_pro["']/ - base_gem_pattern = /^(\s*)gem\s+(["'])react_on_rails\2(?=\s*(?:,|#|$))/ + pro_gem_pattern = /^\s*gem(?:\s+|\(\s*)["']react_on_rails_pro["']/ + base_gem_pattern = /^(\s*)gem(?:\s+|\(\s*)(["'])react_on_rails\2(?=\s*(?:,|\)|#|$))/ has_pro_gem_entry = gemfile_content.match?(pro_gem_pattern) gemfile_lines = gemfile_content.lines @@ -131,6 +131,8 @@ def swap_base_gem_for_pro_in_gemfile File.write(gemfile_path, updated_content) say "✅ Replaced react_on_rails with react_on_rails_pro in Gemfile", :green bundle_install_after_gem_swap + rescue StandardError => e + add_gemfile_update_warning(gemfile_path, e) end # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength @@ -243,7 +245,7 @@ def gem_declaration_continues_on_next_line?(line) stripped = line.lstrip return false if stripped.empty? - !stripped.start_with?("gem ") + !stripped.match?(/\Agem(?:\s|\()/) end def add_missing_gemfile_warning(gemfile_path) @@ -255,6 +257,15 @@ def add_missing_gemfile_warning(gemfile_path) MSG end + def add_gemfile_update_warning(gemfile_path, error) + GeneratorMessages.add_warning(<<~MSG.strip) + âš ī¸ Could not update Gemfile at #{gemfile_path}: #{error.message} + + Skipping automatic react_on_rails -> react_on_rails_pro Gemfile swap. + Please update your Gemfile manually. + MSG + end + def rewrite_non_comment_lines(content) in_block_comment = false @@ -271,12 +282,23 @@ def rewrite_non_comment_lines(content) line else rewritten_line = yield line - in_block_comment = true if stripped.include?("/*") && !stripped.include?("*/") + in_block_comment = true if unclosed_block_comment_starts?(line) rewritten_line end end.join end + def unclosed_block_comment_starts?(line) + line_without_strings = line.gsub( + /"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|`(?:\\.|[^`\\])*`/, + "" + ) + opening_index = line_without_strings.index("/*") + return false unless opening_index + + line_without_strings.index("*/", opening_index + 2).nil? + end + def print_success_message route = if File.exist?(File.join(destination_root, "app/controllers/hello_server_controller.rb")) "hello_server" diff --git a/react_on_rails/spec/react_on_rails/generators/pro_generator_spec.rb b/react_on_rails/spec/react_on_rails/generators/pro_generator_spec.rb index 87c90bc44b..f81f39e0ac 100644 --- a/react_on_rails/spec/react_on_rails/generators/pro_generator_spec.rb +++ b/react_on_rails/spec/react_on_rails/generators/pro_generator_spec.rb @@ -164,6 +164,22 @@ expect(generator).to have_received(:bundle_install_after_gem_swap) end + it "replaces parenthesized Gemfile declarations" do + simulate_existing_file("Gemfile", <<~RUBY) + source "https://rubygems.org" + gem("react_on_rails", "~> 16.0") + RUBY + allow(generator).to receive(:bundle_install_after_gem_swap) + + generator.send(:swap_base_gem_for_pro_in_gemfile) + + gemfile_content = File.read(gemfile_path) + expected_version = Gem::Version.new(ReactOnRails::VERSION).release.to_s + expect(gemfile_content).to include("gem \"react_on_rails_pro\", \"~> #{expected_version}\"") + expect(gemfile_content).not_to include('gem("react_on_rails"') + expect(generator).to have_received(:bundle_install_after_gem_swap) + end + it "removes base gem without adding duplicate react_on_rails_pro entries" do simulate_existing_file("Gemfile", <<~RUBY) source "https://rubygems.org" @@ -207,6 +223,23 @@ expect(generator).not_to have_received(:bundle_install_after_gem_swap) end + it "warns and skips bundle install when Gemfile cannot be written" do + simulate_existing_file("Gemfile", <<~RUBY) + source "https://rubygems.org" + gem "react_on_rails", "~> 16.0" + RUBY + allow(File).to receive(:write).and_call_original + allow(File).to receive(:write).with(gemfile_path, anything).and_raise(Errno::EACCES) + allow(generator).to receive(:bundle_install_after_gem_swap) + + generator.send(:swap_base_gem_for_pro_in_gemfile) + + warning_text = GeneratorMessages.messages.join("\n") + expect(warning_text).to include("Could not update Gemfile") + expect(warning_text).to include("Please update your Gemfile manually") + expect(generator).not_to have_received(:bundle_install_after_gem_swap) + end + it "replaces base gem entries that include inline comments" do simulate_existing_file("Gemfile", <<~RUBY) source "https://rubygems.org" @@ -236,6 +269,15 @@ allow(Process).to receive(:spawn).and_return(fake_pid) end + it "returns without warnings when bundle install succeeds" do + allow(generator).to receive(:wait_for_bundle_process) + .with(fake_pid).and_return(instance_double(Process::Status, success?: true)) + + generator.send(:bundle_install_after_gem_swap) + + expect(GeneratorMessages.messages).to eq([]) + end + it "uses bounded process waiting and warns on timeout" do allow(generator).to receive(:wait_for_bundle_process).with(fake_pid).and_return(nil) @@ -271,6 +313,8 @@ import ReactOnRails from "react-on-rails"; const ror = require("react-on-rails"); const lazyRor = import(/* webpackChunkName: "ror" */ "react-on-rails"); + const commentLikeString = "/* not a JS comment"; + import ReactOnRailsServer from "react-on-rails/server"; import ReactOnRailsClient from "react-on-rails/client"; import "react-on-rails"; import CustomPackage from "react-on-rails-utils"; @@ -302,6 +346,7 @@ expect(File.read(application_js_path)).to include('import ReactOnRails from "react-on-rails-pro";') expect(File.read(application_js_path)).to include('require("react-on-rails-pro")') expect(File.read(application_js_path)).to include('import(/* webpackChunkName: "ror" */ "react-on-rails-pro")') + expect(File.read(application_js_path)).to include('import ReactOnRailsServer from "react-on-rails-pro/server";') expect(File.read(application_js_path)).to include('import ReactOnRailsClient from "react-on-rails-pro/client";') expect(File.read(application_js_path)).to include('import "react-on-rails-pro";') expect(File.read(application_js_path)).to include('import CustomPackage from "react-on-rails-utils";') From 5bcf491b0f1004a17ae21f906cd52453fda49b9a Mon Sep 17 00:00:00 2001 From: Justin Gordon Date: Mon, 23 Mar 2026 22:51:36 -1000 Subject: [PATCH 12/37] Harden pro generator import and Gemfile rewrite flows --- .../react_on_rails/pro_generator.rb | 81 ++++++++++++++++--- .../generators/pro_generator_spec.rb | 76 +++++++++++++++++ 2 files changed, 146 insertions(+), 11 deletions(-) diff --git a/react_on_rails/lib/generators/react_on_rails/pro_generator.rb b/react_on_rails/lib/generators/react_on_rails/pro_generator.rb index abee801b2b..0955275d4a 100644 --- a/react_on_rails/lib/generators/react_on_rails/pro_generator.rb +++ b/react_on_rails/lib/generators/react_on_rails/pro_generator.rb @@ -78,7 +78,7 @@ def add_pro_npm_dependencies say "✅ Pro npm dependencies added", :green end - # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength + # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity def swap_base_gem_for_pro_in_gemfile gemfile_path = File.join(destination_root, "Gemfile") unless File.exist?(gemfile_path) @@ -120,7 +120,8 @@ def swap_base_gem_for_pro_in_gemfile while line_index < gemfile_lines.length && line_continues_with_comma?(current_line) && gem_declaration_continues_on_next_line?(gemfile_lines[line_index]) - current_line = gemfile_lines[line_index] + next_line = gemfile_lines[line_index] + current_line = next_line unless comment_or_blank_line?(next_line) line_index += 1 end end @@ -128,15 +129,25 @@ def swap_base_gem_for_pro_in_gemfile updated_content = updated_lines.join return if updated_content == gemfile_content + if options[:pretend] + say_status :pretend, "Would replace react_on_rails with react_on_rails_pro in Gemfile", :yellow + return + end + File.write(gemfile_path, updated_content) say "✅ Replaced react_on_rails with react_on_rails_pro in Gemfile", :green bundle_install_after_gem_swap rescue StandardError => e add_gemfile_update_warning(gemfile_path, e) end - # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength + # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity def bundle_install_after_gem_swap + if options[:pretend] + say_status :pretend, "Skipping bundle install in --pretend mode", :yellow + return + end + say "đŸ“Ļ Running bundle install after Gemfile update...", :yellow install_status = Bundler.with_unbundled_env do gemfile_path = File.join(destination_root, "Gemfile") @@ -182,16 +193,33 @@ def bundle_install_after_gem_swap def update_imports_to_pro_package files = js_files_for_import_update - updated_files = files.count do |file| + updated_files = 0 + + files.each do |file| content = File.read(file) updated_content = rewrite_react_on_rails_module_specifiers(content) - next false if updated_content == content + next if updated_content == content + + if options[:pretend] + say_status :pretend, "Would update react-on-rails imports in #{file}", :yellow + updated_files += 1 + next + end File.write(file, updated_content) - true + updated_files += 1 + rescue StandardError => e + GeneratorMessages.add_warning(<<~MSG.strip) + âš ī¸ Could not update imports in #{file}: #{e.message} + + Please update react-on-rails imports to react-on-rails-pro manually. + MSG end - return if updated_files.zero? + if updated_files.zero? + say "â„šī¸ No react-on-rails imports required updates", :yellow + return + end say "✅ Updated react-on-rails imports in #{updated_files} file(s)", :green end @@ -237,7 +265,7 @@ def rewrite_react_on_rails_module_specifiers(content) end def line_continues_with_comma?(line) - line_without_comment = line.sub(/\s+#.*$/, "").rstrip + line_without_comment = line.sub(/\s*#.*$/, "").rstrip line_without_comment.end_with?(",") end @@ -248,6 +276,11 @@ def gem_declaration_continues_on_next_line?(line) !stripped.match?(/\Agem(?:\s|\()/) end + def comment_or_blank_line?(line) + stripped = line.lstrip + stripped.empty? || stripped.start_with?("#") + end + def add_missing_gemfile_warning(gemfile_path) GeneratorMessages.add_warning(<<~MSG.strip) âš ī¸ Could not find Gemfile at #{gemfile_path}. @@ -266,8 +299,10 @@ def add_gemfile_update_warning(gemfile_path, error) MSG end + # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity def rewrite_non_comment_lines(content) in_block_comment = false + pending_dynamic_import = false content.lines.map do |line| stripped = line.lstrip @@ -278,25 +313,49 @@ def rewrite_non_comment_lines(content) elsif stripped.start_with?("/*") in_block_comment = !stripped.include?("*/") line - elsif stripped.start_with?("//", "*") + elsif stripped.start_with?("//") || stripped.match?(/\A\*\s/) line else rewritten_line = yield line + if pending_dynamic_import + rewritten_line = rewrite_pending_dynamic_import_specifier(rewritten_line) + pending_dynamic_import = !import_call_closes_on_line?(rewritten_line) + elsif starts_pending_dynamic_import?(rewritten_line) + pending_dynamic_import = true + end in_block_comment = true if unclosed_block_comment_starts?(line) rewritten_line end end.join end + # rubocop:enable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity def unclosed_block_comment_starts?(line) line_without_strings = line.gsub( /"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|`(?:\\.|[^`\\])*`/, "" ) - opening_index = line_without_strings.index("/*") + line_without_inline_comment = line_without_strings.sub(%r{//.*$}, "") + opening_index = line_without_inline_comment.index("/*") return false unless opening_index - line_without_strings.index("*/", opening_index + 2).nil? + line_without_inline_comment.index("*/", opening_index + 2).nil? + end + + def starts_pending_dynamic_import?(line) + return false unless line.match?(/\bimport\s*\(/) + + !line.match?(%r{\bimport\s*\(\s*(?:/\*[^*]*\*+(?:[^/*][^*]*\*+)*/\s*)*["']}) + end + + def rewrite_pending_dynamic_import_specifier(line) + line.sub(%r{(?["'])react-on-rails(?!-pro)(?=(?:["']|/))}) do + "#{Regexp.last_match[:quote]}react-on-rails-pro" + end + end + + def import_call_closes_on_line?(line) + line.include?(")") end def print_success_message diff --git a/react_on_rails/spec/react_on_rails/generators/pro_generator_spec.rb b/react_on_rails/spec/react_on_rails/generators/pro_generator_spec.rb index f81f39e0ac..2542859b93 100644 --- a/react_on_rails/spec/react_on_rails/generators/pro_generator_spec.rb +++ b/react_on_rails/spec/react_on_rails/generators/pro_generator_spec.rb @@ -133,6 +133,42 @@ expect(gemfile_content).not_to include(" \"~> 16.0\"") end + it "replaces multiline declarations when trailing comma is followed by a tight inline comment" do + simulate_existing_file("Gemfile", <<~RUBY) + source "https://rubygems.org" + gem "react_on_rails",#pinned for compatibility + "~> 16.0" + RUBY + allow(generator).to receive(:bundle_install_after_gem_swap) + + generator.send(:swap_base_gem_for_pro_in_gemfile) + + gemfile_content = File.read(gemfile_path) + expected_version = Gem::Version.new(ReactOnRails::VERSION).release.to_s + expect(gemfile_content).to include("gem \"react_on_rails_pro\", \"~> #{expected_version}\"") + expect(gemfile_content).not_to include("gem \"react_on_rails\",#pinned for compatibility") + expect(gemfile_content).not_to include(" \"~> 16.0\"") + end + + it "consumes multiline declarations when comment-only lines appear before continuation lines" do + simulate_existing_file("Gemfile", <<~RUBY) + source "https://rubygems.org" + gem "react_on_rails", + # pinned for compatibility + "~> 16.0" + gem "rails" + RUBY + allow(generator).to receive(:bundle_install_after_gem_swap) + + generator.send(:swap_base_gem_for_pro_in_gemfile) + + gemfile_content = File.read(gemfile_path) + expected_version = Gem::Version.new(ReactOnRails::VERSION).release.to_s + expect(gemfile_content).to include("gem \"react_on_rails_pro\", \"~> #{expected_version}\"") + expect(gemfile_content).to include("gem \"rails\"") + expect(gemfile_content).not_to include("~> 16.0") + end + it "does not consume the next gem line when base declaration ends with a trailing comma" do simulate_existing_file("Gemfile", <<~RUBY) source "https://rubygems.org" @@ -255,6 +291,22 @@ expect(gemfile_content).not_to include("gem \"react_on_rails\" # pinned for compatibility") expect(generator).to have_received(:bundle_install_after_gem_swap) end + + it "does not modify Gemfile in --pretend mode" do + pretend_generator = described_class.new([], { pretend: true }) + allow(pretend_generator).to receive(:destination_root).and_return(destination_root) + simulate_existing_file("Gemfile", <<~RUBY) + source "https://rubygems.org" + gem "react_on_rails", "~> 16.0" + RUBY + + original_content = File.read(gemfile_path) + expect(pretend_generator).not_to receive(:bundle_install_after_gem_swap) + + pretend_generator.send(:swap_base_gem_for_pro_in_gemfile) + + expect(File.read(gemfile_path)).to eq(original_content) + end end describe "#bundle_install_after_gem_swap" do @@ -278,6 +330,14 @@ expect(GeneratorMessages.messages).to eq([]) end + it "skips bundle install in --pretend mode" do + pretend_generator = described_class.new([], { pretend: true }) + allow(pretend_generator).to receive(:destination_root).and_return(destination_root) + + expect(Bundler).not_to receive(:with_unbundled_env) + pretend_generator.send(:bundle_install_after_gem_swap) + end + it "uses bounded process waiting and warns on timeout" do allow(generator).to receive(:wait_for_bundle_process).with(fake_pid).and_return(nil) @@ -313,6 +373,11 @@ import ReactOnRails from "react-on-rails"; const ror = require("react-on-rails"); const lazyRor = import(/* webpackChunkName: "ror" */ "react-on-rails"); + const lazyRorMultiline = import( + /* webpackMode: "lazy" */ + "react-on-rails/client" + ); + const keepRor = require("react-on-rails"); // /* not a block comment start const commentLikeString = "/* not a JS comment"; import ReactOnRailsServer from "react-on-rails/server"; import ReactOnRailsClient from "react-on-rails/client"; @@ -346,6 +411,7 @@ expect(File.read(application_js_path)).to include('import ReactOnRails from "react-on-rails-pro";') expect(File.read(application_js_path)).to include('require("react-on-rails-pro")') expect(File.read(application_js_path)).to include('import(/* webpackChunkName: "ror" */ "react-on-rails-pro")') + expect(File.read(application_js_path)).to include('"react-on-rails-pro/client"') expect(File.read(application_js_path)).to include('import ReactOnRailsServer from "react-on-rails-pro/server";') expect(File.read(application_js_path)).to include('import ReactOnRailsClient from "react-on-rails-pro/client";') expect(File.read(application_js_path)).to include('import "react-on-rails-pro";') @@ -360,6 +426,16 @@ expect(File.read(vue_component_path)).to include('require("react-on-rails-pro")') expect(File.read(svelte_component_path)).to include('import ReactOnRails from "react-on-rails-pro";') end + + it "does not write files in --pretend mode" do + pretend_generator = described_class.new([], { pretend: true }) + allow(pretend_generator).to receive(:destination_root).and_return(destination_root) + original_content = File.read(application_js_path) + + pretend_generator.send(:update_imports_to_pro_package) + + expect(File.read(application_js_path)).to eq(original_content) + end end # Integration test for standalone happy path From 28747cb123cec494c86c2afd02821066288fddce Mon Sep 17 00:00:00 2001 From: Justin Gordon Date: Mon, 23 Mar 2026 22:55:37 -1000 Subject: [PATCH 13/37] Clarify preserved Pro Gemfile entries during swap --- react_on_rails/lib/generators/react_on_rails/pro_generator.rb | 4 ++++ .../spec/react_on_rails/generators/pro_generator_spec.rb | 3 +++ 2 files changed, 7 insertions(+) diff --git a/react_on_rails/lib/generators/react_on_rails/pro_generator.rb b/react_on_rails/lib/generators/react_on_rails/pro_generator.rb index 0955275d4a..8751b2f251 100644 --- a/react_on_rails/lib/generators/react_on_rails/pro_generator.rb +++ b/react_on_rails/lib/generators/react_on_rails/pro_generator.rb @@ -129,6 +129,10 @@ def swap_base_gem_for_pro_in_gemfile updated_content = updated_lines.join return if updated_content == gemfile_content + if has_pro_gem_entry + say "â„šī¸ Existing react_on_rails_pro Gemfile entry detected; preserving current version constraint", :yellow + end + if options[:pretend] say_status :pretend, "Would replace react_on_rails with react_on_rails_pro in Gemfile", :yellow return diff --git a/react_on_rails/spec/react_on_rails/generators/pro_generator_spec.rb b/react_on_rails/spec/react_on_rails/generators/pro_generator_spec.rb index 2542859b93..929617d75d 100644 --- a/react_on_rails/spec/react_on_rails/generators/pro_generator_spec.rb +++ b/react_on_rails/spec/react_on_rails/generators/pro_generator_spec.rb @@ -223,12 +223,15 @@ gem "react_on_rails_pro", "~> 16.0" RUBY allow(generator).to receive(:bundle_install_after_gem_swap) + allow(generator).to receive(:say) generator.send(:swap_base_gem_for_pro_in_gemfile) gemfile_content = File.read(gemfile_path) expect(gemfile_content).not_to match(/gem\s+["']react_on_rails["']/) expect(gemfile_content.scan(/gem\s+["']react_on_rails_pro["']/).size).to eq(1) + expect(generator).to have_received(:say) + .with("â„šī¸ Existing react_on_rails_pro Gemfile entry detected; preserving current version constraint", :yellow) expect(generator).to have_received(:bundle_install_after_gem_swap) end From 45da4bba52a6d7de306df72d93f535c4c2fd0c25 Mon Sep 17 00:00:00 2001 From: Justin Gordon Date: Mon, 23 Mar 2026 23:01:08 -1000 Subject: [PATCH 14/37] Harden Pro generator multiline parsing and atomic writes --- .../react_on_rails/pro_generator.rb | 25 ++++++++++--- .../generators/pro_generator_spec.rb | 35 +++++++++++++++++-- 2 files changed, 54 insertions(+), 6 deletions(-) diff --git a/react_on_rails/lib/generators/react_on_rails/pro_generator.rb b/react_on_rails/lib/generators/react_on_rails/pro_generator.rb index 8751b2f251..8b08a25cff 100644 --- a/react_on_rails/lib/generators/react_on_rails/pro_generator.rb +++ b/react_on_rails/lib/generators/react_on_rails/pro_generator.rb @@ -1,6 +1,8 @@ # frozen_string_literal: true require "rails/generators" +require "tempfile" +require "fileutils" require_relative "generator_helper" require_relative "generator_messages" require_relative "js_dependency_manager" @@ -138,7 +140,7 @@ def swap_base_gem_for_pro_in_gemfile return end - File.write(gemfile_path, updated_content) + atomic_write_file(gemfile_path, updated_content) say "✅ Replaced react_on_rails with react_on_rails_pro in Gemfile", :green bundle_install_after_gem_swap rescue StandardError => e @@ -146,15 +148,25 @@ def swap_base_gem_for_pro_in_gemfile end # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity + def atomic_write_file(path, content) + Tempfile.create([File.basename(path), ".tmp"], File.dirname(path)) do |temp_file| + temp_file.write(content) + temp_file.flush + temp_file.fsync + temp_file.close + FileUtils.mv(temp_file.path, path) + end + end + def bundle_install_after_gem_swap if options[:pretend] say_status :pretend, "Skipping bundle install in --pretend mode", :yellow return end + gemfile_path = File.join(destination_root, "Gemfile") say "đŸ“Ļ Running bundle install after Gemfile update...", :yellow install_status = Bundler.with_unbundled_env do - gemfile_path = File.join(destination_root, "Gemfile") pid = Process.spawn( { "BUNDLE_GEMFILE" => gemfile_path }, "bundle", @@ -275,7 +287,7 @@ def line_continues_with_comma?(line) def gem_declaration_continues_on_next_line?(line) stripped = line.lstrip - return false if stripped.empty? + return true if stripped.empty? !stripped.match?(/\Agem(?:\s|\()/) end @@ -359,7 +371,12 @@ def rewrite_pending_dynamic_import_specifier(line) end def import_call_closes_on_line?(line) - line.include?(")") + line_without_strings = line.gsub( + /"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|`(?:\\.|[^`\\])*`/, + "" + ) + line_without_comments = line_without_strings.sub(%r{//.*$}, "") + line_without_comments.include?(")") end def print_success_message diff --git a/react_on_rails/spec/react_on_rails/generators/pro_generator_spec.rb b/react_on_rails/spec/react_on_rails/generators/pro_generator_spec.rb index 929617d75d..acfc59958b 100644 --- a/react_on_rails/spec/react_on_rails/generators/pro_generator_spec.rb +++ b/react_on_rails/spec/react_on_rails/generators/pro_generator_spec.rb @@ -169,6 +169,25 @@ expect(gemfile_content).not_to include("~> 16.0") end + it "consumes multiline declarations when blank lines appear before continuation lines" do + simulate_existing_file("Gemfile", <<~RUBY) + source "https://rubygems.org" + gem "react_on_rails", + + "~> 16.0" + gem "rails" + RUBY + allow(generator).to receive(:bundle_install_after_gem_swap) + + generator.send(:swap_base_gem_for_pro_in_gemfile) + + gemfile_content = File.read(gemfile_path) + expected_version = Gem::Version.new(ReactOnRails::VERSION).release.to_s + expect(gemfile_content).to include("gem \"react_on_rails_pro\", \"~> #{expected_version}\"") + expect(gemfile_content).to include("gem \"rails\"") + expect(gemfile_content).not_to include("~> 16.0") + end + it "does not consume the next gem line when base declaration ends with a trailing comma" do simulate_existing_file("Gemfile", <<~RUBY) source "https://rubygems.org" @@ -267,8 +286,7 @@ source "https://rubygems.org" gem "react_on_rails", "~> 16.0" RUBY - allow(File).to receive(:write).and_call_original - allow(File).to receive(:write).with(gemfile_path, anything).and_raise(Errno::EACCES) + allow(generator).to receive(:atomic_write_file).and_raise(Errno::EACCES) allow(generator).to receive(:bundle_install_after_gem_swap) generator.send(:swap_base_gem_for_pro_in_gemfile) @@ -439,6 +457,19 @@ expect(File.read(application_js_path)).to eq(original_content) end + + it "keeps multiline dynamic import tracking active when comments contain unrelated closing parentheses" do + source = <<~JS + const lazyRor = import( + /* webpackMode: "lazy" */ // some(comment) + "react-on-rails/client" + ); + JS + + rewritten = generator.send(:rewrite_react_on_rails_module_specifiers, source) + + expect(rewritten).to include('"react-on-rails-pro/client"') + end end # Integration test for standalone happy path From 7970be6ab210d6e4fe8290a660a065c4cec40fd6 Mon Sep 17 00:00:00 2001 From: Justin Gordon Date: Mon, 23 Mar 2026 23:21:23 -1000 Subject: [PATCH 15/37] Harden Pro generator import rewrites and error handling --- .../react_on_rails/pro_generator.rb | 67 ++++++++++++------- .../generators/pro_generator_spec.rb | 8 +++ 2 files changed, 50 insertions(+), 25 deletions(-) diff --git a/react_on_rails/lib/generators/react_on_rails/pro_generator.rb b/react_on_rails/lib/generators/react_on_rails/pro_generator.rb index 8b08a25cff..cdc1ec2a22 100644 --- a/react_on_rails/lib/generators/react_on_rails/pro_generator.rb +++ b/react_on_rails/lib/generators/react_on_rails/pro_generator.rb @@ -149,13 +149,18 @@ def swap_base_gem_for_pro_in_gemfile # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity def atomic_write_file(path, content) - Tempfile.create([File.basename(path), ".tmp"], File.dirname(path)) do |temp_file| - temp_file.write(content) - temp_file.flush - temp_file.fsync - temp_file.close - FileUtils.mv(temp_file.path, path) - end + temp_file = Tempfile.new([File.basename(path), ".tmp"], File.dirname(path)) + temp_path = temp_file.path + + temp_file.write(content) + temp_file.flush + temp_file.fsync + temp_file.close + FileUtils.mv(temp_path, path) + temp_path = nil + ensure + temp_file&.close unless temp_file&.closed? + File.delete(temp_path) if temp_path && File.exist?(temp_path) end def bundle_install_after_gem_swap @@ -200,7 +205,7 @@ def bundle_install_after_gem_swap MSG rescue StandardError => e GeneratorMessages.add_warning(<<~MSG.strip) - âš ī¸ Could not run automatic bundle install: #{e.message} + âš ī¸ Could not run automatic bundle install: #{e.class}: #{e.message} Please run manually: bundle install @@ -226,7 +231,7 @@ def update_imports_to_pro_package updated_files += 1 rescue StandardError => e GeneratorMessages.add_warning(<<~MSG.strip) - âš ī¸ Could not update imports in #{file}: #{e.message} + âš ī¸ Could not update imports in #{file}: #{e.class}: #{e.message} Please update react-on-rails imports to react-on-rails-pro manually. MSG @@ -308,17 +313,17 @@ def add_missing_gemfile_warning(gemfile_path) def add_gemfile_update_warning(gemfile_path, error) GeneratorMessages.add_warning(<<~MSG.strip) - âš ī¸ Could not update Gemfile at #{gemfile_path}: #{error.message} + âš ī¸ Could not update Gemfile at #{gemfile_path}: #{error.class}: #{error.message} Skipping automatic react_on_rails -> react_on_rails_pro Gemfile swap. Please update your Gemfile manually. MSG end - # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity + # rubocop:disable Metrics/AbcSize, Metrics/BlockLength, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity def rewrite_non_comment_lines(content) in_block_comment = false - pending_dynamic_import = false + pending_multiline_module_call = false content.lines.map do |line| stripped = line.lstrip @@ -327,24 +332,36 @@ def rewrite_non_comment_lines(content) in_block_comment = false if stripped.include?("*/") line elsif stripped.start_with?("/*") - in_block_comment = !stripped.include?("*/") - line + if stripped.include?("*/") + rewritten_line = yield line + if pending_multiline_module_call + rewritten_line = rewrite_pending_module_specifier(rewritten_line) + pending_multiline_module_call = !module_call_closes_on_line?(rewritten_line) + elsif starts_pending_multiline_module_call?(rewritten_line) + pending_multiline_module_call = true + end + in_block_comment = true if unclosed_block_comment_starts?(line) + rewritten_line + else + in_block_comment = true + line + end elsif stripped.start_with?("//") || stripped.match?(/\A\*\s/) line else rewritten_line = yield line - if pending_dynamic_import - rewritten_line = rewrite_pending_dynamic_import_specifier(rewritten_line) - pending_dynamic_import = !import_call_closes_on_line?(rewritten_line) - elsif starts_pending_dynamic_import?(rewritten_line) - pending_dynamic_import = true + if pending_multiline_module_call + rewritten_line = rewrite_pending_module_specifier(rewritten_line) + pending_multiline_module_call = !module_call_closes_on_line?(rewritten_line) + elsif starts_pending_multiline_module_call?(rewritten_line) + pending_multiline_module_call = true end in_block_comment = true if unclosed_block_comment_starts?(line) rewritten_line end end.join end - # rubocop:enable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity + # rubocop:enable Metrics/AbcSize, Metrics/BlockLength, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity def unclosed_block_comment_starts?(line) line_without_strings = line.gsub( @@ -358,19 +375,19 @@ def unclosed_block_comment_starts?(line) line_without_inline_comment.index("*/", opening_index + 2).nil? end - def starts_pending_dynamic_import?(line) - return false unless line.match?(/\bimport\s*\(/) + def starts_pending_multiline_module_call?(line) + return false unless line.match?(/\b(?:import|require)\s*\(/) - !line.match?(%r{\bimport\s*\(\s*(?:/\*[^*]*\*+(?:[^/*][^*]*\*+)*/\s*)*["']}) + !line.match?(%r{\b(?:import|require)\s*\(\s*(?:/\*[^*]*\*+(?:[^/*][^*]*\*+)*/\s*)*["']}) end - def rewrite_pending_dynamic_import_specifier(line) + def rewrite_pending_module_specifier(line) line.sub(%r{(?["'])react-on-rails(?!-pro)(?=(?:["']|/))}) do "#{Regexp.last_match[:quote]}react-on-rails-pro" end end - def import_call_closes_on_line?(line) + def module_call_closes_on_line?(line) line_without_strings = line.gsub( /"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|`(?:\\.|[^`\\])*`/, "" diff --git a/react_on_rails/spec/react_on_rails/generators/pro_generator_spec.rb b/react_on_rails/spec/react_on_rails/generators/pro_generator_spec.rb index acfc59958b..02c1ad4937 100644 --- a/react_on_rails/spec/react_on_rails/generators/pro_generator_spec.rb +++ b/react_on_rails/spec/react_on_rails/generators/pro_generator_spec.rb @@ -398,6 +398,10 @@ /* webpackMode: "lazy" */ "react-on-rails/client" ); + const lazyRorRequire = require( + "react-on-rails/server" + ); + /* short comment */ import InlineReactOnRails from "react-on-rails"; const keepRor = require("react-on-rails"); // /* not a block comment start const commentLikeString = "/* not a JS comment"; import ReactOnRailsServer from "react-on-rails/server"; @@ -433,6 +437,10 @@ expect(File.read(application_js_path)).to include('require("react-on-rails-pro")') expect(File.read(application_js_path)).to include('import(/* webpackChunkName: "ror" */ "react-on-rails-pro")') expect(File.read(application_js_path)).to include('"react-on-rails-pro/client"') + expect(File.read(application_js_path)).to include('"react-on-rails-pro/server"') + expect(File.read(application_js_path)).to include( + '/* short comment */ import InlineReactOnRails from "react-on-rails-pro";' + ) expect(File.read(application_js_path)).to include('import ReactOnRailsServer from "react-on-rails-pro/server";') expect(File.read(application_js_path)).to include('import ReactOnRailsClient from "react-on-rails-pro/client";') expect(File.read(application_js_path)).to include('import "react-on-rails-pro";') From 88fe947eb78bd68d96059f495e99d9e673fbb939 Mon Sep 17 00:00:00 2001 From: Justin Gordon Date: Mon, 23 Mar 2026 23:28:34 -1000 Subject: [PATCH 16/37] Cover Pro generator multiline and rewrite edge cases --- .../react_on_rails/pro_generator.rb | 142 ++++++++++++++---- .../generators/pro_generator_spec.rb | 38 +++++ 2 files changed, 150 insertions(+), 30 deletions(-) diff --git a/react_on_rails/lib/generators/react_on_rails/pro_generator.rb b/react_on_rails/lib/generators/react_on_rails/pro_generator.rb index cdc1ec2a22..19e3470667 100644 --- a/react_on_rails/lib/generators/react_on_rails/pro_generator.rb +++ b/react_on_rails/lib/generators/react_on_rails/pro_generator.rb @@ -100,6 +100,21 @@ def swap_base_gem_for_pro_in_gemfile while line_index < gemfile_lines.length line = gemfile_lines[line_index] + multiline_parenthesized_match = match_multiline_parenthesized_base_gem(gemfile_lines, line_index) + + if multiline_parenthesized_match + unless pro_entry_added + indentation = multiline_parenthesized_match[:indentation] + quote = multiline_parenthesized_match[:quote] + updated_lines << "#{indentation}gem #{quote}react_on_rails_pro#{quote}, " \ + "#{quote}~> #{recommended_pro_gem_version}#{quote}\n" + pro_entry_added = true + end + + line_index = multiline_parenthesized_match[:next_index] + next + end + match = line.match(base_gem_pattern) unless match @@ -256,11 +271,20 @@ def js_files_for_import_update end def rewrite_react_on_rails_module_specifiers(content) - module_specifier_pattern = %r{ + static_import_specifier_pattern = %r{ + (? + \A\s*(?:/\*.*?\*/\s*)?import(?:\s+type)?\s+.*?\s+from\s+| + \A\s*[\w\}\],\*\$\s]+\s+from\s+ + ) + (?["']) + react-on-rails(?!-pro) + (?=(?:["']|/)) + }x + + dynamic_or_require_specifier_pattern = %r{ (? - \bfrom\s+| - \bimport\s*\(\s*(?:/\*[^*]*\*+(?:[^/*][^*]*\*+)*/\s*)*| - \brequire\s*\(\s* + (?["']) react-on-rails(?!-pro) @@ -275,7 +299,11 @@ def rewrite_react_on_rails_module_specifiers(content) }x rewrite_non_comment_lines(content) do |line| - rewritten_line = line.gsub(module_specifier_pattern) do + rewritten_line = line.gsub(static_import_specifier_pattern) do + "#{Regexp.last_match[:prefix]}#{Regexp.last_match[:quote]}react-on-rails-pro" + end + + rewritten_line = rewritten_line.gsub(dynamic_or_require_specifier_pattern) do "#{Regexp.last_match[:prefix]}#{Regexp.last_match[:quote]}react-on-rails-pro" end @@ -320,10 +348,10 @@ def add_gemfile_update_warning(gemfile_path, error) MSG end - # rubocop:disable Metrics/AbcSize, Metrics/BlockLength, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity + # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity def rewrite_non_comment_lines(content) in_block_comment = false - pending_multiline_module_call = false + pending_multiline_module_call_depth = 0 content.lines.map do |line| stripped = line.lstrip @@ -334,12 +362,8 @@ def rewrite_non_comment_lines(content) elsif stripped.start_with?("/*") if stripped.include?("*/") rewritten_line = yield line - if pending_multiline_module_call - rewritten_line = rewrite_pending_module_specifier(rewritten_line) - pending_multiline_module_call = !module_call_closes_on_line?(rewritten_line) - elsif starts_pending_multiline_module_call?(rewritten_line) - pending_multiline_module_call = true - end + rewritten_line, pending_multiline_module_call_depth = + update_pending_multiline_module_call_tracking(rewritten_line, pending_multiline_module_call_depth) in_block_comment = true if unclosed_block_comment_starts?(line) rewritten_line else @@ -350,25 +374,17 @@ def rewrite_non_comment_lines(content) line else rewritten_line = yield line - if pending_multiline_module_call - rewritten_line = rewrite_pending_module_specifier(rewritten_line) - pending_multiline_module_call = !module_call_closes_on_line?(rewritten_line) - elsif starts_pending_multiline_module_call?(rewritten_line) - pending_multiline_module_call = true - end + rewritten_line, pending_multiline_module_call_depth = + update_pending_multiline_module_call_tracking(rewritten_line, pending_multiline_module_call_depth) in_block_comment = true if unclosed_block_comment_starts?(line) rewritten_line end end.join end - # rubocop:enable Metrics/AbcSize, Metrics/BlockLength, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity + # rubocop:enable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity def unclosed_block_comment_starts?(line) - line_without_strings = line.gsub( - /"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|`(?:\\.|[^`\\])*`/, - "" - ) - line_without_inline_comment = line_without_strings.sub(%r{//.*$}, "") + line_without_inline_comment = line_without_string_literals_and_inline_comments(line) opening_index = line_without_inline_comment.index("/*") return false unless opening_index @@ -376,9 +392,10 @@ def unclosed_block_comment_starts?(line) end def starts_pending_multiline_module_call?(line) - return false unless line.match?(/\b(?:import|require)\s*\(/) + line_without_literals = line_without_string_literals_and_inline_comments(line) + return false unless line_without_literals.match?(/(? 16.0" + ) + RUBY + allow(generator).to receive(:bundle_install_after_gem_swap) + + generator.send(:swap_base_gem_for_pro_in_gemfile) + + gemfile_content = File.read(gemfile_path) + expected_version = Gem::Version.new(ReactOnRails::VERSION).release.to_s + expect(gemfile_content).to include("gem \"react_on_rails_pro\", \"~> #{expected_version}\"") + expect(gemfile_content).not_to include('"react_on_rails"') + expect(gemfile_content).not_to include('"~> 16.0"') + expect(generator).to have_received(:bundle_install_after_gem_swap) + end + it "removes base gem without adding duplicate react_on_rails_pro entries" do simulate_existing_file("Gemfile", <<~RUBY) source "https://rubygems.org" @@ -410,6 +430,8 @@ import CustomPackage from "react-on-rails-utils"; const scoped = "@scope/react-on-rails"; const url = "https://cdn.example.com/react-on-rails/client.js"; + const importTemplate = 'import("react-on-rails")'; + const fromTemplate = "import Example from \\"react-on-rails\\""; // import "react-on-rails"; /* * import ReactOnRails from "react-on-rails"; @@ -447,6 +469,10 @@ expect(File.read(application_js_path)).to include('import CustomPackage from "react-on-rails-utils";') expect(File.read(application_js_path)).to include('const scoped = "@scope/react-on-rails";') expect(File.read(application_js_path)).to include('const url = "https://cdn.example.com/react-on-rails/client.js";') + expect(File.read(application_js_path)).to include('const importTemplate = \'import("react-on-rails")\';') + expect(File.read(application_js_path)).to include( + 'const fromTemplate = "import Example from \\"react-on-rails\\"";' + ) expect(File.read(application_js_path)).to include('// import "react-on-rails";') expect(File.read(application_js_path)).to include('* import ReactOnRails from "react-on-rails";') expect(File.read(server_js_path)).to include('import ReactOnRails from "react-on-rails-pro";') @@ -478,6 +504,18 @@ expect(rewritten).to include('"react-on-rails-pro/client"') end + + it "keeps multiline module-call tracking active when wrapper parens close later" do + source = <<~JS + const wrappedLazyRor = someWrapper(import( + "react-on-rails" + )); + JS + + rewritten = generator.send(:rewrite_react_on_rails_module_specifiers, source) + + expect(rewritten).to include('"react-on-rails-pro"') + end end # Integration test for standalone happy path From d68f7db72d589e321d2978e6e455a091a2db2abe Mon Sep 17 00:00:00 2001 From: Justin Gordon Date: Mon, 23 Mar 2026 23:33:00 -1000 Subject: [PATCH 17/37] Handle block-comment and multiline static import rewrites --- .../react_on_rails/pro_generator.rb | 72 +++++++++++++++++-- .../generators/pro_generator_spec.rb | 25 +++++++ 2 files changed, 93 insertions(+), 4 deletions(-) diff --git a/react_on_rails/lib/generators/react_on_rails/pro_generator.rb b/react_on_rails/lib/generators/react_on_rails/pro_generator.rb index 19e3470667..8053558dad 100644 --- a/react_on_rails/lib/generators/react_on_rails/pro_generator.rb +++ b/react_on_rails/lib/generators/react_on_rails/pro_generator.rb @@ -348,20 +348,38 @@ def add_gemfile_update_warning(gemfile_path, error) MSG end - # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity + # rubocop:disable Metrics/AbcSize, Metrics/BlockLength, Metrics/CyclomaticComplexity, Metrics/MethodLength + # rubocop:disable Metrics/PerceivedComplexity, Style/ExplicitBlockArgument def rewrite_non_comment_lines(content) in_block_comment = false pending_multiline_module_call_depth = 0 + pending_multiline_static_import_specifier = false content.lines.map do |line| stripped = line.lstrip if in_block_comment - in_block_comment = false if stripped.include?("*/") - line + if stripped.include?("*/") + in_block_comment = false + rewritten_line, pending_multiline_module_call_depth, pending_multiline_static_import_specifier = + rewrite_line_after_block_comment_close( + line, + pending_multiline_module_call_depth, + pending_multiline_static_import_specifier + ) { |line_fragment| yield line_fragment } + in_block_comment = true if unclosed_block_comment_starts?(rewritten_line) + rewritten_line + else + line + end elsif stripped.start_with?("/*") if stripped.include?("*/") rewritten_line = yield line + rewritten_line, pending_multiline_static_import_specifier = + update_pending_multiline_static_import_tracking( + rewritten_line, + pending_multiline_static_import_specifier + ) rewritten_line, pending_multiline_module_call_depth = update_pending_multiline_module_call_tracking(rewritten_line, pending_multiline_module_call_depth) in_block_comment = true if unclosed_block_comment_starts?(line) @@ -374,6 +392,11 @@ def rewrite_non_comment_lines(content) line else rewritten_line = yield line + rewritten_line, pending_multiline_static_import_specifier = + update_pending_multiline_static_import_tracking( + rewritten_line, + pending_multiline_static_import_specifier + ) rewritten_line, pending_multiline_module_call_depth = update_pending_multiline_module_call_tracking(rewritten_line, pending_multiline_module_call_depth) in_block_comment = true if unclosed_block_comment_starts?(line) @@ -381,7 +404,8 @@ def rewrite_non_comment_lines(content) end end.join end - # rubocop:enable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity + # rubocop:enable Metrics/PerceivedComplexity, Style/ExplicitBlockArgument + # rubocop:enable Metrics/AbcSize, Metrics/BlockLength, Metrics/CyclomaticComplexity, Metrics/MethodLength def unclosed_block_comment_starts?(line) line_without_inline_comment = line_without_string_literals_and_inline_comments(line) @@ -418,6 +442,31 @@ def update_pending_multiline_module_call_tracking(line, pending_depth) end end + def update_pending_multiline_static_import_tracking(line, pending_multiline_static_import_specifier) + rewritten_line = line + if pending_multiline_static_import_specifier + rewritten_line = rewrite_pending_module_specifier(rewritten_line) + pending_multiline_static_import_specifier = false + end + + if starts_pending_multiline_static_import_specifier?(rewritten_line) + pending_multiline_static_import_specifier = true + end + + [rewritten_line, pending_multiline_static_import_specifier] + end + + def starts_pending_multiline_static_import_specifier?(line) + line_without_literals = line_without_string_literals_and_inline_comments(line) + return false unless line_without_literals.match?( + /\A\s*(?:import(?:\s+type)?\b.*\bfrom|import|[\w\}\],\*\$\s]+\s+from)\s*\z/ + ) + return false if line.match?(%r{\bfrom\s*(?:/\*[^*]*\*+(?:[^/*][^*]*\*+)*/\s*)*["']}) + return false if line.match?(%r{\A\s*import\s*(?:/\*[^*]*\*+(?:[^/*][^*]*\*+)*/\s*)*["']}) + + true + end + def module_call_parenthesis_delta(line, from_module_call_start: false) line_without_literals = line_without_string_literals_and_inline_comments(line) line_to_measure = if from_module_call_start @@ -437,6 +486,21 @@ def line_without_string_literals_and_inline_comments(line) line_without_strings.sub(%r{//.*$}, "") end + def rewrite_line_after_block_comment_close(line, pending_depth, pending_multiline_static_import_specifier) + closing_index = line.index("*/") + return [line, pending_depth, pending_multiline_static_import_specifier] unless closing_index + return [line, pending_depth, pending_multiline_static_import_specifier] if closing_index >= line.length - 2 + + comment_prefix = line[0, closing_index + 2] + line_fragment = line[(closing_index + 2)..] + rewritten_fragment = yield line_fragment + rewritten_fragment, pending_multiline_static_import_specifier = + update_pending_multiline_static_import_tracking(rewritten_fragment, pending_multiline_static_import_specifier) + rewritten_fragment, pending_depth = + update_pending_multiline_module_call_tracking(rewritten_fragment, pending_depth) + ["#{comment_prefix}#{rewritten_fragment}", pending_depth, pending_multiline_static_import_specifier] + end + # rubocop:disable Metrics/CyclomaticComplexity def match_multiline_parenthesized_base_gem(lines, start_index) start_line = lines[start_index] diff --git a/react_on_rails/spec/react_on_rails/generators/pro_generator_spec.rb b/react_on_rails/spec/react_on_rails/generators/pro_generator_spec.rb index 655206a63e..74270d01ca 100644 --- a/react_on_rails/spec/react_on_rails/generators/pro_generator_spec.rb +++ b/react_on_rails/spec/react_on_rails/generators/pro_generator_spec.rb @@ -516,6 +516,31 @@ expect(rewritten).to include('"react-on-rails-pro"') end + + it "rewrites imports that appear after a block comment closes on the same line" do + source = <<~JS + /* + * explanatory comment + */ import ReactOnRails from "react-on-rails"; + JS + + rewritten = generator.send(:rewrite_react_on_rails_module_specifiers, source) + + expect(rewritten).to include('*/ import ReactOnRails from "react-on-rails-pro";') + end + + it "rewrites multiline static imports when from and module specifier are on separate lines" do + source = <<~JS + import { + ReactOnRailsComponent + } from + "react-on-rails"; + JS + + rewritten = generator.send(:rewrite_react_on_rails_module_specifiers, source) + + expect(rewritten).to include('"react-on-rails-pro";') + end end # Integration test for standalone happy path From eed2fb0d4e124ebd88aa5ae827cfe4b1b35e535b Mon Sep 17 00:00:00 2001 From: Justin Gordon Date: Tue, 24 Mar 2026 00:04:28 -1000 Subject: [PATCH 18/37] Preserve Gemfile metadata in Pro swap edge cases --- .../react_on_rails/pro_generator.rb | 71 +++++++++++++++---- .../generators/pro_generator_spec.rb | 55 ++++++++++++++ 2 files changed, 111 insertions(+), 15 deletions(-) diff --git a/react_on_rails/lib/generators/react_on_rails/pro_generator.rb b/react_on_rails/lib/generators/react_on_rails/pro_generator.rb index 8053558dad..8ec7ec39cf 100644 --- a/react_on_rails/lib/generators/react_on_rails/pro_generator.rb +++ b/react_on_rails/lib/generators/react_on_rails/pro_generator.rb @@ -106,8 +106,11 @@ def swap_base_gem_for_pro_in_gemfile unless pro_entry_added indentation = multiline_parenthesized_match[:indentation] quote = multiline_parenthesized_match[:quote] - updated_lines << "#{indentation}gem #{quote}react_on_rails_pro#{quote}, " \ - "#{quote}~> #{recommended_pro_gem_version}#{quote}\n" + updated_lines << build_pro_gem_replacement_line( + indentation: indentation, + quote: quote, + suffix: multiline_parenthesized_match[:trailing_suffix] + ) pro_entry_added = true end @@ -126,8 +129,11 @@ def swap_base_gem_for_pro_in_gemfile unless pro_entry_added indentation = match[1] quote = match[2] - updated_lines << "#{indentation}gem #{quote}react_on_rails_pro#{quote}, " \ - "#{quote}~> #{recommended_pro_gem_version}#{quote}\n" + updated_lines << build_pro_gem_replacement_line( + indentation: indentation, + quote: quote, + suffix: line[match.end(0)..] + ) pro_entry_added = true end @@ -164,6 +170,7 @@ def swap_base_gem_for_pro_in_gemfile # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity def atomic_write_file(path, content) + original_mode = File.file?(path) ? File.stat(path).mode & 0o777 : nil temp_file = Tempfile.new([File.basename(path), ".tmp"], File.dirname(path)) temp_path = temp_file.path @@ -172,6 +179,7 @@ def atomic_write_file(path, content) temp_file.fsync temp_file.close FileUtils.mv(temp_path, path) + File.chmod(original_mode, path) if original_mode temp_path = nil ensure temp_file&.close unless temp_file&.closed? @@ -409,10 +417,25 @@ def rewrite_non_comment_lines(content) def unclosed_block_comment_starts?(line) line_without_inline_comment = line_without_string_literals_and_inline_comments(line) - opening_index = line_without_inline_comment.index("/*") - return false unless opening_index + comment_balance = 0 + scan_index = 0 + + while scan_index < line_without_inline_comment.length + next_opening = line_without_inline_comment.index("/*", scan_index) + next_closing = line_without_inline_comment.index("*/", scan_index) + + break unless next_opening || next_closing - line_without_inline_comment.index("*/", opening_index + 2).nil? + if next_opening && (!next_closing || next_opening < next_closing) + comment_balance += 1 + scan_index = next_opening + 2 + else + comment_balance -= 1 if comment_balance.positive? + scan_index = next_closing + 2 + end + end + + comment_balance.positive? end def starts_pending_multiline_module_call?(line) @@ -501,7 +524,7 @@ def rewrite_line_after_block_comment_close(line, pending_depth, pending_multilin ["#{comment_prefix}#{rewritten_fragment}", pending_depth, pending_multiline_static_import_specifier] end - # rubocop:disable Metrics/CyclomaticComplexity + # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity def match_multiline_parenthesized_base_gem(lines, start_index) start_line = lines[start_index] start_match = start_line.match(/^(\s*)gem\s*\(\s*(?:#.*)?$/) @@ -513,18 +536,27 @@ def match_multiline_parenthesized_base_gem(lines, start_index) while line_index < lines.length line = lines[line_index] - - if line.include?(")") - return nil unless found_base_gem_name - - return { indentation: start_match[1], quote: base_gem_quote, next_index: line_index + 1 } - end + line_without_comment = line.sub(/\s*#.*$/, "") if comment_or_blank_line?(line) line_index += 1 next end + if line_without_comment.include?(")") + return nil unless found_base_gem_name + + closing_index = line_without_comment.index(")") + trailing_suffix = line[(closing_index + 1)..] + trailing_suffix = "\n" if trailing_suffix.nil? || trailing_suffix.empty? + return { + indentation: start_match[1], + quote: base_gem_quote, + next_index: line_index + 1, + trailing_suffix: trailing_suffix + } + end + if !found_base_gem_name && (gem_name_match = line.match(/^\s*(["'])react_on_rails\1(?=\s*(?:,|\)|#|$))/)) found_base_gem_name = true @@ -540,7 +572,16 @@ def match_multiline_parenthesized_base_gem(lines, start_index) nil end - # rubocop:enable Metrics/CyclomaticComplexity + # rubocop:enable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity + + def build_pro_gem_replacement_line(indentation:, quote:, suffix:) + normalized_suffix = suffix || "\n" + normalized_suffix = "#{normalized_suffix}\n" unless normalized_suffix.end_with?("\n") + normalized_suffix = normalized_suffix.sub(/\A\s*,\s*["'][^"']*["']/, "") + + "#{indentation}gem #{quote}react_on_rails_pro#{quote}, " \ + "#{quote}~> #{recommended_pro_gem_version}#{quote}#{normalized_suffix}" + end def print_success_message route = if File.exist?(File.join(destination_root, "app/controllers/hello_server_controller.rb")) diff --git a/react_on_rails/spec/react_on_rails/generators/pro_generator_spec.rb b/react_on_rails/spec/react_on_rails/generators/pro_generator_spec.rb index 74270d01ca..b7766bbc17 100644 --- a/react_on_rails/spec/react_on_rails/generators/pro_generator_spec.rb +++ b/react_on_rails/spec/react_on_rails/generators/pro_generator_spec.rb @@ -80,6 +80,22 @@ expect(generator).to have_received(:bundle_install_after_gem_swap) end + it "preserves trailing Gemfile guards and options on replaced entries" do + simulate_existing_file("Gemfile", <<~RUBY) + source "https://rubygems.org" + gem "react_on_rails", "~> 16.0", require: false, if: ENV["ENABLE_ROR"] + RUBY + allow(generator).to receive(:bundle_install_after_gem_swap) + + generator.send(:swap_base_gem_for_pro_in_gemfile) + + gemfile_content = File.read(gemfile_path) + expected_version = Gem::Version.new(ReactOnRails::VERSION).release.to_s + expect(gemfile_content).to include("gem \"react_on_rails_pro\", \"~> #{expected_version}\", require: false") + expect(gemfile_content).to include("if: ENV[\"ENABLE_ROR\"]") + expect(gemfile_content).not_to include("gem \"react_on_rails\",") + end + it "preserves indentation when replacing a grouped Gemfile entry" do simulate_existing_file("Gemfile", <<~RUBY) source "https://rubygems.org" @@ -255,6 +271,26 @@ expect(generator).to have_received(:bundle_install_after_gem_swap) end + it "handles comments containing closing parentheses inside multiline parenthesized declarations" do + simulate_existing_file("Gemfile", <<~RUBY) + source "https://rubygems.org" + gem( + "react_on_rails", + # pinned :) + "~> 16.0" + ) + RUBY + allow(generator).to receive(:bundle_install_after_gem_swap) + + generator.send(:swap_base_gem_for_pro_in_gemfile) + + gemfile_content = File.read(gemfile_path) + expected_version = Gem::Version.new(ReactOnRails::VERSION).release.to_s + expect(gemfile_content).to include("gem \"react_on_rails_pro\", \"~> #{expected_version}\"") + expect(gemfile_content).not_to include('"~> 16.0"') + expect(gemfile_content).not_to include("gem(") + end + it "removes base gem without adding duplicate react_on_rails_pro entries" do simulate_existing_file("Gemfile", <<~RUBY) source "https://rubygems.org" @@ -348,6 +384,19 @@ expect(File.read(gemfile_path)).to eq(original_content) end + + it "preserves Gemfile file mode when writing updates" do + simulate_existing_file("Gemfile", <<~RUBY) + source "https://rubygems.org" + gem "react_on_rails", "~> 16.0" + RUBY + allow(generator).to receive(:bundle_install_after_gem_swap) + File.chmod(0o644, gemfile_path) + + generator.send(:swap_base_gem_for_pro_in_gemfile) + + expect(File.stat(gemfile_path).mode & 0o777).to eq(0o644) + end end describe "#bundle_install_after_gem_swap" do @@ -541,6 +590,12 @@ expect(rewritten).to include('"react-on-rails-pro";') end + + it "detects unclosed block comments when multiple block markers appear on one line" do + source_line = "/* closed */ const keep = true; /* unclosed" + + expect(generator.send(:unclosed_block_comment_starts?, source_line)).to be true + end end # Integration test for standalone happy path From 07b9593720b02f5985cdccbd7342fecbf5af0e25 Mon Sep 17 00:00:00 2001 From: Justin Gordon Date: Tue, 24 Mar 2026 00:15:22 -1000 Subject: [PATCH 19/37] Fix parenthesized Gemfile swap edge cases --- .../react_on_rails/pro_generator.rb | 21 +++++++---- .../generators/pro_generator_spec.rb | 37 +++++++++++++++++++ 2 files changed, 51 insertions(+), 7 deletions(-) diff --git a/react_on_rails/lib/generators/react_on_rails/pro_generator.rb b/react_on_rails/lib/generators/react_on_rails/pro_generator.rb index 8ec7ec39cf..dc745f7cec 100644 --- a/react_on_rails/lib/generators/react_on_rails/pro_generator.rb +++ b/react_on_rails/lib/generators/react_on_rails/pro_generator.rb @@ -109,7 +109,8 @@ def swap_base_gem_for_pro_in_gemfile updated_lines << build_pro_gem_replacement_line( indentation: indentation, quote: quote, - suffix: multiline_parenthesized_match[:trailing_suffix] + suffix: multiline_parenthesized_match[:trailing_suffix], + parenthesized_gem_call: true ) pro_entry_added = true end @@ -132,7 +133,8 @@ def swap_base_gem_for_pro_in_gemfile updated_lines << build_pro_gem_replacement_line( indentation: indentation, quote: quote, - suffix: line[match.end(0)..] + suffix: line[match.end(0)..], + parenthesized_gem_call: match[0].include?("(") ) pro_entry_added = true end @@ -524,7 +526,7 @@ def rewrite_line_after_block_comment_close(line, pending_depth, pending_multilin ["#{comment_prefix}#{rewritten_fragment}", pending_depth, pending_multiline_static_import_specifier] end - # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity + # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity def match_multiline_parenthesized_base_gem(lines, start_index) start_line = lines[start_index] start_match = start_line.match(/^(\s*)gem\s*\(\s*(?:#.*)?$/) @@ -533,20 +535,24 @@ def match_multiline_parenthesized_base_gem(lines, start_index) line_index = start_index + 1 found_base_gem_name = false base_gem_quote = nil + paren_depth = 1 while line_index < lines.length line = lines[line_index] line_without_comment = line.sub(/\s*#.*$/, "") + line_without_literals = line_without_string_literals_and_inline_comments(line) if comment_or_blank_line?(line) line_index += 1 next end - if line_without_comment.include?(")") + paren_depth += line_without_literals.count("(") - line_without_literals.count(")") + + if paren_depth <= 0 return nil unless found_base_gem_name - closing_index = line_without_comment.index(")") + closing_index = line_without_comment.rindex(")") trailing_suffix = line[(closing_index + 1)..] trailing_suffix = "\n" if trailing_suffix.nil? || trailing_suffix.empty? return { @@ -572,12 +578,13 @@ def match_multiline_parenthesized_base_gem(lines, start_index) nil end - # rubocop:enable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity + # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity - def build_pro_gem_replacement_line(indentation:, quote:, suffix:) + def build_pro_gem_replacement_line(indentation:, quote:, suffix:, parenthesized_gem_call: false) normalized_suffix = suffix || "\n" normalized_suffix = "#{normalized_suffix}\n" unless normalized_suffix.end_with?("\n") normalized_suffix = normalized_suffix.sub(/\A\s*,\s*["'][^"']*["']/, "") + normalized_suffix = normalized_suffix.sub(/\)(\s*(?:#.*)?\n)\z/, '\1') if parenthesized_gem_call "#{indentation}gem #{quote}react_on_rails_pro#{quote}, " \ "#{quote}~> #{recommended_pro_gem_version}#{quote}#{normalized_suffix}" diff --git a/react_on_rails/spec/react_on_rails/generators/pro_generator_spec.rb b/react_on_rails/spec/react_on_rails/generators/pro_generator_spec.rb index b7766bbc17..230db19ae9 100644 --- a/react_on_rails/spec/react_on_rails/generators/pro_generator_spec.rb +++ b/react_on_rails/spec/react_on_rails/generators/pro_generator_spec.rb @@ -251,6 +251,22 @@ expect(generator).to have_received(:bundle_install_after_gem_swap) end + it "replaces parenthesized Gemfile declarations without leaving trailing parentheses" do + simulate_existing_file("Gemfile", <<~RUBY) + source "https://rubygems.org" + gem("react_on_rails", "~> 16.0", require: false, if: ENV.fetch("ENABLE_ROR", false)) + RUBY + allow(generator).to receive(:bundle_install_after_gem_swap) + + generator.send(:swap_base_gem_for_pro_in_gemfile) + + gemfile_content = File.read(gemfile_path) + expected_version = Gem::Version.new(ReactOnRails::VERSION).release.to_s + expect(gemfile_content).to include("gem \"react_on_rails_pro\", \"~> #{expected_version}\", require: false") + expect(gemfile_content).to include('if: ENV.fetch("ENABLE_ROR", false)') + expect(gemfile_content).not_to include("false))") + end + it "replaces multiline parenthesized Gemfile declarations" do simulate_existing_file("Gemfile", <<~RUBY) source "https://rubygems.org" @@ -291,6 +307,27 @@ expect(gemfile_content).not_to include("gem(") end + it "handles nested parentheses in multiline parenthesized Gemfile options" do + simulate_existing_file("Gemfile", <<~RUBY) + source "https://rubygems.org" + gem( + "react_on_rails", + "~> 16.0", + if: ENV.fetch("ENABLE_ROR") { true } + ) + RUBY + allow(generator).to receive(:bundle_install_after_gem_swap) + + generator.send(:swap_base_gem_for_pro_in_gemfile) + + gemfile_content = File.read(gemfile_path) + expected_version = Gem::Version.new(ReactOnRails::VERSION).release.to_s + expect(gemfile_content).to include("gem \"react_on_rails_pro\", \"~> #{expected_version}\"") + expect(gemfile_content).not_to include('"react_on_rails"') + expect(gemfile_content).not_to include('if: ENV.fetch("ENABLE_ROR") { true }') + expect(gemfile_content).not_to include("gem(") + end + it "removes base gem without adding duplicate react_on_rails_pro entries" do simulate_existing_file("Gemfile", <<~RUBY) source "https://rubygems.org" From bd71df2aa40c8ff1232dcc0e4ee023deae904a92 Mon Sep 17 00:00:00 2001 From: Justin Gordon Date: Tue, 24 Mar 2026 00:16:56 -1000 Subject: [PATCH 20/37] Assert no orphan parenthesis in Gemfile rewrite --- .../spec/react_on_rails/generators/pro_generator_spec.rb | 1 + 1 file changed, 1 insertion(+) diff --git a/react_on_rails/spec/react_on_rails/generators/pro_generator_spec.rb b/react_on_rails/spec/react_on_rails/generators/pro_generator_spec.rb index 230db19ae9..bb91f6c90a 100644 --- a/react_on_rails/spec/react_on_rails/generators/pro_generator_spec.rb +++ b/react_on_rails/spec/react_on_rails/generators/pro_generator_spec.rb @@ -247,6 +247,7 @@ gemfile_content = File.read(gemfile_path) expected_version = Gem::Version.new(ReactOnRails::VERSION).release.to_s expect(gemfile_content).to include("gem \"react_on_rails_pro\", \"~> #{expected_version}\"") + expect(gemfile_content).not_to include("gem \"react_on_rails_pro\", \"~> #{expected_version}\")") expect(gemfile_content).not_to include('gem("react_on_rails"') expect(generator).to have_received(:bundle_install_after_gem_swap) end From 88ca44c970c4bd4f74eae406fecb452d52c1400e Mon Sep 17 00:00:00 2001 From: Justin Gordon Date: Tue, 24 Mar 2026 00:41:45 -1000 Subject: [PATCH 21/37] Harden Pro gem swap parsing and import rewrite guards --- .../react_on_rails/pro_generator.rb | 54 +++++++++++++--- .../generators/pro_generator_spec.rb | 64 ++++++++++++++++++- 2 files changed, 107 insertions(+), 11 deletions(-) diff --git a/react_on_rails/lib/generators/react_on_rails/pro_generator.rb b/react_on_rails/lib/generators/react_on_rails/pro_generator.rb index dc745f7cec..28d296cc0c 100644 --- a/react_on_rails/lib/generators/react_on_rails/pro_generator.rb +++ b/react_on_rails/lib/generators/react_on_rails/pro_generator.rb @@ -362,13 +362,19 @@ def add_gemfile_update_warning(gemfile_path, error) # rubocop:disable Metrics/PerceivedComplexity, Style/ExplicitBlockArgument def rewrite_non_comment_lines(content) in_block_comment = false + in_multiline_template_literal = false pending_multiline_module_call_depth = 0 pending_multiline_static_import_specifier = false content.lines.map do |line| stripped = line.lstrip + line_contains_unescaped_backtick = line_has_unescaped_backtick?(line) - if in_block_comment + if in_multiline_template_literal || line_contains_unescaped_backtick + in_multiline_template_literal = + update_multiline_template_literal_state(in_multiline_template_literal, line) + line + elsif in_block_comment if stripped.include?("*/") in_block_comment = false rewritten_line, pending_multiline_module_call_depth, pending_multiline_static_import_specifier = @@ -503,12 +509,28 @@ def module_call_parenthesis_delta(line, from_module_call_start: false) line_to_measure.count("(") - line_to_measure.count(")") end - def line_without_string_literals_and_inline_comments(line) + def line_without_string_literals_and_inline_comments(line, strip_ruby_comments: false) line_without_strings = line.gsub( /"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|`(?:\\.|[^`\\])*`/, "" ) - line_without_strings.sub(%r{//.*$}, "") + line_without_comments = line_without_strings.sub(%r{//.*$}, "") + return line_without_comments unless strip_ruby_comments + + line_without_comments.sub(/\s*#.*$/, "") + end + + def line_has_unescaped_backtick?(line) + update_multiline_template_literal_state(false, line) + end + + def update_multiline_template_literal_state(in_multiline_template_literal, line) + backticks = line.each_char.with_index.count do |char, index| + char == "`" && (index.zero? || line[index - 1] != "\\") + end + return in_multiline_template_literal if backticks.even? + + !in_multiline_template_literal end def rewrite_line_after_block_comment_close(line, pending_depth, pending_multiline_static_import_specifier) @@ -526,7 +548,7 @@ def rewrite_line_after_block_comment_close(line, pending_depth, pending_multilin ["#{comment_prefix}#{rewritten_fragment}", pending_depth, pending_multiline_static_import_specifier] end - # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity + # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity def match_multiline_parenthesized_base_gem(lines, start_index) start_line = lines[start_index] start_match = start_line.match(/^(\s*)gem\s*\(\s*(?:#.*)?$/) @@ -535,12 +557,14 @@ def match_multiline_parenthesized_base_gem(lines, start_index) line_index = start_index + 1 found_base_gem_name = false base_gem_quote = nil + gem_name_line_index = nil + gem_name_match_end = nil paren_depth = 1 while line_index < lines.length line = lines[line_index] line_without_comment = line.sub(/\s*#.*$/, "") - line_without_literals = line_without_string_literals_and_inline_comments(line) + line_without_literals = line_without_string_literals_and_inline_comments(line, strip_ruby_comments: true) if comment_or_blank_line?(line) line_index += 1 @@ -553,13 +577,16 @@ def match_multiline_parenthesized_base_gem(lines, start_index) return nil unless found_base_gem_name closing_index = line_without_comment.rindex(")") - trailing_suffix = line[(closing_index + 1)..] - trailing_suffix = "\n" if trailing_suffix.nil? || trailing_suffix.empty? + return nil unless closing_index + + declaration_fragment = lines[gem_name_line_index..line_index].join + suffix = declaration_fragment[gem_name_match_end..] + suffix = "\n" if suffix.nil? || suffix.empty? return { indentation: start_match[1], quote: base_gem_quote, next_index: line_index + 1, - trailing_suffix: trailing_suffix + trailing_suffix: suffix } end @@ -567,6 +594,8 @@ def match_multiline_parenthesized_base_gem(lines, start_index) (gem_name_match = line.match(/^\s*(["'])react_on_rails\1(?=\s*(?:,|\)|#|$))/)) found_base_gem_name = true base_gem_quote = gem_name_match[1] + gem_name_line_index = line_index + gem_name_match_end = gem_name_match.end(0) line_index += 1 next end @@ -578,12 +607,17 @@ def match_multiline_parenthesized_base_gem(lines, start_index) nil end - # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity + # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity def build_pro_gem_replacement_line(indentation:, quote:, suffix:, parenthesized_gem_call: false) normalized_suffix = suffix || "\n" normalized_suffix = "#{normalized_suffix}\n" unless normalized_suffix.end_with?("\n") - normalized_suffix = normalized_suffix.sub(/\A\s*,\s*["'][^"']*["']/, "") + normalized_suffix = normalized_suffix.sub( + /\A(?\s*,(?:\s*#.*\n|\s+)*)["'][^"']*["'](?\s*,)?/ + ) do + Regexp.last_match[:trailing_comma] ? Regexp.last_match[:prefix] : "" + end + normalized_suffix = normalized_suffix.sub(/\A,[ \t]{2,}/, ", ") normalized_suffix = normalized_suffix.sub(/\)(\s*(?:#.*)?\n)\z/, '\1') if parenthesized_gem_call "#{indentation}gem #{quote}react_on_rails_pro#{quote}, " \ diff --git a/react_on_rails/spec/react_on_rails/generators/pro_generator_spec.rb b/react_on_rails/spec/react_on_rails/generators/pro_generator_spec.rb index bb91f6c90a..afcde78c2c 100644 --- a/react_on_rails/spec/react_on_rails/generators/pro_generator_spec.rb +++ b/react_on_rails/spec/react_on_rails/generators/pro_generator_spec.rb @@ -288,6 +288,29 @@ expect(generator).to have_received(:bundle_install_after_gem_swap) end + it "preserves options and guards in multiline parenthesized Gemfile declarations" do + simulate_existing_file("Gemfile", <<~RUBY) + source "https://rubygems.org" + gem( + "react_on_rails", + "~> 16.0", + require: false, + if: ENV["ENABLE_ROR"] + ) + RUBY + allow(generator).to receive(:bundle_install_after_gem_swap) + + generator.send(:swap_base_gem_for_pro_in_gemfile) + + gemfile_content = File.read(gemfile_path) + expected_version = Gem::Version.new(ReactOnRails::VERSION).release.to_s + expect(gemfile_content).to include("gem \"react_on_rails_pro\", \"~> #{expected_version}\",") + expect(gemfile_content).to include("require: false,") + expect(gemfile_content).to include('if: ENV["ENABLE_ROR"]') + expect(gemfile_content).not_to include('"react_on_rails"') + expect(gemfile_content).not_to include('"~> 16.0"') + end + it "handles comments containing closing parentheses inside multiline parenthesized declarations" do simulate_existing_file("Gemfile", <<~RUBY) source "https://rubygems.org" @@ -308,6 +331,27 @@ expect(gemfile_content).not_to include("gem(") end + it "handles inline Ruby comments containing parentheses in multiline parenthesized declarations" do + simulate_existing_file("Gemfile", <<~RUBY) + source "https://rubygems.org" + gem( + "react_on_rails", + "~> 16.0", # pinned :) + require: false + ) + RUBY + allow(generator).to receive(:bundle_install_after_gem_swap) + + expect { generator.send(:swap_base_gem_for_pro_in_gemfile) }.not_to raise_error + + gemfile_content = File.read(gemfile_path) + expected_version = Gem::Version.new(ReactOnRails::VERSION).release.to_s + expect(gemfile_content).to include("gem \"react_on_rails_pro\", \"~> #{expected_version}\",") + expect(gemfile_content).to include("require: false") + expect(gemfile_content).not_to include("gem(") + expect(gemfile_content).not_to include('"~> 16.0"') + end + it "handles nested parentheses in multiline parenthesized Gemfile options" do simulate_existing_file("Gemfile", <<~RUBY) source "https://rubygems.org" @@ -325,8 +369,9 @@ expected_version = Gem::Version.new(ReactOnRails::VERSION).release.to_s expect(gemfile_content).to include("gem \"react_on_rails_pro\", \"~> #{expected_version}\"") expect(gemfile_content).not_to include('"react_on_rails"') - expect(gemfile_content).not_to include('if: ENV.fetch("ENABLE_ROR") { true }') + expect(gemfile_content).to include('if: ENV.fetch("ENABLE_ROR") { true }') expect(gemfile_content).not_to include("gem(") + expect(gemfile_content).not_to include('"~> 16.0"') end it "removes base gem without adding duplicate react_on_rails_pro entries" do @@ -629,6 +674,23 @@ expect(rewritten).to include('"react-on-rails-pro";') end + it "does not rewrite imports inside multiline template literals" do + source = <<~JS + const importTemplate = ` + import ReactOnRails from "react-on-rails"; + const packageName = "react-on-rails/client"; + `; + import ReactOnRails from "react-on-rails"; + JS + + rewritten = generator.send(:rewrite_react_on_rails_module_specifiers, source) + + expect(rewritten).to include('import ReactOnRails from "react-on-rails";') + expect(rewritten).to include('const packageName = "react-on-rails/client";') + expect(rewritten).to include("import ReactOnRails from \"react-on-rails-pro\";") + expect(rewritten.scan("react-on-rails-pro").size).to eq(1) + end + it "detects unclosed block comments when multiple block markers appear on one line" do source_line = "/* closed */ const keep = true; /* unclosed" From 97252bb0e0e7760c27ed4dfa43a2d3b3cfe363a2 Mon Sep 17 00:00:00 2001 From: Justin Gordon Date: Tue, 24 Mar 2026 00:50:40 -1000 Subject: [PATCH 22/37] Fix backtick detection for quoted string literals --- .../lib/generators/react_on_rails/pro_generator.rb | 4 +++- .../react_on_rails/generators/pro_generator_spec.rb | 12 ++++++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/react_on_rails/lib/generators/react_on_rails/pro_generator.rb b/react_on_rails/lib/generators/react_on_rails/pro_generator.rb index 28d296cc0c..5d243033d1 100644 --- a/react_on_rails/lib/generators/react_on_rails/pro_generator.rb +++ b/react_on_rails/lib/generators/react_on_rails/pro_generator.rb @@ -521,7 +521,9 @@ def line_without_string_literals_and_inline_comments(line, strip_ruby_comments: end def line_has_unescaped_backtick?(line) - update_multiline_template_literal_state(false, line) + line_without_quoted_literals = line.gsub(/"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'/, "") + line_without_quoted_literals = line_without_quoted_literals.sub(%r{//.*$}, "") + update_multiline_template_literal_state(false, line_without_quoted_literals) end def update_multiline_template_literal_state(in_multiline_template_literal, line) diff --git a/react_on_rails/spec/react_on_rails/generators/pro_generator_spec.rb b/react_on_rails/spec/react_on_rails/generators/pro_generator_spec.rb index afcde78c2c..a3a8e339be 100644 --- a/react_on_rails/spec/react_on_rails/generators/pro_generator_spec.rb +++ b/react_on_rails/spec/react_on_rails/generators/pro_generator_spec.rb @@ -691,6 +691,18 @@ expect(rewritten.scan("react-on-rails-pro").size).to eq(1) end + it "does not treat quoted backticks as multiline template delimiters" do + source = <<~JS + const marker = "`"; // should not toggle template-literal tracking + import ReactOnRails from "react-on-rails"; + JS + + rewritten = generator.send(:rewrite_react_on_rails_module_specifiers, source) + + expect(rewritten).to include('const marker = "`";') + expect(rewritten).to include('import ReactOnRails from "react-on-rails-pro";') + end + it "detects unclosed block comments when multiple block markers appear on one line" do source_line = "/* closed */ const keep = true; /* unclosed" From 237ceb00e687e8ab86289ab4c8bc4ceeb226038c Mon Sep 17 00:00:00 2001 From: Justin Gordon Date: Tue, 24 Mar 2026 01:20:46 -1000 Subject: [PATCH 23/37] Handle mixed-line gem calls and template-state edge cases --- .../react_on_rails/pro_generator.rb | 51 +++++++++---------- .../generators/pro_generator_spec.rb | 35 +++++++++++++ 2 files changed, 58 insertions(+), 28 deletions(-) diff --git a/react_on_rails/lib/generators/react_on_rails/pro_generator.rb b/react_on_rails/lib/generators/react_on_rails/pro_generator.rb index 5d243033d1..dbd367e74d 100644 --- a/react_on_rails/lib/generators/react_on_rails/pro_generator.rb +++ b/react_on_rails/lib/generators/react_on_rails/pro_generator.rb @@ -368,11 +368,14 @@ def rewrite_non_comment_lines(content) content.lines.map do |line| stripped = line.lstrip - line_contains_unescaped_backtick = line_has_unescaped_backtick?(line) + line_for_template_literal_state = line_for_template_literal_tracking(line) + line_contains_unescaped_backtick = + line_has_unescaped_backtick?(line, line_for_tracking: line_for_template_literal_state) if in_multiline_template_literal || line_contains_unescaped_backtick + line_for_state_update = in_multiline_template_literal ? line : line_for_template_literal_state in_multiline_template_literal = - update_multiline_template_literal_state(in_multiline_template_literal, line) + update_multiline_template_literal_state(in_multiline_template_literal, line_for_state_update) line elsif in_block_comment if stripped.include?("*/") @@ -520,10 +523,14 @@ def line_without_string_literals_and_inline_comments(line, strip_ruby_comments: line_without_comments.sub(/\s*#.*$/, "") end - def line_has_unescaped_backtick?(line) + def line_for_template_literal_tracking(line) line_without_quoted_literals = line.gsub(/"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'/, "") - line_without_quoted_literals = line_without_quoted_literals.sub(%r{//.*$}, "") - update_multiline_template_literal_state(false, line_without_quoted_literals) + line_without_quoted_literals.sub(%r{//.*$}, "") + end + + def line_has_unescaped_backtick?(line, line_for_tracking: nil) + line_to_track = line_for_tracking || line_for_template_literal_tracking(line) + update_multiline_template_literal_state(false, line_to_track) end def update_multiline_template_literal_state(in_multiline_template_literal, line) @@ -550,27 +557,30 @@ def rewrite_line_after_block_comment_close(line, pending_depth, pending_multilin ["#{comment_prefix}#{rewritten_fragment}", pending_depth, pending_multiline_static_import_specifier] end - # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity + # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity def match_multiline_parenthesized_base_gem(lines, start_index) start_line = lines[start_index] - start_match = start_line.match(/^(\s*)gem\s*\(\s*(?:#.*)?$/) + start_match = start_line.match(/^(\s*)gem\s*\(/) return nil unless start_match - line_index = start_index + 1 + line_index = start_index found_base_gem_name = false base_gem_quote = nil gem_name_line_index = nil gem_name_match_end = nil - paren_depth = 1 + paren_depth = 0 while line_index < lines.length line = lines[line_index] line_without_comment = line.sub(/\s*#.*$/, "") line_without_literals = line_without_string_literals_and_inline_comments(line, strip_ruby_comments: true) - if comment_or_blank_line?(line) - line_index += 1 - next + if !found_base_gem_name && + (gem_name_match = line_without_comment.match(/(["'])react_on_rails\1(?=\s*(?:,|\)|#|$))/)) + found_base_gem_name = true + base_gem_quote = gem_name_match[1] + gem_name_line_index = line_index + gem_name_match_end = gem_name_match.end(0) end paren_depth += line_without_literals.count("(") - line_without_literals.count(")") @@ -578,9 +588,6 @@ def match_multiline_parenthesized_base_gem(lines, start_index) if paren_depth <= 0 return nil unless found_base_gem_name - closing_index = line_without_comment.rindex(")") - return nil unless closing_index - declaration_fragment = lines[gem_name_line_index..line_index].join suffix = declaration_fragment[gem_name_match_end..] suffix = "\n" if suffix.nil? || suffix.empty? @@ -592,24 +599,12 @@ def match_multiline_parenthesized_base_gem(lines, start_index) } end - if !found_base_gem_name && - (gem_name_match = line.match(/^\s*(["'])react_on_rails\1(?=\s*(?:,|\)|#|$))/)) - found_base_gem_name = true - base_gem_quote = gem_name_match[1] - gem_name_line_index = line_index - gem_name_match_end = gem_name_match.end(0) - line_index += 1 - next - end - - return nil unless found_base_gem_name - line_index += 1 end nil end - # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity + # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity def build_pro_gem_replacement_line(indentation:, quote:, suffix:, parenthesized_gem_call: false) normalized_suffix = suffix || "\n" diff --git a/react_on_rails/spec/react_on_rails/generators/pro_generator_spec.rb b/react_on_rails/spec/react_on_rails/generators/pro_generator_spec.rb index a3a8e339be..6234e2f4aa 100644 --- a/react_on_rails/spec/react_on_rails/generators/pro_generator_spec.rb +++ b/react_on_rails/spec/react_on_rails/generators/pro_generator_spec.rb @@ -288,6 +288,26 @@ expect(generator).to have_received(:bundle_install_after_gem_swap) end + it "replaces parenthesized declarations that start on the gem line and continue across lines" do + simulate_existing_file("Gemfile", <<~RUBY) + source "https://rubygems.org" + gem("react_on_rails", + "~> 16.0", + require: false + ) + RUBY + allow(generator).to receive(:bundle_install_after_gem_swap) + + generator.send(:swap_base_gem_for_pro_in_gemfile) + + gemfile_content = File.read(gemfile_path) + expected_version = Gem::Version.new(ReactOnRails::VERSION).release.to_s + expect(gemfile_content).to include("gem \"react_on_rails_pro\", \"~> #{expected_version}\",") + expect(gemfile_content).to include("require: false") + expect(gemfile_content).not_to include('gem("react_on_rails"') + expect(gemfile_content.lines.any? { |line| line.strip == ")" }).to be false + end + it "preserves options and guards in multiline parenthesized Gemfile declarations" do simulate_existing_file("Gemfile", <<~RUBY) source "https://rubygems.org" @@ -703,6 +723,21 @@ expect(rewritten).to include('import ReactOnRails from "react-on-rails-pro";') end + it "tracks multiline template literal state when quoted backticks and template delimiters share a line" do + source = <<~JS + const marker = "`"; const templateStart = `template header + import ReactOnRails from "react-on-rails"; + `; + import ReactOnRails from "react-on-rails"; + JS + + rewritten = generator.send(:rewrite_react_on_rails_module_specifiers, source) + + expect(rewritten).to include('import ReactOnRails from "react-on-rails";') + expect(rewritten).to include('import ReactOnRails from "react-on-rails-pro";') + expect(rewritten.scan("react-on-rails-pro").size).to eq(1) + end + it "detects unclosed block comments when multiple block markers appear on one line" do source_line = "/* closed */ const keep = true; /* unclosed" From 84c806db9c16d00130bdcd1847ec670949dedf47 Mon Sep 17 00:00:00 2001 From: Justin Gordon Date: Mon, 23 Mar 2026 21:30:11 -1000 Subject: [PATCH 24/37] Fix rsc-pro recovery flags and rsc dependency assertion --- .../lib/generators/react_on_rails/install_generator.rb | 8 ++------ .../react_on_rails/generators/install_generator_spec.rb | 4 +++- 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/react_on_rails/lib/generators/react_on_rails/install_generator.rb b/react_on_rails/lib/generators/react_on_rails/install_generator.rb index 1f92cd3d58..0ee8f5a508 100644 --- a/react_on_rails/lib/generators/react_on_rails/install_generator.rb +++ b/react_on_rails/lib/generators/react_on_rails/install_generator.rb @@ -280,7 +280,7 @@ def installation_prerequisites_met? # it on a clean worktree. On a dirty tree, use the read-only pro_gem_installed? # check to catch a missing gem without triggering auto-install. if has_worktree_issues && use_pro? && !pro_gem_installed? - required_flag = missing_pro_required_flag + required_flag = pro_requirement_flag GeneratorMessages.add_error(<<~MSG.strip) đŸšĢ react_on_rails_pro gem is required for #{required_flag} but is not installed. Auto-install was skipped because the worktree has uncommitted changes. @@ -499,7 +499,7 @@ def recovery_install_command flags << "--typescript" if options.typescript? flags << "--rspack" if options.rspack? - if options.rsc_pro? + if use_rsc_pro_mode? flags << "--rsc-pro" elsif options.rsc? flags << "--rsc" @@ -521,10 +521,6 @@ def rsc_pro_verification_message MSG end - def missing_pro_required_flag - pro_requirement_flag - end - def recovery_working_tree_lines [ "If this run created or changed files, clean up your working tree before rerunning", diff --git a/react_on_rails/spec/react_on_rails/generators/install_generator_spec.rb b/react_on_rails/spec/react_on_rails/generators/install_generator_spec.rb index f57bdbdd51..4c205865c4 100644 --- a/react_on_rails/spec/react_on_rails/generators/install_generator_spec.rb +++ b/react_on_rails/spec/react_on_rails/generators/install_generator_spec.rb @@ -1715,6 +1715,7 @@ class ActiveSupport::TestCase assert_file "package.json" do |content| package_json = JSON.parse(content) deps = package_json["dependencies"] || {} + expect(deps).to include("react-on-rails-rsc") expect(deps["react-on-rails-pro"]).to eq(expected_npm_version) expect(deps["react-on-rails-pro-node-renderer"]).to eq(expected_npm_version) end @@ -2003,12 +2004,13 @@ class ActiveSupport::TestCase command = install_generator.send(:recovery_install_command) - expect(command).to eq("rails generate react_on_rails:install --redux --typescript --rspack --rsc") + expect(command).to eq("rails generate react_on_rails:install --redux --typescript --rspack --rsc-pro") expect(command).not_to include("--ignore-warnings") expect(command).not_to include("--force") expect(command).not_to include("--skip") expect(command).not_to include("--pretend") expect(command).not_to include("--pro") + expect(command).not_to match(/\s--rsc(\s|$)/) end specify "recovery_install_command includes --pro when requested without --rsc" do From d84f57e8955d35c752bb002ae8850d26bbdab93c Mon Sep 17 00:00:00 2001 From: Justin Gordon Date: Mon, 23 Mar 2026 22:23:05 -1000 Subject: [PATCH 25/37] Fix #2821 review follow-ups for RSC-Pro generator --- .../lib/generators/react_on_rails/generator_helper.rb | 2 +- .../lib/generators/react_on_rails/install_generator.rb | 8 +++++--- .../generators/react_on_rails/js_dependency_manager.rb | 8 ++++---- react_on_rails/lib/generators/react_on_rails/pro_setup.rb | 2 ++ .../react_on_rails/generators/install_generator_spec.rb | 2 +- .../generators/js_dependency_manager_spec.rb | 8 ++++---- 6 files changed, 17 insertions(+), 13 deletions(-) diff --git a/react_on_rails/lib/generators/react_on_rails/generator_helper.rb b/react_on_rails/lib/generators/react_on_rails/generator_helper.rb index 503e684052..817983bc1a 100644 --- a/react_on_rails/lib/generators/react_on_rails/generator_helper.rb +++ b/react_on_rails/lib/generators/react_on_rails/generator_helper.rb @@ -34,7 +34,7 @@ def add_npm_dependencies(packages, dev: false) else pj.manager.add(packages, exact: true) end - result ? true : false + !result.nil? && result != false rescue StandardError => e say_status :warning, "Could not add packages via package_json gem: #{e.message}", :yellow say_status :warning, "Will fall back to direct npm commands.", :yellow diff --git a/react_on_rails/lib/generators/react_on_rails/install_generator.rb b/react_on_rails/lib/generators/react_on_rails/install_generator.rb index 0ee8f5a508..70ce7a4782 100644 --- a/react_on_rails/lib/generators/react_on_rails/install_generator.rb +++ b/react_on_rails/lib/generators/react_on_rails/install_generator.rb @@ -57,13 +57,15 @@ class InstallGenerator < Rails::Generators::Base class_option :pro, type: :boolean, default: false, - desc: "Install React on Rails Pro with Node Renderer. Default: false" + desc: "Install React on Rails Pro with Node Renderer. " \ + "Combined with --rsc, uses --rsc-pro mode. Default: false" # --rsc class_option :rsc, type: :boolean, default: false, - desc: "Install React Server Components support (includes Pro). Default: false" + desc: "Install React Server Components support (includes Pro). " \ + "Combined with --pro, uses --rsc-pro mode. Default: false" # --rsc-pro class_option :rsc_pro, @@ -516,7 +518,7 @@ def rsc_pro_verification_message 🔎 RSC Pro Verification: ───────────────────────────────────────────────────────────────────────── 1. Start all processes: #{Rainbow('bin/dev').cyan} - 2. Visit: #{Rainbow('http://localhost:3000/hello_server').cyan.underline} (or your configured port) + 2. Visit: #{Rainbow('http://localhost:/hello_server').cyan.underline} 3. Confirm the page streams and the Like button hydrates on click. MSG end diff --git a/react_on_rails/lib/generators/react_on_rails/js_dependency_manager.rb b/react_on_rails/lib/generators/react_on_rails/js_dependency_manager.rb index d547ff1b4a..526c05fb15 100644 --- a/react_on_rails/lib/generators/react_on_rails/js_dependency_manager.rb +++ b/react_on_rails/lib/generators/react_on_rails/js_dependency_manager.rb @@ -383,11 +383,11 @@ def pro_packages_with_version def add_rsc_dependencies say "Installing React Server Components dependencies..." - rsc_packages = rsc_packages_with_version + rsc_packages, used_version_pins = rsc_packages_with_version return if add_packages(rsc_packages) manual_install_packages = rsc_packages - if rsc_packages != RSC_DEPENDENCIES + if used_version_pins say_status :warning, "Could not install version-pinned RSC dependency. Retrying latest available package.", :yellow @@ -415,10 +415,10 @@ def add_rsc_dependencies # Falls back to unversioned package names when version resolution fails. def rsc_packages_with_version npm_version = ReactOnRails::VersionSyntaxConverter.new.rubygem_to_npm(ReactOnRails::VERSION) - RSC_DEPENDENCIES.map { |pkg| "#{pkg}@#{npm_version}" } + [RSC_DEPENDENCIES.map { |pkg| "#{pkg}@#{npm_version}" }, true] rescue StandardError => e say_status :warning, "Could not determine RSC package version (#{e.message}). Installing latest.", :yellow - RSC_DEPENDENCIES + [RSC_DEPENDENCIES, false] end def remove_base_package_if_present diff --git a/react_on_rails/lib/generators/react_on_rails/pro_setup.rb b/react_on_rails/lib/generators/react_on_rails/pro_setup.rb index 252f415604..bb21b6a444 100644 --- a/react_on_rails/lib/generators/react_on_rails/pro_setup.rb +++ b/react_on_rails/lib/generators/react_on_rails/pro_setup.rb @@ -475,6 +475,8 @@ def pro_gem_auto_install_command end def pro_gem_version_requirement + # RSC Pro uses exact pinning so the Pro gem version always matches the + # paired RSC package version generated in the same run. return ReactOnRails::VERSION if use_rsc_pro_mode? "~> #{recommended_pro_gem_version}" diff --git a/react_on_rails/spec/react_on_rails/generators/install_generator_spec.rb b/react_on_rails/spec/react_on_rails/generators/install_generator_spec.rb index 4c205865c4..7b4dfd3540 100644 --- a/react_on_rails/spec/react_on_rails/generators/install_generator_spec.rb +++ b/react_on_rails/spec/react_on_rails/generators/install_generator_spec.rb @@ -2063,7 +2063,7 @@ class ActiveSupport::TestCase output_text = GeneratorMessages.output.join("\n") expect(output_text).to include("RSC Pro Verification") - expect(output_text).to include("http://localhost:3000/hello_server") + expect(output_text).to include("http://localhost:/hello_server") expect(output_text).to include("Like button hydrates on click") end end diff --git a/react_on_rails/spec/react_on_rails/generators/js_dependency_manager_spec.rb b/react_on_rails/spec/react_on_rails/generators/js_dependency_manager_spec.rb index 871dd7d02d..fae59b3389 100644 --- a/react_on_rails/spec/react_on_rails/generators/js_dependency_manager_spec.rb +++ b/react_on_rails/spec/react_on_rails/generators/js_dependency_manager_spec.rb @@ -520,13 +520,13 @@ def errors converter = instance_double(ReactOnRails::VersionSyntaxConverter, rubygem_to_npm: "16.4.0-rc.5") allow(ReactOnRails::VersionSyntaxConverter).to receive(:new).and_return(converter) - expect(instance.send(:rsc_packages_with_version)).to eq(["react-on-rails-rsc@16.4.0-rc.5"]) + expect(instance.send(:rsc_packages_with_version)).to eq([["react-on-rails-rsc@16.4.0-rc.5"], true]) end it "falls back to unversioned package when conversion fails" do allow(ReactOnRails::VersionSyntaxConverter).to receive(:new).and_raise(StandardError, "conversion failed") - expect(instance.send(:rsc_packages_with_version)).to eq(["react-on-rails-rsc"]) + expect(instance.send(:rsc_packages_with_version)).to eq([["react-on-rails-rsc"], false]) expect(instance.say_status_calls).to include( a_hash_including(message: a_string_including("conversion failed")) ) @@ -535,7 +535,7 @@ def errors describe "#add_rsc_dependencies" do it "installs version-pinned rsc dependency" do - allow(instance).to receive(:rsc_packages_with_version).and_return(["react-on-rails-rsc@16.4.0"]) + allow(instance).to receive(:rsc_packages_with_version).and_return([["react-on-rails-rsc@16.4.0"], true]) instance.send(:add_rsc_dependencies) @@ -545,7 +545,7 @@ def errors end it "falls back to unversioned package when pinned install fails" do - allow(instance).to receive(:rsc_packages_with_version).and_return(["react-on-rails-rsc@16.4.0"]) + allow(instance).to receive(:rsc_packages_with_version).and_return([["react-on-rails-rsc@16.4.0"], true]) allow(instance).to receive(:add_packages).with(["react-on-rails-rsc@16.4.0"]).and_return(false) allow(instance).to receive(:add_packages).with(["react-on-rails-rsc"]).and_return(true) From 341a9902c564cf7961664ec136e906db312007ae Mon Sep 17 00:00:00 2001 From: Justin Gordon Date: Mon, 23 Mar 2026 22:46:38 -1000 Subject: [PATCH 26/37] Address review feedback for RSC Pro dependency handling --- .../react_on_rails/generator_helper.rb | 3 +- .../react_on_rails/install_generator.rb | 12 +++++--- .../react_on_rails/js_dependency_manager.rb | 15 +++++----- .../generators/react_on_rails/pro_setup.rb | 14 +++++++++- .../generators/generator_helper_spec.rb | 12 ++++++++ .../generators/install_generator_spec.rb | 27 +++++++++++++++++- .../generators/js_dependency_manager_spec.rb | 28 ++++++------------- 7 files changed, 76 insertions(+), 35 deletions(-) diff --git a/react_on_rails/lib/generators/react_on_rails/generator_helper.rb b/react_on_rails/lib/generators/react_on_rails/generator_helper.rb index 817983bc1a..ea884d562b 100644 --- a/react_on_rails/lib/generators/react_on_rails/generator_helper.rb +++ b/react_on_rails/lib/generators/react_on_rails/generator_helper.rb @@ -34,7 +34,8 @@ def add_npm_dependencies(packages, dev: false) else pj.manager.add(packages, exact: true) end - !result.nil? && result != false + # package_json#add can return nil for successful side-effect operations. + result != false rescue StandardError => e say_status :warning, "Could not add packages via package_json gem: #{e.message}", :yellow say_status :warning, "Will fall back to direct npm commands.", :yellow diff --git a/react_on_rails/lib/generators/react_on_rails/install_generator.rb b/react_on_rails/lib/generators/react_on_rails/install_generator.rb index 70ce7a4782..2b432da3b9 100644 --- a/react_on_rails/lib/generators/react_on_rails/install_generator.rb +++ b/react_on_rails/lib/generators/react_on_rails/install_generator.rb @@ -96,6 +96,8 @@ class InstallGenerator < Rails::Generators::Base # Removed: --skip-shakapacker-install (Shakapacker is now a required dependency) SHAKAPACKER_YML_PATH = "config/shakapacker.yml" + HELLO_WORLD_ROUTE = "hello_world" + HELLO_SERVER_ROUTE = "hello_server" # Matches the stock `bin/dev` written by Rails 8.x. Rails 7.1 commonly # generated a foreman-based shell script instead, which stock_rails_bin_dev? # also recognizes so the React on Rails template can replace either variant. @@ -391,8 +393,10 @@ def add_bin_scripts if preserve_existing_bin_dev? if use_rsc? && !options.redux? && !options.new_app? say_status :warn, - 'Custom bin/dev detected: update DEFAULT_ROUTE to "hello_server" manually for --rsc', + "Custom bin/dev detected: update DEFAULT_ROUTE to \"#{HELLO_SERVER_ROUTE}\" manually for --rsc", :yellow + else + gsub_file "bin/dev", "DEFAULT_ROUTE = \"#{HELLO_WORLD_ROUTE}\"", "DEFAULT_ROUTE = \"#{HELLO_SERVER_ROUTE}\"" end else copy_file("#{template_bin_path}/dev", "bin/dev") @@ -472,10 +476,10 @@ def add_post_install_message # Determine what route and component will be created by the generator if use_rsc? && !options.redux? # RSC without Redux: HelloServer replaces HelloWorld - route = "hello_server" + route = HELLO_SERVER_ROUTE component_name = "HelloServer" else - route = "hello_world" + route = HELLO_WORLD_ROUTE component_name = options.redux? ? "HelloWorldApp" : "HelloWorld" end @@ -518,7 +522,7 @@ def rsc_pro_verification_message 🔎 RSC Pro Verification: ───────────────────────────────────────────────────────────────────────── 1. Start all processes: #{Rainbow('bin/dev').cyan} - 2. Visit: #{Rainbow('http://localhost:/hello_server').cyan.underline} + 2. Visit: #{Rainbow("http://localhost:/#{HELLO_SERVER_ROUTE}").cyan.underline} 3. Confirm the page streams and the Like button hydrates on click. MSG end diff --git a/react_on_rails/lib/generators/react_on_rails/js_dependency_manager.rb b/react_on_rails/lib/generators/react_on_rails/js_dependency_manager.rb index 526c05fb15..8e1c705827 100644 --- a/react_on_rails/lib/generators/react_on_rails/js_dependency_manager.rb +++ b/react_on_rails/lib/generators/react_on_rails/js_dependency_manager.rb @@ -125,6 +125,10 @@ module JsDependencyManager react-on-rails-rsc ].freeze + # RSC package releases follow the React 19.0.x line (independent from gem versioning). + RSC_REACT_VERSION_RANGE = "~19.0.4" + RSC_PACKAGE_VERSION_PIN = RSC_REACT_VERSION_RANGE.delete_prefix("~") + private def setup_js_dependencies @@ -218,7 +222,7 @@ def add_react_dependencies # RSC requires React 19.0.x specifically (not 19.1.x or later) # Pin to ~19.0.4 to allow patch updates while staying within 19.0.x react_deps = if respond_to?(:use_rsc?) && use_rsc? - %w[react@~19.0.4 react-dom@~19.0.4 prop-types] + ["react@#{RSC_REACT_VERSION_RANGE}", "react-dom@#{RSC_REACT_VERSION_RANGE}", "prop-types"] else REACT_DEPENDENCIES end @@ -411,14 +415,9 @@ def add_rsc_dependencies MSG end - # Returns RSC package names pinned to the same version as the gem. - # Falls back to unversioned package names when version resolution fails. + # Returns RSC package names pinned to the RSC/React compatibility track. def rsc_packages_with_version - npm_version = ReactOnRails::VersionSyntaxConverter.new.rubygem_to_npm(ReactOnRails::VERSION) - [RSC_DEPENDENCIES.map { |pkg| "#{pkg}@#{npm_version}" }, true] - rescue StandardError => e - say_status :warning, "Could not determine RSC package version (#{e.message}). Installing latest.", :yellow - [RSC_DEPENDENCIES, false] + [RSC_DEPENDENCIES.map { |pkg| "#{pkg}@#{RSC_PACKAGE_VERSION_PIN}" }, true] end def remove_base_package_if_present diff --git a/react_on_rails/lib/generators/react_on_rails/pro_setup.rb b/react_on_rails/lib/generators/react_on_rails/pro_setup.rb index bb21b6a444..80db0588ad 100644 --- a/react_on_rails/lib/generators/react_on_rails/pro_setup.rb +++ b/react_on_rails/lib/generators/react_on_rails/pro_setup.rb @@ -61,11 +61,13 @@ def missing_pro_gem?(force: false) return false if attempt_pro_gem_auto_install context_line = pro_gem_requirement_context_line + prerelease_note = rsc_pro_prerelease_note GeneratorMessages.add_error(<<~MSG.strip) đŸšĢ Failed to auto-install #{PRO_GEM_NAME} gem. #{context_line} + #{prerelease_note} Please add manually to your Gemfile: gem '#{PRO_GEM_NAME}', '#{pro_gem_version_requirement}' @@ -88,7 +90,7 @@ def pro_gem_requirement_context_line end def pro_flag_specified_for_context? - options.key?(:pro) || options.key?(:rsc) || options.key?(:rsc_pro) + options[:pro] || options[:rsc] || options[:rsc_pro] end def pro_requirement_flag @@ -98,6 +100,16 @@ def pro_requirement_flag "--pro" end + def rsc_pro_prerelease_note + return "" unless use_rsc_pro_mode? + return "" unless Gem::Version.new(ReactOnRails::VERSION).prerelease? + + "Note: #{PRO_GEM_NAME} #{ReactOnRails::VERSION} may not be published yet. " \ + "If you are testing from source, use a local Gemfile `path:` option." + rescue StandardError + "" + end + # Attempt to auto-install the Pro gem via bundle add. # Uses Process.spawn instead of Timeout.timeout to avoid Thread#raise corrupting # Bundler.with_unbundled_env's ENV restoration. diff --git a/react_on_rails/spec/react_on_rails/generators/generator_helper_spec.rb b/react_on_rails/spec/react_on_rails/generators/generator_helper_spec.rb index 8c9a2067c3..a7c413637d 100644 --- a/react_on_rails/spec/react_on_rails/generators/generator_helper_spec.rb +++ b/react_on_rails/spec/react_on_rails/generators/generator_helper_spec.rb @@ -115,6 +115,18 @@ def options end end + context "when package manager add returns nil" do + it "treats nil as success for side-effect-only package managers" do + packages = %w[react react-dom] + + allow(mock_manager).to receive(:add).with(packages, exact: true).and_return(nil) + + result = add_npm_dependencies(packages) + expect(mock_manager).to have_received(:add).with(packages, exact: true) + expect(result).to be true + end + end + context "when package_json gem raises an error" do it "returns false and logs warnings via say_status" do packages = ["react"] diff --git a/react_on_rails/spec/react_on_rails/generators/install_generator_spec.rb b/react_on_rails/spec/react_on_rails/generators/install_generator_spec.rb index 7b4dfd3540..a2d245a94e 100644 --- a/react_on_rails/spec/react_on_rails/generators/install_generator_spec.rb +++ b/react_on_rails/spec/react_on_rails/generators/install_generator_spec.rb @@ -1711,11 +1711,12 @@ class ActiveSupport::TestCase it "pins Pro dependencies and installs the RSC dependency" do expected_npm_version = ReactOnRails::VersionSyntaxConverter.new.rubygem_to_npm(ReactOnRails::VERSION) + expected_rsc_npm_version = ReactOnRails::Generators::JsDependencyManager::RSC_PACKAGE_VERSION_PIN assert_file "package.json" do |content| package_json = JSON.parse(content) deps = package_json["dependencies"] || {} - expect(deps).to include("react-on-rails-rsc") + expect(deps["react-on-rails-rsc"]).to eq(expected_rsc_npm_version) expect(deps["react-on-rails-pro"]).to eq(expected_npm_version) expect(deps["react-on-rails-pro-node-renderer"]).to eq(expected_npm_version) end @@ -2881,6 +2882,8 @@ class ActiveSupport::TestCase err: anything) error_text = GeneratorMessages.messages.join("\n") expect(error_text).to include("gem 'react_on_rails_pro', '16.4.0.rc.5'") + expect(error_text).to include("may not be published yet") + expect(error_text).to include("path:") end end @@ -2969,6 +2972,28 @@ class ActiveSupport::TestCase end end + context "when force-checking Pro gem without pro-related flags" do + let(:install_generator) { described_class.new([], { pro: false, rsc: false, rsc_pro: false }) } + let(:fake_pid) { 12_345 } + + before do + allow(Gem).to receive(:loaded_specs).and_return({}) + allow(install_generator).to receive(:gem_in_lockfile?).with("react_on_rails_pro").and_return(false) + allow(Bundler).to receive(:with_unbundled_env).and_yield + allow(Process).to receive(:spawn).and_return(fake_pid) + allow(install_generator).to receive(:wait_for_bundle_process) + .with(fake_pid).and_return(instance_double(Process::Status, success?: false)) + end + + specify "missing_pro_gem?(force: true) uses generic context messaging" do + expect(install_generator.send(:missing_pro_gem?, force: true)).to be true + + error_text = GeneratorMessages.messages.join("\n") + expect(error_text).to include("This generator requires the react_on_rails_pro gem.") + expect(error_text).not_to include("You specified") + end + end + context "when --pro flag used on a dirty worktree without pro gem" do let(:install_generator) { described_class.new([], { pro: true }) } diff --git a/react_on_rails/spec/react_on_rails/generators/js_dependency_manager_spec.rb b/react_on_rails/spec/react_on_rails/generators/js_dependency_manager_spec.rb index fae59b3389..0531ac14c6 100644 --- a/react_on_rails/spec/react_on_rails/generators/js_dependency_manager_spec.rb +++ b/react_on_rails/spec/react_on_rails/generators/js_dependency_manager_spec.rb @@ -515,44 +515,32 @@ def errors end describe "#rsc_packages_with_version" do - it "pins react-on-rails-rsc to the current gem version" do - stub_const("ReactOnRails::VERSION", "16.4.0.rc.5") - converter = instance_double(ReactOnRails::VersionSyntaxConverter, rubygem_to_npm: "16.4.0-rc.5") - allow(ReactOnRails::VersionSyntaxConverter).to receive(:new).and_return(converter) - - expect(instance.send(:rsc_packages_with_version)).to eq([["react-on-rails-rsc@16.4.0-rc.5"], true]) - end - - it "falls back to unversioned package when conversion fails" do - allow(ReactOnRails::VersionSyntaxConverter).to receive(:new).and_raise(StandardError, "conversion failed") - - expect(instance.send(:rsc_packages_with_version)).to eq([["react-on-rails-rsc"], false]) - expect(instance.say_status_calls).to include( - a_hash_including(message: a_string_including("conversion failed")) - ) + it "pins react-on-rails-rsc to the React 19 compatibility track" do + expected_pin = ReactOnRails::Generators::JsDependencyManager::RSC_PACKAGE_VERSION_PIN + expect(instance.send(:rsc_packages_with_version)).to eq([["react-on-rails-rsc@#{expected_pin}"], true]) end end describe "#add_rsc_dependencies" do it "installs version-pinned rsc dependency" do - allow(instance).to receive(:rsc_packages_with_version).and_return([["react-on-rails-rsc@16.4.0"], true]) + allow(instance).to receive(:rsc_packages_with_version).and_return([["react-on-rails-rsc@19.0.4"], true]) instance.send(:add_rsc_dependencies) expect(instance.add_npm_dependencies_calls).to include( - a_hash_including(packages: ["react-on-rails-rsc@16.4.0"], dev: false) + a_hash_including(packages: ["react-on-rails-rsc@19.0.4"], dev: false) ) end it "falls back to unversioned package when pinned install fails" do - allow(instance).to receive(:rsc_packages_with_version).and_return([["react-on-rails-rsc@16.4.0"], true]) + allow(instance).to receive(:rsc_packages_with_version).and_return([["react-on-rails-rsc@19.0.4"], true]) - allow(instance).to receive(:add_packages).with(["react-on-rails-rsc@16.4.0"]).and_return(false) + allow(instance).to receive(:add_packages).with(["react-on-rails-rsc@19.0.4"]).and_return(false) allow(instance).to receive(:add_packages).with(["react-on-rails-rsc"]).and_return(true) instance.send(:add_rsc_dependencies) - expect(instance).to have_received(:add_packages).with(["react-on-rails-rsc@16.4.0"]) + expect(instance).to have_received(:add_packages).with(["react-on-rails-rsc@19.0.4"]) expect(instance).to have_received(:add_packages).with(["react-on-rails-rsc"]) end end From 919a1b16d968ba7b87897f2d922b514406884cb5 Mon Sep 17 00:00:00 2001 From: Justin Gordon Date: Mon, 23 Mar 2026 22:58:31 -1000 Subject: [PATCH 27/37] Harden RSC pin fallback messaging and Pro flag checks --- .../react_on_rails/js_dependency_manager.rb | 12 +++++++++--- .../lib/generators/react_on_rails/pro_setup.rb | 2 +- .../generators/js_dependency_manager_spec.rb | 6 ++++++ .../react_on_rails/generators/pro_generator_spec.rb | 11 +++++++++++ 4 files changed, 27 insertions(+), 4 deletions(-) diff --git a/react_on_rails/lib/generators/react_on_rails/js_dependency_manager.rb b/react_on_rails/lib/generators/react_on_rails/js_dependency_manager.rb index 8e1c705827..751b398c35 100644 --- a/react_on_rails/lib/generators/react_on_rails/js_dependency_manager.rb +++ b/react_on_rails/lib/generators/react_on_rails/js_dependency_manager.rb @@ -127,7 +127,7 @@ module JsDependencyManager # RSC package releases follow the React 19.0.x line (independent from gem versioning). RSC_REACT_VERSION_RANGE = "~19.0.4" - RSC_PACKAGE_VERSION_PIN = RSC_REACT_VERSION_RANGE.delete_prefix("~") + RSC_PACKAGE_VERSION_PIN = "19.0.4" private @@ -392,9 +392,14 @@ def add_rsc_dependencies manual_install_packages = rsc_packages if used_version_pins + warning_msg = "Could not install version-pinned RSC dependency. Retrying latest available package." say_status :warning, - "Could not install version-pinned RSC dependency. Retrying latest available package.", + warning_msg, :yellow + GeneratorMessages.add_warning( + "Warning: #{warning_msg} " \ + "The installed react-on-rails-rsc version may not match the expected compatibility pin." + ) return if add_packages(RSC_DEPENDENCIES) manual_install_packages = RSC_DEPENDENCIES @@ -415,7 +420,8 @@ def add_rsc_dependencies MSG end - # Returns RSC package names pinned to the RSC/React compatibility track. + # Returns [pinned_packages, used_version_pins]. used_version_pins is always true here; + # subclasses may override to return [packages, false] when pinning should be skipped. def rsc_packages_with_version [RSC_DEPENDENCIES.map { |pkg| "#{pkg}@#{RSC_PACKAGE_VERSION_PIN}" }, true] end diff --git a/react_on_rails/lib/generators/react_on_rails/pro_setup.rb b/react_on_rails/lib/generators/react_on_rails/pro_setup.rb index 80db0588ad..0693ba2240 100644 --- a/react_on_rails/lib/generators/react_on_rails/pro_setup.rb +++ b/react_on_rails/lib/generators/react_on_rails/pro_setup.rb @@ -90,7 +90,7 @@ def pro_gem_requirement_context_line end def pro_flag_specified_for_context? - options[:pro] || options[:rsc] || options[:rsc_pro] + use_pro? end def pro_requirement_flag diff --git a/react_on_rails/spec/react_on_rails/generators/js_dependency_manager_spec.rb b/react_on_rails/spec/react_on_rails/generators/js_dependency_manager_spec.rb index 0531ac14c6..50ca48e84f 100644 --- a/react_on_rails/spec/react_on_rails/generators/js_dependency_manager_spec.rb +++ b/react_on_rails/spec/react_on_rails/generators/js_dependency_manager_spec.rb @@ -515,6 +515,11 @@ def errors end describe "#rsc_packages_with_version" do + it "defines an explicit RSC package version pin independent from the React semver range prefix" do + expect(ReactOnRails::Generators::JsDependencyManager::RSC_REACT_VERSION_RANGE).to eq("~19.0.4") + expect(ReactOnRails::Generators::JsDependencyManager::RSC_PACKAGE_VERSION_PIN).to eq("19.0.4") + end + it "pins react-on-rails-rsc to the React 19 compatibility track" do expected_pin = ReactOnRails::Generators::JsDependencyManager::RSC_PACKAGE_VERSION_PIN expect(instance.send(:rsc_packages_with_version)).to eq([["react-on-rails-rsc@#{expected_pin}"], true]) @@ -542,6 +547,7 @@ def errors expect(instance).to have_received(:add_packages).with(["react-on-rails-rsc@19.0.4"]) expect(instance).to have_received(:add_packages).with(["react-on-rails-rsc"]) + expect(warnings.join("\n")).to include("installed react-on-rails-rsc version may not match") end end diff --git a/react_on_rails/spec/react_on_rails/generators/pro_generator_spec.rb b/react_on_rails/spec/react_on_rails/generators/pro_generator_spec.rb index 6234e2f4aa..99c429c552 100644 --- a/react_on_rails/spec/react_on_rails/generators/pro_generator_spec.rb +++ b/react_on_rails/spec/react_on_rails/generators/pro_generator_spec.rb @@ -745,6 +745,17 @@ end end + describe "#pro_flag_specified_for_context?" do + let(:generator) { described_class.new } + + it "delegates to use_pro? for consistent Pro/RSC flag semantics" do + allow(generator).to receive(:use_pro?).and_return(true) + + expect(generator.send(:pro_flag_specified_for_context?)).to be(true) + expect(generator).to have_received(:use_pro?) + end + end + # Integration test for standalone happy path # Uses before (not before(:all)) to allow mocking the Pro gem check From 95efd3fd95803008ce64ce3275bb5d940758482e Mon Sep 17 00:00:00 2001 From: Justin Gordon Date: Sat, 28 Mar 2026 00:04:07 -1000 Subject: [PATCH 28/37] Rollback Gemfile swap on bundle failure and atomically rewrite imports --- .../react_on_rails/pro_generator.rb | 52 +++++++++++++------ .../generators/pro_generator_spec.rb | 33 ++++++++++++ 2 files changed, 70 insertions(+), 15 deletions(-) diff --git a/react_on_rails/lib/generators/react_on_rails/pro_generator.rb b/react_on_rails/lib/generators/react_on_rails/pro_generator.rb index dbd367e74d..c4a256e858 100644 --- a/react_on_rails/lib/generators/react_on_rails/pro_generator.rb +++ b/react_on_rails/lib/generators/react_on_rails/pro_generator.rb @@ -163,9 +163,13 @@ def swap_base_gem_for_pro_in_gemfile return end + original_gemfile_content = gemfile_content atomic_write_file(gemfile_path, updated_content) say "✅ Replaced react_on_rails with react_on_rails_pro in Gemfile", :green - bundle_install_after_gem_swap + bundle_install_after_gem_swap( + gemfile_path: gemfile_path, + original_gemfile_content: original_gemfile_content + ) rescue StandardError => e add_gemfile_update_warning(gemfile_path, e) end @@ -188,13 +192,15 @@ def atomic_write_file(path, content) File.delete(temp_path) if temp_path && File.exist?(temp_path) end - def bundle_install_after_gem_swap + def bundle_install_after_gem_swap( + gemfile_path: File.join(destination_root, "Gemfile"), + original_gemfile_content: nil + ) if options[:pretend] say_status :pretend, "Skipping bundle install in --pretend mode", :yellow return end - gemfile_path = File.join(destination_root, "Gemfile") say "đŸ“Ļ Running bundle install after Gemfile update...", :yellow install_status = Bundler.with_unbundled_env do pid = Process.spawn( @@ -210,33 +216,49 @@ def bundle_install_after_gem_swap return if install_status&.success? - if install_status.nil? - GeneratorMessages.add_warning(<<~MSG.strip) - âš ī¸ Automatic bundle install timed out after #{ProSetup::AUTO_INSTALL_TIMEOUT} seconds. + rollback_message = rollback_gemfile_after_failed_bundle_install( + gemfile_path: gemfile_path, + original_gemfile_content: original_gemfile_content + ) - Gemfile has been updated with react_on_rails_pro. - Please run manually: - bundle install - MSG - return - end + failure_header = if install_status.nil? + "âš ī¸ Automatic bundle install timed out after #{ProSetup::AUTO_INSTALL_TIMEOUT} seconds." + else + "âš ī¸ Automatic bundle install failed after swapping Gemfile entries." + end GeneratorMessages.add_warning(<<~MSG.strip) - âš ī¸ Automatic bundle install failed after swapping Gemfile entries. + #{failure_header} - Gemfile has been updated with react_on_rails_pro. + #{rollback_message} Please run manually: bundle install MSG rescue StandardError => e + rollback_message = rollback_gemfile_after_failed_bundle_install( + gemfile_path: gemfile_path, + original_gemfile_content: original_gemfile_content + ) + GeneratorMessages.add_warning(<<~MSG.strip) âš ī¸ Could not run automatic bundle install: #{e.class}: #{e.message} + #{rollback_message} Please run manually: bundle install MSG end + def rollback_gemfile_after_failed_bundle_install(gemfile_path:, original_gemfile_content:) + return "Gemfile remains updated with react_on_rails_pro." unless original_gemfile_content + + atomic_write_file(gemfile_path, original_gemfile_content) + "Gemfile has been reverted to its previous react_on_rails entry." + rescue StandardError => e + "Could not revert Gemfile automatically (#{e.class}: #{e.message}). " \ + "Gemfile remains updated with react_on_rails_pro." + end + def update_imports_to_pro_package files = js_files_for_import_update updated_files = 0 @@ -252,7 +274,7 @@ def update_imports_to_pro_package next end - File.write(file, updated_content) + atomic_write_file(file, updated_content) updated_files += 1 rescue StandardError => e GeneratorMessages.add_warning(<<~MSG.strip) diff --git a/react_on_rails/spec/react_on_rails/generators/pro_generator_spec.rb b/react_on_rails/spec/react_on_rails/generators/pro_generator_spec.rb index 99c429c552..fada01c0ef 100644 --- a/react_on_rails/spec/react_on_rails/generators/pro_generator_spec.rb +++ b/react_on_rails/spec/react_on_rails/generators/pro_generator_spec.rb @@ -505,6 +505,7 @@ describe "#bundle_install_after_gem_swap" do let(:generator) { described_class.new } let(:fake_pid) { 23_456 } + let(:gemfile_path) { File.join(destination_root, "Gemfile") } before do prepare_destination @@ -549,6 +550,30 @@ expect(warning_text).to include("timed out") expect(warning_text).to include("bundle install") end + + it "reverts Gemfile when bundle install fails after a swap" do + original_content = <<~RUBY + source "https://rubygems.org" + gem "react_on_rails", "~> 16.0" + RUBY + simulate_existing_file("Gemfile", <<~RUBY) + source "https://rubygems.org" + gem "react_on_rails_pro", "~> 16.0" + RUBY + allow(generator).to receive(:wait_for_bundle_process) + .with(fake_pid).and_return(instance_double(Process::Status, success?: false)) + + generator.send( + :bundle_install_after_gem_swap, + gemfile_path: gemfile_path, + original_gemfile_content: original_content + ) + + expect(File.read(gemfile_path)).to eq(original_content) + warning_text = GeneratorMessages.messages.join("\n") + expect(warning_text).to include("failed after swapping Gemfile entries") + expect(warning_text).to include("Gemfile has been reverted to its previous react_on_rails entry") + end end describe "#update_imports_to_pro_package" do @@ -644,6 +669,14 @@ expect(File.read(application_js_path)).to eq(original_content) end + it "uses atomic writes for rewritten import files" do + allow(generator).to receive(:atomic_write_file).and_call_original + + generator.send(:update_imports_to_pro_package) + + expect(generator).to have_received(:atomic_write_file).at_least(:once) + end + it "keeps multiline dynamic import tracking active when comments contain unrelated closing parentheses" do source = <<~JS const lazyRor = import( From 24bbb516b10ed399a3a1464e1f2726d8b001be56 Mon Sep 17 00:00:00 2001 From: Justin Gordon Date: Sat, 28 Mar 2026 10:04:47 -1000 Subject: [PATCH 29/37] Fix pro generator swap consistency and template-literal rewrites --- .../react_on_rails/pro_generator.rb | 42 ++++++++- .../generators/pro_generator_spec.rb | 92 +++++++++++-------- 2 files changed, 92 insertions(+), 42 deletions(-) diff --git a/react_on_rails/lib/generators/react_on_rails/pro_generator.rb b/react_on_rails/lib/generators/react_on_rails/pro_generator.rb index c4a256e858..4fca7ba601 100644 --- a/react_on_rails/lib/generators/react_on_rails/pro_generator.rb +++ b/react_on_rails/lib/generators/react_on_rails/pro_generator.rb @@ -396,9 +396,23 @@ def rewrite_non_comment_lines(content) if in_multiline_template_literal || line_contains_unescaped_backtick line_for_state_update = in_multiline_template_literal ? line : line_for_template_literal_state - in_multiline_template_literal = + updated_template_literal_state = update_multiline_template_literal_state(in_multiline_template_literal, line_for_state_update) - line + + if in_multiline_template_literal && !updated_template_literal_state + rewritten_line, pending_multiline_module_call_depth, pending_multiline_static_import_specifier = + rewrite_line_after_template_literal_close( + line, + pending_multiline_module_call_depth, + pending_multiline_static_import_specifier + ) { |line_fragment| yield line_fragment } + in_multiline_template_literal = updated_template_literal_state + in_block_comment = true if unclosed_block_comment_starts?(rewritten_line) + rewritten_line + else + in_multiline_template_literal = updated_template_literal_state + line + end elsif in_block_comment if stripped.include?("*/") in_block_comment = false @@ -579,6 +593,28 @@ def rewrite_line_after_block_comment_close(line, pending_depth, pending_multilin ["#{comment_prefix}#{rewritten_fragment}", pending_depth, pending_multiline_static_import_specifier] end + def rewrite_line_after_template_literal_close(line, pending_depth, pending_multiline_static_import_specifier) + closing_index = first_unescaped_backtick_index(line) + return [line, pending_depth, pending_multiline_static_import_specifier] unless closing_index + return [line, pending_depth, pending_multiline_static_import_specifier] if closing_index >= line.length - 1 + + template_literal_prefix = line[0, closing_index + 1] + line_fragment = line[(closing_index + 1)..] + rewritten_fragment = yield line_fragment + rewritten_fragment, pending_multiline_static_import_specifier = + update_pending_multiline_static_import_tracking(rewritten_fragment, pending_multiline_static_import_specifier) + rewritten_fragment, pending_depth = + update_pending_multiline_module_call_tracking(rewritten_fragment, pending_depth) + ["#{template_literal_prefix}#{rewritten_fragment}", pending_depth, pending_multiline_static_import_specifier] + end + + def first_unescaped_backtick_index(line) + line.each_char.with_index do |char, index| + return index if char == "`" && (index.zero? || line[index - 1] != "\\") + end + nil + end + # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity def match_multiline_parenthesized_base_gem(lines, start_index) start_line = lines[start_index] @@ -640,7 +676,7 @@ def build_pro_gem_replacement_line(indentation:, quote:, suffix:, parenthesized_ normalized_suffix = normalized_suffix.sub(/\)(\s*(?:#.*)?\n)\z/, '\1') if parenthesized_gem_call "#{indentation}gem #{quote}react_on_rails_pro#{quote}, " \ - "#{quote}~> #{recommended_pro_gem_version}#{quote}#{normalized_suffix}" + "#{quote}#{ReactOnRails::VERSION}#{quote}#{normalized_suffix}" end def print_success_message diff --git a/react_on_rails/spec/react_on_rails/generators/pro_generator_spec.rb b/react_on_rails/spec/react_on_rails/generators/pro_generator_spec.rb index fada01c0ef..397fa2b466 100644 --- a/react_on_rails/spec/react_on_rails/generators/pro_generator_spec.rb +++ b/react_on_rails/spec/react_on_rails/generators/pro_generator_spec.rb @@ -74,8 +74,8 @@ generator.send(:swap_base_gem_for_pro_in_gemfile) gemfile_content = File.read(gemfile_path) - expected_version = Gem::Version.new(ReactOnRails::VERSION).release.to_s - expect(gemfile_content).to include("gem \"react_on_rails_pro\", \"~> #{expected_version}\"") + expected_version = ReactOnRails::VERSION + expect(gemfile_content).to include("gem \"react_on_rails_pro\", \"#{expected_version}\"") expect(gemfile_content).not_to match(/gem\s+["']react_on_rails["']/) expect(generator).to have_received(:bundle_install_after_gem_swap) end @@ -90,8 +90,8 @@ generator.send(:swap_base_gem_for_pro_in_gemfile) gemfile_content = File.read(gemfile_path) - expected_version = Gem::Version.new(ReactOnRails::VERSION).release.to_s - expect(gemfile_content).to include("gem \"react_on_rails_pro\", \"~> #{expected_version}\", require: false") + expected_version = ReactOnRails::VERSION + expect(gemfile_content).to include("gem \"react_on_rails_pro\", \"#{expected_version}\", require: false") expect(gemfile_content).to include("if: ENV[\"ENABLE_ROR\"]") expect(gemfile_content).not_to include("gem \"react_on_rails\",") end @@ -109,8 +109,8 @@ generator.send(:swap_base_gem_for_pro_in_gemfile) gemfile_content = File.read(gemfile_path) - expected_version = Gem::Version.new(ReactOnRails::VERSION).release.to_s - expect(gemfile_content).to include(" gem \"react_on_rails_pro\", \"~> #{expected_version}\"") + expected_version = ReactOnRails::VERSION + expect(gemfile_content).to include(" gem \"react_on_rails_pro\", \"#{expected_version}\"") expect(generator).to have_received(:bundle_install_after_gem_swap) end @@ -125,8 +125,8 @@ generator.send(:swap_base_gem_for_pro_in_gemfile) gemfile_content = File.read(gemfile_path) - expected_version = Gem::Version.new(ReactOnRails::VERSION).release.to_s - expect(gemfile_content).to include("gem \"react_on_rails_pro\", \"~> #{expected_version}\"") + expected_version = ReactOnRails::VERSION + expect(gemfile_content).to include("gem \"react_on_rails_pro\", \"#{expected_version}\"") expect(gemfile_content).not_to include("gem \"react_on_rails\",") expect(gemfile_content).not_to include(" \"~> 16.0\"") expect(generator).to have_received(:bundle_install_after_gem_swap) @@ -143,8 +143,8 @@ generator.send(:swap_base_gem_for_pro_in_gemfile) gemfile_content = File.read(gemfile_path) - expected_version = Gem::Version.new(ReactOnRails::VERSION).release.to_s - expect(gemfile_content).to include("gem \"react_on_rails_pro\", \"~> #{expected_version}\"") + expected_version = ReactOnRails::VERSION + expect(gemfile_content).to include("gem \"react_on_rails_pro\", \"#{expected_version}\"") expect(gemfile_content).not_to include("gem \"react_on_rails\", # pinned for compatibility") expect(gemfile_content).not_to include(" \"~> 16.0\"") end @@ -160,8 +160,8 @@ generator.send(:swap_base_gem_for_pro_in_gemfile) gemfile_content = File.read(gemfile_path) - expected_version = Gem::Version.new(ReactOnRails::VERSION).release.to_s - expect(gemfile_content).to include("gem \"react_on_rails_pro\", \"~> #{expected_version}\"") + expected_version = ReactOnRails::VERSION + expect(gemfile_content).to include("gem \"react_on_rails_pro\", \"#{expected_version}\"") expect(gemfile_content).not_to include("gem \"react_on_rails\",#pinned for compatibility") expect(gemfile_content).not_to include(" \"~> 16.0\"") end @@ -179,8 +179,8 @@ generator.send(:swap_base_gem_for_pro_in_gemfile) gemfile_content = File.read(gemfile_path) - expected_version = Gem::Version.new(ReactOnRails::VERSION).release.to_s - expect(gemfile_content).to include("gem \"react_on_rails_pro\", \"~> #{expected_version}\"") + expected_version = ReactOnRails::VERSION + expect(gemfile_content).to include("gem \"react_on_rails_pro\", \"#{expected_version}\"") expect(gemfile_content).to include("gem \"rails\"") expect(gemfile_content).not_to include("~> 16.0") end @@ -198,8 +198,8 @@ generator.send(:swap_base_gem_for_pro_in_gemfile) gemfile_content = File.read(gemfile_path) - expected_version = Gem::Version.new(ReactOnRails::VERSION).release.to_s - expect(gemfile_content).to include("gem \"react_on_rails_pro\", \"~> #{expected_version}\"") + expected_version = ReactOnRails::VERSION + expect(gemfile_content).to include("gem \"react_on_rails_pro\", \"#{expected_version}\"") expect(gemfile_content).to include("gem \"rails\"") expect(gemfile_content).not_to include("~> 16.0") end @@ -215,8 +215,8 @@ generator.send(:swap_base_gem_for_pro_in_gemfile) gemfile_content = File.read(gemfile_path) - expected_version = Gem::Version.new(ReactOnRails::VERSION).release.to_s - expect(gemfile_content).to include("gem \"react_on_rails_pro\", \"~> #{expected_version}\"") + expected_version = ReactOnRails::VERSION + expect(gemfile_content).to include("gem \"react_on_rails_pro\", \"#{expected_version}\"") expect(gemfile_content).to include("gem \"rails\"") end @@ -230,8 +230,8 @@ generator.send(:swap_base_gem_for_pro_in_gemfile) gemfile_content = File.read(gemfile_path) - expected_version = Gem::Version.new(ReactOnRails::VERSION).release.to_s - expect(gemfile_content).to include("gem 'react_on_rails_pro', '~> #{expected_version}'") + expected_version = ReactOnRails::VERSION + expect(gemfile_content).to include("gem 'react_on_rails_pro', '#{expected_version}'") expect(generator).to have_received(:bundle_install_after_gem_swap) end @@ -245,9 +245,9 @@ generator.send(:swap_base_gem_for_pro_in_gemfile) gemfile_content = File.read(gemfile_path) - expected_version = Gem::Version.new(ReactOnRails::VERSION).release.to_s - expect(gemfile_content).to include("gem \"react_on_rails_pro\", \"~> #{expected_version}\"") - expect(gemfile_content).not_to include("gem \"react_on_rails_pro\", \"~> #{expected_version}\")") + expected_version = ReactOnRails::VERSION + expect(gemfile_content).to include("gem \"react_on_rails_pro\", \"#{expected_version}\"") + expect(gemfile_content).not_to include("gem \"react_on_rails_pro\", \"#{expected_version}\")") expect(gemfile_content).not_to include('gem("react_on_rails"') expect(generator).to have_received(:bundle_install_after_gem_swap) end @@ -262,8 +262,8 @@ generator.send(:swap_base_gem_for_pro_in_gemfile) gemfile_content = File.read(gemfile_path) - expected_version = Gem::Version.new(ReactOnRails::VERSION).release.to_s - expect(gemfile_content).to include("gem \"react_on_rails_pro\", \"~> #{expected_version}\", require: false") + expected_version = ReactOnRails::VERSION + expect(gemfile_content).to include("gem \"react_on_rails_pro\", \"#{expected_version}\", require: false") expect(gemfile_content).to include('if: ENV.fetch("ENABLE_ROR", false)') expect(gemfile_content).not_to include("false))") end @@ -281,8 +281,8 @@ generator.send(:swap_base_gem_for_pro_in_gemfile) gemfile_content = File.read(gemfile_path) - expected_version = Gem::Version.new(ReactOnRails::VERSION).release.to_s - expect(gemfile_content).to include("gem \"react_on_rails_pro\", \"~> #{expected_version}\"") + expected_version = ReactOnRails::VERSION + expect(gemfile_content).to include("gem \"react_on_rails_pro\", \"#{expected_version}\"") expect(gemfile_content).not_to include('"react_on_rails"') expect(gemfile_content).not_to include('"~> 16.0"') expect(generator).to have_received(:bundle_install_after_gem_swap) @@ -301,8 +301,8 @@ generator.send(:swap_base_gem_for_pro_in_gemfile) gemfile_content = File.read(gemfile_path) - expected_version = Gem::Version.new(ReactOnRails::VERSION).release.to_s - expect(gemfile_content).to include("gem \"react_on_rails_pro\", \"~> #{expected_version}\",") + expected_version = ReactOnRails::VERSION + expect(gemfile_content).to include("gem \"react_on_rails_pro\", \"#{expected_version}\",") expect(gemfile_content).to include("require: false") expect(gemfile_content).not_to include('gem("react_on_rails"') expect(gemfile_content.lines.any? { |line| line.strip == ")" }).to be false @@ -323,8 +323,8 @@ generator.send(:swap_base_gem_for_pro_in_gemfile) gemfile_content = File.read(gemfile_path) - expected_version = Gem::Version.new(ReactOnRails::VERSION).release.to_s - expect(gemfile_content).to include("gem \"react_on_rails_pro\", \"~> #{expected_version}\",") + expected_version = ReactOnRails::VERSION + expect(gemfile_content).to include("gem \"react_on_rails_pro\", \"#{expected_version}\",") expect(gemfile_content).to include("require: false,") expect(gemfile_content).to include('if: ENV["ENABLE_ROR"]') expect(gemfile_content).not_to include('"react_on_rails"') @@ -345,8 +345,8 @@ generator.send(:swap_base_gem_for_pro_in_gemfile) gemfile_content = File.read(gemfile_path) - expected_version = Gem::Version.new(ReactOnRails::VERSION).release.to_s - expect(gemfile_content).to include("gem \"react_on_rails_pro\", \"~> #{expected_version}\"") + expected_version = ReactOnRails::VERSION + expect(gemfile_content).to include("gem \"react_on_rails_pro\", \"#{expected_version}\"") expect(gemfile_content).not_to include('"~> 16.0"') expect(gemfile_content).not_to include("gem(") end @@ -365,8 +365,8 @@ expect { generator.send(:swap_base_gem_for_pro_in_gemfile) }.not_to raise_error gemfile_content = File.read(gemfile_path) - expected_version = Gem::Version.new(ReactOnRails::VERSION).release.to_s - expect(gemfile_content).to include("gem \"react_on_rails_pro\", \"~> #{expected_version}\",") + expected_version = ReactOnRails::VERSION + expect(gemfile_content).to include("gem \"react_on_rails_pro\", \"#{expected_version}\",") expect(gemfile_content).to include("require: false") expect(gemfile_content).not_to include("gem(") expect(gemfile_content).not_to include('"~> 16.0"') @@ -386,8 +386,8 @@ generator.send(:swap_base_gem_for_pro_in_gemfile) gemfile_content = File.read(gemfile_path) - expected_version = Gem::Version.new(ReactOnRails::VERSION).release.to_s - expect(gemfile_content).to include("gem \"react_on_rails_pro\", \"~> #{expected_version}\"") + expected_version = ReactOnRails::VERSION + expect(gemfile_content).to include("gem \"react_on_rails_pro\", \"#{expected_version}\"") expect(gemfile_content).not_to include('"react_on_rails"') expect(gemfile_content).to include('if: ENV.fetch("ENABLE_ROR") { true }') expect(gemfile_content).not_to include("gem(") @@ -466,8 +466,8 @@ generator.send(:swap_base_gem_for_pro_in_gemfile) gemfile_content = File.read(gemfile_path) - expected_version = Gem::Version.new(ReactOnRails::VERSION).release.to_s - expect(gemfile_content).to include("gem \"react_on_rails_pro\", \"~> #{expected_version}\"") + expected_version = ReactOnRails::VERSION + expect(gemfile_content).to include("gem \"react_on_rails_pro\", \"#{expected_version}\"") expect(gemfile_content).not_to include("gem \"react_on_rails\" # pinned for compatibility") expect(generator).to have_received(:bundle_install_after_gem_swap) end @@ -744,6 +744,20 @@ expect(rewritten.scan("react-on-rails-pro").size).to eq(1) end + it "rewrites imports that appear after a multiline template literal closes on the same line" do + source = <<~JS + const importTemplate = ` + import ReactOnRails from "react-on-rails"; + `; const ror = require("react-on-rails"); + JS + + rewritten = generator.send(:rewrite_react_on_rails_module_specifiers, source) + + expect(rewritten).to include('import ReactOnRails from "react-on-rails";') + expect(rewritten).to include('`; const ror = require("react-on-rails-pro");') + expect(rewritten.scan("react-on-rails-pro").size).to eq(1) + end + it "does not treat quoted backticks as multiline template delimiters" do source = <<~JS const marker = "`"; // should not toggle template-literal tracking From 422ff98438cd5e311d2f56cb924b7feb94212c0e Mon Sep 17 00:00:00 2001 From: Justin Gordon Date: Sun, 29 Mar 2026 01:00:36 -1000 Subject: [PATCH 30/37] Fix regex backtracking and strip all version constraints in gem swap Use possessive quantifier (\s++) in build_pro_gem_replacement_line to prevent exponential backtracking flagged by GitHub code scanning. Wrap the version-argument stripping .sub in a loop so declarations with multiple positional version constraints (e.g. ">= 15.0", "< 16.0") are fully stripped before inserting the pinned version. Adds spec for multi-constraint gem declarations. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../generators/react_on_rails/pro_generator.rb | 12 ++++++++---- .../generators/pro_generator_spec.rb | 16 ++++++++++++++++ 2 files changed, 24 insertions(+), 4 deletions(-) diff --git a/react_on_rails/lib/generators/react_on_rails/pro_generator.rb b/react_on_rails/lib/generators/react_on_rails/pro_generator.rb index 4fca7ba601..d7e037a036 100644 --- a/react_on_rails/lib/generators/react_on_rails/pro_generator.rb +++ b/react_on_rails/lib/generators/react_on_rails/pro_generator.rb @@ -667,10 +667,14 @@ def match_multiline_parenthesized_base_gem(lines, start_index) def build_pro_gem_replacement_line(indentation:, quote:, suffix:, parenthesized_gem_call: false) normalized_suffix = suffix || "\n" normalized_suffix = "#{normalized_suffix}\n" unless normalized_suffix.end_with?("\n") - normalized_suffix = normalized_suffix.sub( - /\A(?\s*,(?:\s*#.*\n|\s+)*)["'][^"']*["'](?\s*,)?/ - ) do - Regexp.last_match[:trailing_comma] ? Regexp.last_match[:prefix] : "" + version_arg_pattern = /\A(?\s*,(?:\s*#.*\n|\s++)*)["'][^"']*["'](?\s*,)?/ + loop do + updated_suffix = normalized_suffix.sub(version_arg_pattern) do + Regexp.last_match[:trailing_comma] ? Regexp.last_match[:prefix] : "" + end + break if updated_suffix == normalized_suffix + + normalized_suffix = updated_suffix end normalized_suffix = normalized_suffix.sub(/\A,[ \t]{2,}/, ", ") normalized_suffix = normalized_suffix.sub(/\)(\s*(?:#.*)?\n)\z/, '\1') if parenthesized_gem_call diff --git a/react_on_rails/spec/react_on_rails/generators/pro_generator_spec.rb b/react_on_rails/spec/react_on_rails/generators/pro_generator_spec.rb index 397fa2b466..de8f971fec 100644 --- a/react_on_rails/spec/react_on_rails/generators/pro_generator_spec.rb +++ b/react_on_rails/spec/react_on_rails/generators/pro_generator_spec.rb @@ -96,6 +96,22 @@ expect(gemfile_content).not_to include("gem \"react_on_rails\",") end + it "strips all version constraints from multi-constraint declarations" do + simulate_existing_file("Gemfile", <<~RUBY) + source "https://rubygems.org" + gem "react_on_rails", ">= 15.0", "< 16.0", require: false + RUBY + allow(generator).to receive(:bundle_install_after_gem_swap) + + generator.send(:swap_base_gem_for_pro_in_gemfile) + + gemfile_content = File.read(gemfile_path) + expected_version = ReactOnRails::VERSION + expect(gemfile_content).to include("gem \"react_on_rails_pro\", \"#{expected_version}\", require: false") + expect(gemfile_content).not_to include(">= 15.0") + expect(gemfile_content).not_to include("< 16.0") + end + it "preserves indentation when replacing a grouped Gemfile entry" do simulate_existing_file("Gemfile", <<~RUBY) source "https://rubygems.org" From 27137fc30e0f05f8c7cbde9b783ef24787f761a5 Mon Sep 17 00:00:00 2001 From: Justin Gordon Date: Sun, 29 Mar 2026 15:06:31 -1000 Subject: [PATCH 31/37] Avoid partial pro upgrades and stale generator rewrites --- .../react_on_rails/install_generator.rb | 4 +- .../react_on_rails/pro_generator.rb | 116 ++++++++++++++---- .../generators/install_generator_spec.rb | 54 ++++++++ .../generators/js_dependency_manager_spec.rb | 23 ++++ .../generators/pro_generator_spec.rb | 77 +++++++++++- 5 files changed, 246 insertions(+), 28 deletions(-) diff --git a/react_on_rails/lib/generators/react_on_rails/install_generator.rb b/react_on_rails/lib/generators/react_on_rails/install_generator.rb index 2b432da3b9..b65ff4238e 100644 --- a/react_on_rails/lib/generators/react_on_rails/install_generator.rb +++ b/react_on_rails/lib/generators/react_on_rails/install_generator.rb @@ -212,7 +212,7 @@ def invoke_generators invoke "react_on_rails:react_with_redux", [], { typescript: options.typescript?, invoked_by_install: true, new_app: options.new_app?, - rsc: options.rsc?, + rsc: use_rsc?, force: options[:force], skip: options[:skip], pretend: options[:pretend] } elsif !use_rsc? @@ -395,8 +395,6 @@ def add_bin_scripts say_status :warn, "Custom bin/dev detected: update DEFAULT_ROUTE to \"#{HELLO_SERVER_ROUTE}\" manually for --rsc", :yellow - else - gsub_file "bin/dev", "DEFAULT_ROUTE = \"#{HELLO_WORLD_ROUTE}\"", "DEFAULT_ROUTE = \"#{HELLO_SERVER_ROUTE}\"" end else copy_file("#{template_bin_path}/dev", "bin/dev") diff --git a/react_on_rails/lib/generators/react_on_rails/pro_generator.rb b/react_on_rails/lib/generators/react_on_rails/pro_generator.rb index d7e037a036..a644a2950c 100644 --- a/react_on_rails/lib/generators/react_on_rails/pro_generator.rb +++ b/react_on_rails/lib/generators/react_on_rails/pro_generator.rb @@ -32,7 +32,8 @@ def self.usage_path def run_generator # When invoked by install_generator, skip prerequisites (parent already validated) if options[:invoked_by_install] || prerequisites_met? - swap_base_gem_for_pro_in_gemfile unless options[:invoked_by_install] + return unless options[:invoked_by_install] || swap_base_gem_for_pro_in_gemfile + setup_pro add_pro_npm_dependencies update_imports_to_pro_package unless options[:invoked_by_install] @@ -54,6 +55,12 @@ def prerequisites_met? !(missing_base_installation? || missing_pro_gem?(force: true)) end + def pro_gem_version_requirement + return super if options[:invoked_by_install] + + ReactOnRails::VERSION + end + def missing_base_installation? return false if base_react_on_rails_installed? @@ -85,7 +92,7 @@ def swap_base_gem_for_pro_in_gemfile gemfile_path = File.join(destination_root, "Gemfile") unless File.exist?(gemfile_path) add_missing_gemfile_warning(gemfile_path) - return + return false end gemfile_content = File.read(gemfile_path) @@ -152,7 +159,7 @@ def swap_base_gem_for_pro_in_gemfile end updated_content = updated_lines.join - return if updated_content == gemfile_content + return true if updated_content == gemfile_content if has_pro_gem_entry say "â„šī¸ Existing react_on_rails_pro Gemfile entry detected; preserving current version constraint", :yellow @@ -160,7 +167,7 @@ def swap_base_gem_for_pro_in_gemfile if options[:pretend] say_status :pretend, "Would replace react_on_rails with react_on_rails_pro in Gemfile", :yellow - return + return true end original_gemfile_content = gemfile_content @@ -172,6 +179,7 @@ def swap_base_gem_for_pro_in_gemfile ) rescue StandardError => e add_gemfile_update_warning(gemfile_path, e) + false end # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity @@ -198,7 +206,7 @@ def bundle_install_after_gem_swap( ) if options[:pretend] say_status :pretend, "Skipping bundle install in --pretend mode", :yellow - return + return true end say "đŸ“Ļ Running bundle install after Gemfile update...", :yellow @@ -214,34 +222,40 @@ def bundle_install_after_gem_swap( wait_for_bundle_process(pid) end - return if install_status&.success? + return true if install_status&.success? rollback_message = rollback_gemfile_after_failed_bundle_install( gemfile_path: gemfile_path, original_gemfile_content: original_gemfile_content ) - failure_header = if install_status.nil? - "âš ī¸ Automatic bundle install timed out after #{ProSetup::AUTO_INSTALL_TIMEOUT} seconds." - else - "âš ī¸ Automatic bundle install failed after swapping Gemfile entries." - end + add_bundle_install_failure_warning(install_status, rollback_message) + false + rescue StandardError => e + rollback_message = rollback_gemfile_after_failed_bundle_install( + gemfile_path: gemfile_path, + original_gemfile_content: original_gemfile_content + ) GeneratorMessages.add_warning(<<~MSG.strip) - #{failure_header} + âš ī¸ Could not run automatic bundle install: #{e.class}: #{e.message} #{rollback_message} Please run manually: bundle install MSG - rescue StandardError => e - rollback_message = rollback_gemfile_after_failed_bundle_install( - gemfile_path: gemfile_path, - original_gemfile_content: original_gemfile_content - ) + false + end + + def add_bundle_install_failure_warning(install_status, rollback_message) + failure_header = if install_status.nil? + "âš ī¸ Automatic bundle install timed out after #{ProSetup::AUTO_INSTALL_TIMEOUT} seconds." + else + "âš ī¸ Automatic bundle install failed after swapping Gemfile entries." + end GeneratorMessages.add_warning(<<~MSG.strip) - âš ī¸ Could not run automatic bundle install: #{e.class}: #{e.message} + #{failure_header} #{rollback_message} Please run manually: @@ -305,7 +319,7 @@ def js_files_for_import_update def rewrite_react_on_rails_module_specifiers(content) static_import_specifier_pattern = %r{ (? - \A\s*(?:/\*.*?\*/\s*)?import(?:\s+type)?\s+.*?\s+from\s+| + \A\s*(?:/\*.*?\*/\s*)?(?:import|export)(?:\s+type)?\s+.*?\s+from\s+| \A\s*[\w\}\],\*\$\s]+\s+from\s+ ) (?["']) @@ -316,7 +330,7 @@ def rewrite_react_on_rails_module_specifiers(content) dynamic_or_require_specifier_pattern = %r{ (? (?["']) react-on-rails(?!-pro) @@ -409,6 +423,16 @@ def rewrite_non_comment_lines(content) in_multiline_template_literal = updated_template_literal_state in_block_comment = true if unclosed_block_comment_starts?(rewritten_line) rewritten_line + elsif line_contains_unescaped_backtick + rewritten_line, pending_multiline_module_call_depth, pending_multiline_static_import_specifier = + rewrite_line_before_template_literal_open( + line, + pending_multiline_module_call_depth, + pending_multiline_static_import_specifier + ) { |line_fragment| yield line_fragment } + in_multiline_template_literal = updated_template_literal_state + in_block_comment = true if unclosed_block_comment_starts?(rewritten_line) + rewritten_line else in_multiline_template_literal = updated_template_literal_state line @@ -493,7 +517,7 @@ def starts_pending_multiline_module_call?(line) end def rewrite_pending_module_specifier(line) - line.sub(%r{(?["'])react-on-rails(?!-pro)(?=(?:["']|/))}) do + line.gsub(%r{(?["'])react-on-rails(?!-pro)(?=(?:["']|/))}) do "#{Regexp.last_match[:quote]}react-on-rails-pro" end end @@ -529,7 +553,7 @@ def update_pending_multiline_static_import_tracking(line, pending_multiline_stat def starts_pending_multiline_static_import_specifier?(line) line_without_literals = line_without_string_literals_and_inline_comments(line) return false unless line_without_literals.match?( - /\A\s*(?:import(?:\s+type)?\b.*\bfrom|import|[\w\}\],\*\$\s]+\s+from)\s*\z/ + /\A\s*(?:(?:import|export)(?:\s+type)?\b.*\bfrom|import|export|[\w\}\],\*\$\s]+\s+from)\s*\z/ ) return false if line.match?(%r{\bfrom\s*(?:/\*[^*]*\*+(?:[^/*][^*]*\*+)*/\s*)*["']}) return false if line.match?(%r{\A\s*import\s*(?:/\*[^*]*\*+(?:[^/*][^*]*\*+)*/\s*)*["']}) @@ -571,13 +595,25 @@ def line_has_unescaped_backtick?(line, line_for_tracking: nil) def update_multiline_template_literal_state(in_multiline_template_literal, line) backticks = line.each_char.with_index.count do |char, index| - char == "`" && (index.zero? || line[index - 1] != "\\") + char == "`" && !character_escaped?(line, index) end return in_multiline_template_literal if backticks.even? !in_multiline_template_literal end + def character_escaped?(line, index) + backslash_count = 0 + scan_index = index - 1 + + while scan_index >= 0 && line[scan_index] == "\\" + backslash_count += 1 + scan_index -= 1 + end + + backslash_count.odd? + end + def rewrite_line_after_block_comment_close(line, pending_depth, pending_multiline_static_import_specifier) closing_index = line.index("*/") return [line, pending_depth, pending_multiline_static_import_specifier] unless closing_index @@ -608,13 +644,45 @@ def rewrite_line_after_template_literal_close(line, pending_depth, pending_multi ["#{template_literal_prefix}#{rewritten_fragment}", pending_depth, pending_multiline_static_import_specifier] end + def rewrite_line_before_template_literal_open(line, pending_depth, pending_multiline_static_import_specifier) + opening_index = first_unescaped_backtick_index(line) + return [line, pending_depth, pending_multiline_static_import_specifier] unless opening_index&.positive? + + line_prefix = line[0, opening_index] + template_literal_suffix = line[opening_index..] + rewritten_prefix = yield line_prefix + rewritten_prefix, pending_multiline_static_import_specifier = + update_pending_multiline_static_import_tracking(rewritten_prefix, pending_multiline_static_import_specifier) + rewritten_prefix, pending_depth = + update_pending_multiline_module_call_tracking(rewritten_prefix, pending_depth) + ["#{rewritten_prefix}#{template_literal_suffix}", pending_depth, pending_multiline_static_import_specifier] + end + def first_unescaped_backtick_index(line) + quote_state = nil + line.each_char.with_index do |char, index| - return index if char == "`" && (index.zero? || line[index - 1] != "\\") + quote_state = next_quote_state(quote_state, char, line, index) + next if quote_state + return nil if line[index, 2] == "//" + + return index if char == "`" && !character_escaped?(line, index) end nil end + def next_quote_state(current_state, char, line, index) + if current_state + return nil if char == current_state && !character_escaped?(line, index) + + return current_state + end + + return char if ["'", '"'].include?(char) + + nil + end + # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity def match_multiline_parenthesized_base_gem(lines, start_index) start_line = lines[start_index] diff --git a/react_on_rails/spec/react_on_rails/generators/install_generator_spec.rb b/react_on_rails/spec/react_on_rails/generators/install_generator_spec.rb index a2d245a94e..bdfd181648 100644 --- a/react_on_rails/spec/react_on_rails/generators/install_generator_spec.rb +++ b/react_on_rails/spec/react_on_rails/generators/install_generator_spec.rb @@ -2181,6 +2181,24 @@ class ActiveSupport::TestCase rsc_pro_install_generator.send(:invoke_generators) end + + it "forwards RSC mode to the Redux generator for --rsc-pro --redux" do + rsc_pro_redux_install_generator = described_class.new([], { pretend: true, rsc_pro: true, redux: true }) + + allow(rsc_pro_redux_install_generator).to receive(:ensure_shakapacker_installed) + allow(rsc_pro_redux_install_generator).to receive(:setup_react_dependencies) + + expect(rsc_pro_redux_install_generator).to receive(:invoke) + .with("react_on_rails:base", [], hash_including(pro: true, rsc: true, pretend: true)) + expect(rsc_pro_redux_install_generator).to receive(:invoke) + .with("react_on_rails:react_with_redux", [], hash_including(rsc: true, pretend: true)) + expect(rsc_pro_redux_install_generator).to receive(:invoke) + .with("react_on_rails:pro", [], hash_including(pretend: true)) + expect(rsc_pro_redux_install_generator).to receive(:invoke) + .with("react_on_rails:rsc", [], hash_including(pretend: true)) + + rsc_pro_redux_install_generator.send(:invoke_generators) + end end context "when detecting existing bin-files on *nix" do @@ -3378,5 +3396,41 @@ class ActiveSupport::TestCase assert_file "bin/switch-bundler" assert_file "bin/shakapacker-precompile-hook" end + + it "keeps DEFAULT_ROUTE unchanged in custom bin/dev files for non-RSC installs" do + custom_bin_dev = <<~RUBY + #!/usr/bin/env ruby + DEFAULT_ROUTE = "hello_world" + RUBY + simulate_existing_file("bin/dev", custom_bin_dev) + + Dir.chdir(destination_root) do + install_generator.send(:add_bin_scripts) + end + + assert_file "bin/dev", custom_bin_dev + end + + it "warns instead of rewriting custom bin/dev files for --rsc installs" do + rsc_install_generator = described_class.new([], { rsc: true }, destination_root: destination_root) + custom_bin_dev = <<~RUBY + #!/usr/bin/env ruby + DEFAULT_ROUTE = "hello_world" + RUBY + simulate_existing_file("bin/dev", custom_bin_dev) + + allow(rsc_install_generator).to receive(:say_status).and_call_original + expect(rsc_install_generator).to receive(:say_status).with( + :warn, + a_string_matching(%r{Custom bin/dev detected: update DEFAULT_ROUTE to "hello_server" manually for --rsc}), + :yellow + ) + + Dir.chdir(destination_root) do + rsc_install_generator.send(:add_bin_scripts) + end + + assert_file "bin/dev", custom_bin_dev + end end end diff --git a/react_on_rails/spec/react_on_rails/generators/js_dependency_manager_spec.rb b/react_on_rails/spec/react_on_rails/generators/js_dependency_manager_spec.rb index 50ca48e84f..36607fc3b4 100644 --- a/react_on_rails/spec/react_on_rails/generators/js_dependency_manager_spec.rb +++ b/react_on_rails/spec/react_on_rails/generators/js_dependency_manager_spec.rb @@ -422,6 +422,19 @@ def errors expect(instance.add_npm_dependencies_called?).to be(true) end + it "pins react and react-dom to the RSC-compatible 19.0.x track when RSC is enabled" do + instance.use_rsc = true + + instance.send(:add_react_dependencies) + + expect(instance.add_npm_dependencies_calls).to include( + a_hash_including( + packages: ["react@~19.0.4", "react-dom@~19.0.4", "prop-types"], + dev: false + ) + ) + end + it "adds warning when add_packages fails" do instance.add_npm_dependencies_result = false instance.system_result = false @@ -432,6 +445,16 @@ def errors expect(warnings.size).to be > 0 expect(warnings.first.to_s).to include("Failed to add React dependencies") end + + it "warns with the pinned React install command when the RSC add fails" do + instance.use_rsc = true + instance.add_npm_dependencies_result = false + + instance.send(:add_react_dependencies) + + expect(warnings.size).to be > 0 + expect(warnings.first.to_s).to include("npm install react@~19.0.4 react-dom@~19.0.4 prop-types") + end end describe "#add_css_dependencies" do diff --git a/react_on_rails/spec/react_on_rails/generators/pro_generator_spec.rb b/react_on_rails/spec/react_on_rails/generators/pro_generator_spec.rb index de8f971fec..ca0531b166 100644 --- a/react_on_rails/spec/react_on_rails/generators/pro_generator_spec.rb +++ b/react_on_rails/spec/react_on_rails/generators/pro_generator_spec.rb @@ -45,7 +45,7 @@ expect(generator.send(:missing_pro_gem?, force: true)).to be true expect(Bundler).to have_received(:with_unbundled_env) expect(Process).to have_received(:spawn) - .with(a_string_matching(/\Abundle add react_on_rails_pro --version='~> [\d.]+' --strict\z/), + .with("bundle add react_on_rails_pro --version='#{ReactOnRails::VERSION}' --strict", out: anything, err: anything) error_text = GeneratorMessages.messages.join("\n") # Standalone message should NOT mention --pro flag @@ -55,6 +55,29 @@ end end + describe "#run_generator" do + let(:generator) { described_class.new } + + before do + allow(generator).to receive(:print_generator_messages) + end + + it "stops before setup when the Gemfile swap fails" do + allow(generator).to receive_messages(prerequisites_met?: true, swap_base_gem_for_pro_in_gemfile: false) + allow(generator).to receive(:setup_pro) + allow(generator).to receive(:add_pro_npm_dependencies) + allow(generator).to receive(:update_imports_to_pro_package) + allow(generator).to receive(:print_success_message) + + generator.run_generator + + expect(generator).not_to have_received(:setup_pro) + expect(generator).not_to have_received(:add_pro_npm_dependencies) + expect(generator).not_to have_received(:update_imports_to_pro_package) + expect(generator).not_to have_received(:print_success_message) + end + end + describe "#swap_base_gem_for_pro_in_gemfile" do let(:generator) { described_class.new } let(:gemfile_path) { File.join(destination_root, "Gemfile") } @@ -606,6 +629,7 @@ simulate_existing_file("app/javascript/packs/application.js", <<~JS) import ReactOnRails from "react-on-rails"; const ror = require("react-on-rails"); + const commentedRequire = require(/* webpackIgnore: true */ "react-on-rails"); const lazyRor = import(/* webpackChunkName: "ror" */ "react-on-rails"); const lazyRorMultiline = import( /* webpackMode: "lazy" */ @@ -620,6 +644,7 @@ import ReactOnRailsServer from "react-on-rails/server"; import ReactOnRailsClient from "react-on-rails/client"; import "react-on-rails"; + export { default as ReactOnRailsExport } from "react-on-rails"; import CustomPackage from "react-on-rails-utils"; const scoped = "@scope/react-on-rails"; const url = "https://cdn.example.com/react-on-rails/client.js"; @@ -650,6 +675,7 @@ expect(File.read(application_js_path)).to include('import ReactOnRails from "react-on-rails-pro";') expect(File.read(application_js_path)).to include('require("react-on-rails-pro")') + expect(File.read(application_js_path)).to include('require(/* webpackIgnore: true */ "react-on-rails-pro")') expect(File.read(application_js_path)).to include('import(/* webpackChunkName: "ror" */ "react-on-rails-pro")') expect(File.read(application_js_path)).to include('"react-on-rails-pro/client"') expect(File.read(application_js_path)).to include('"react-on-rails-pro/server"') @@ -659,6 +685,9 @@ expect(File.read(application_js_path)).to include('import ReactOnRailsServer from "react-on-rails-pro/server";') expect(File.read(application_js_path)).to include('import ReactOnRailsClient from "react-on-rails-pro/client";') expect(File.read(application_js_path)).to include('import "react-on-rails-pro";') + expect(File.read(application_js_path)).to include( + 'export { default as ReactOnRailsExport } from "react-on-rails-pro";' + ) expect(File.read(application_js_path)).to include('import CustomPackage from "react-on-rails-utils";') expect(File.read(application_js_path)).to include('const scoped = "@scope/react-on-rails";') expect(File.read(application_js_path)).to include('const url = "https://cdn.example.com/react-on-rails/client.js";') @@ -743,6 +772,22 @@ expect(rewritten).to include('"react-on-rails-pro";') end + it "rewrites re-export statements from react-on-rails" do + source = <<~JS + export { default as ReactOnRailsExport } from "react-on-rails"; + export type { + ReactOnRailsComponent + } from + "react-on-rails"; + JS + + rewritten = generator.send(:rewrite_react_on_rails_module_specifiers, source) + + expect(rewritten).to include('export { default as ReactOnRailsExport } from "react-on-rails-pro";') + expect(rewritten).to include('"react-on-rails-pro";') + expect(rewritten.scan("react-on-rails-pro").size).to eq(2) + end + it "does not rewrite imports inside multiline template literals" do source = <<~JS const importTemplate = ` @@ -774,6 +819,36 @@ expect(rewritten.scan("react-on-rails-pro").size).to eq(1) end + it "rewrites imports that appear before a multiline template literal opens on the same line" do + source = <<~JS + const ror = require("react-on-rails"); const importTemplate = ` + import ReactOnRails from "react-on-rails"; + `; + JS + + rewritten = generator.send(:rewrite_react_on_rails_module_specifiers, source) + + expect(rewritten).to include('const ror = require("react-on-rails-pro");') + expect(rewritten).to include('import ReactOnRails from "react-on-rails";') + expect(rewritten.scan("react-on-rails-pro").size).to eq(1) + end + + it "rewrites all matching specifiers on a pending continuation line" do + source = <<~JS + import { + ReactOnRailsComponent + } from + "react-on-rails"; import ReactOnRails from "react-on-rails"; + JS + + rewritten = generator.send(:rewrite_react_on_rails_module_specifiers, source) + + expect(rewritten.scan("react-on-rails-pro").size).to eq(2) + expect(rewritten).to include( + '"react-on-rails-pro"; import ReactOnRails from "react-on-rails-pro";' + ) + end + it "does not treat quoted backticks as multiline template delimiters" do source = <<~JS const marker = "`"; // should not toggle template-literal tracking From 62040f90bba122166115d27e9d9c89a5346967a2 Mon Sep 17 00:00:00 2001 From: Justin Gordon Date: Sun, 29 Mar 2026 18:02:51 -1000 Subject: [PATCH 32/37] Avoid partial standalone Pro upgrades --- .../react_on_rails/pro_generator.rb | 86 +++++++++-- .../generators/pro_generator_spec.rb | 139 +++++++++++++++--- 2 files changed, 190 insertions(+), 35 deletions(-) diff --git a/react_on_rails/lib/generators/react_on_rails/pro_generator.rb b/react_on_rails/lib/generators/react_on_rails/pro_generator.rb index a644a2950c..b494e1cc12 100644 --- a/react_on_rails/lib/generators/react_on_rails/pro_generator.rb +++ b/react_on_rails/lib/generators/react_on_rails/pro_generator.rb @@ -30,9 +30,13 @@ def self.usage_path hide: true def run_generator + original_gemfile_content_before_prerequisites = read_current_gemfile_content + # When invoked by install_generator, skip prerequisites (parent already validated) if options[:invoked_by_install] || prerequisites_met? - return unless options[:invoked_by_install] || swap_base_gem_for_pro_in_gemfile + return unless options[:invoked_by_install] || swap_base_gem_for_pro_in_gemfile( + original_gemfile_content_for_rollback: original_gemfile_content_before_prerequisites + ) setup_pro add_pro_npm_dependencies @@ -51,14 +55,17 @@ def run_generator private - def prerequisites_met? - !(missing_base_installation? || missing_pro_gem?(force: true)) - end + def read_current_gemfile_content + gemfile_path = File.join(destination_root, "Gemfile") + return unless File.exist?(gemfile_path) - def pro_gem_version_requirement - return super if options[:invoked_by_install] + File.read(gemfile_path) + rescue StandardError + nil + end - ReactOnRails::VERSION + def prerequisites_met? + !(missing_base_installation? || missing_pro_gem?(force: true)) end def missing_base_installation? @@ -88,7 +95,7 @@ def add_pro_npm_dependencies end # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity - def swap_base_gem_for_pro_in_gemfile + def swap_base_gem_for_pro_in_gemfile(original_gemfile_content_for_rollback: nil) gemfile_path = File.join(destination_root, "Gemfile") unless File.exist?(gemfile_path) add_missing_gemfile_warning(gemfile_path) @@ -100,9 +107,12 @@ def swap_base_gem_for_pro_in_gemfile base_gem_pattern = /^(\s*)gem(?:\s+|\(\s*)(["'])react_on_rails\2(?=\s*(?:,|\)|#|$))/ has_pro_gem_entry = gemfile_content.match?(pro_gem_pattern) + had_pro_gem_entry_before_prerequisites = + original_gemfile_content_for_rollback&.match?(pro_gem_pattern) gemfile_lines = gemfile_content.lines updated_lines = [] pro_entry_added = has_pro_gem_entry + base_gem_entry_found = false line_index = 0 while line_index < gemfile_lines.length @@ -110,6 +120,7 @@ def swap_base_gem_for_pro_in_gemfile multiline_parenthesized_match = match_multiline_parenthesized_base_gem(gemfile_lines, line_index) if multiline_parenthesized_match + base_gem_entry_found = true unless pro_entry_added indentation = multiline_parenthesized_match[:indentation] quote = multiline_parenthesized_match[:quote] @@ -134,6 +145,8 @@ def swap_base_gem_for_pro_in_gemfile next end + base_gem_entry_found = true + unless pro_entry_added indentation = match[1] quote = match[2] @@ -159,7 +172,14 @@ def swap_base_gem_for_pro_in_gemfile end updated_content = updated_lines.join - return true if updated_content == gemfile_content + if updated_content == gemfile_content + unless base_gem_entry_found || had_pro_gem_entry_before_prerequisites + add_missing_react_on_rails_gem_warning + return false + end + + return true + end if has_pro_gem_entry say "â„šī¸ Existing react_on_rails_pro Gemfile entry detected; preserving current version constraint", :yellow @@ -170,7 +190,7 @@ def swap_base_gem_for_pro_in_gemfile return true end - original_gemfile_content = gemfile_content + original_gemfile_content = original_gemfile_content_for_rollback || gemfile_content atomic_write_file(gemfile_path, updated_content) say "✅ Replaced react_on_rails with react_on_rails_pro in Gemfile", :green bundle_install_after_gem_swap( @@ -394,6 +414,16 @@ def add_gemfile_update_warning(gemfile_path, error) MSG end + def add_missing_react_on_rails_gem_warning + GeneratorMessages.add_warning(<<~MSG.strip) + âš ī¸ Could not find react_on_rails or react_on_rails_pro in Gemfile. + + If this app declares the gem in a .gemspec or another included file, + please update it manually: + replace react_on_rails with react_on_rails_pro + MSG + end + # rubocop:disable Metrics/AbcSize, Metrics/BlockLength, Metrics/CyclomaticComplexity, Metrics/MethodLength # rubocop:disable Metrics/PerceivedComplexity, Style/ExplicitBlockArgument def rewrite_non_comment_lines(content) @@ -404,7 +434,7 @@ def rewrite_non_comment_lines(content) content.lines.map do |line| stripped = line.lstrip - line_for_template_literal_state = line_for_template_literal_tracking(line) + line_for_template_literal_state = line_for_template_literal_tracking(line, in_block_comment: in_block_comment) line_contains_unescaped_backtick = line_has_unescaped_backtick?(line, line_for_tracking: line_for_template_literal_state) @@ -583,9 +613,37 @@ def line_without_string_literals_and_inline_comments(line, strip_ruby_comments: line_without_comments.sub(/\s*#.*$/, "") end - def line_for_template_literal_tracking(line) + def line_for_template_literal_tracking(line, in_block_comment: false) line_without_quoted_literals = line.gsub(/"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'/, "") - line_without_quoted_literals.sub(%r{//.*$}, "") + line_for_comment_aware_template_tracking( + line_without_quoted_literals, + in_block_comment: in_block_comment + ) + end + + def line_for_comment_aware_template_tracking(line, in_block_comment:) + tracked_line = +"" + scan_index = 0 + + while scan_index < line.length + if in_block_comment + closing_index = line.index("*/", scan_index) + return tracked_line unless closing_index + + in_block_comment = false + scan_index = closing_index + 2 + elsif line[scan_index, 2] == "//" + break + elsif line[scan_index, 2] == "/*" + in_block_comment = true + scan_index += 2 + else + tracked_line << line[scan_index] + scan_index += 1 + end + end + + tracked_line end def line_has_unescaped_backtick?(line, line_for_tracking: nil) @@ -748,7 +806,7 @@ def build_pro_gem_replacement_line(indentation:, quote:, suffix:, parenthesized_ normalized_suffix = normalized_suffix.sub(/\)(\s*(?:#.*)?\n)\z/, '\1') if parenthesized_gem_call "#{indentation}gem #{quote}react_on_rails_pro#{quote}, " \ - "#{quote}#{ReactOnRails::VERSION}#{quote}#{normalized_suffix}" + "#{quote}#{pro_gem_version_requirement}#{quote}#{normalized_suffix}" end def print_success_message diff --git a/react_on_rails/spec/react_on_rails/generators/pro_generator_spec.rb b/react_on_rails/spec/react_on_rails/generators/pro_generator_spec.rb index ca0531b166..3d81e9446f 100644 --- a/react_on_rails/spec/react_on_rails/generators/pro_generator_spec.rb +++ b/react_on_rails/spec/react_on_rails/generators/pro_generator_spec.rb @@ -45,7 +45,7 @@ expect(generator.send(:missing_pro_gem?, force: true)).to be true expect(Bundler).to have_received(:with_unbundled_env) expect(Process).to have_received(:spawn) - .with("bundle add react_on_rails_pro --version='#{ReactOnRails::VERSION}' --strict", + .with("bundle add react_on_rails_pro --version='#{generator.send(:pro_gem_version_requirement)}' --strict", out: anything, err: anything) error_text = GeneratorMessages.messages.join("\n") # Standalone message should NOT mention --pro flag @@ -57,8 +57,11 @@ describe "#run_generator" do let(:generator) { described_class.new } + let(:gemfile_path) { File.join(destination_root, "Gemfile") } before do + prepare_destination + allow(generator).to receive(:destination_root).and_return(destination_root) allow(generator).to receive(:print_generator_messages) end @@ -76,6 +79,48 @@ expect(generator).not_to have_received(:update_imports_to_pro_package) expect(generator).not_to have_received(:print_success_message) end + + it "passes the pre-prerequisite Gemfile snapshot into rollback handling" do + original_gemfile = <<~RUBY + source "https://rubygems.org" + gem "react_on_rails", "~> 16.0" + RUBY + simulate_existing_file("Gemfile", original_gemfile) + + allow(generator).to receive(:base_react_on_rails_installed?).and_return(true) + allow(generator).to receive(:attempt_pro_gem_auto_install) do + File.write(gemfile_path, <<~RUBY) + source "https://rubygems.org" + gem "react_on_rails", "~> 16.0" + gem "react_on_rails_pro", "~> 16.0" + RUBY + true + end + allow(generator).to receive(:setup_pro) + allow(generator).to receive(:add_pro_npm_dependencies) + allow(generator).to receive(:update_imports_to_pro_package) + allow(generator).to receive(:print_success_message) + + captured_original_gemfile_content = nil + allow(generator).to receive(:bundle_install_after_gem_swap) do |gemfile_path:, original_gemfile_content:| + captured_original_gemfile_content = original_gemfile_content + generator.send( + :rollback_gemfile_after_failed_bundle_install, + gemfile_path: gemfile_path, + original_gemfile_content: original_gemfile_content + ) + false + end + + generator.run_generator + + expect(captured_original_gemfile_content).to eq(original_gemfile) + expect(File.read(gemfile_path)).to eq(original_gemfile) + expect(generator).not_to have_received(:setup_pro) + expect(generator).not_to have_received(:add_pro_npm_dependencies) + expect(generator).not_to have_received(:update_imports_to_pro_package) + expect(generator).not_to have_received(:print_success_message) + end end describe "#swap_base_gem_for_pro_in_gemfile" do @@ -97,7 +142,7 @@ generator.send(:swap_base_gem_for_pro_in_gemfile) gemfile_content = File.read(gemfile_path) - expected_version = ReactOnRails::VERSION + expected_version = generator.send(:pro_gem_version_requirement) expect(gemfile_content).to include("gem \"react_on_rails_pro\", \"#{expected_version}\"") expect(gemfile_content).not_to match(/gem\s+["']react_on_rails["']/) expect(generator).to have_received(:bundle_install_after_gem_swap) @@ -113,7 +158,7 @@ generator.send(:swap_base_gem_for_pro_in_gemfile) gemfile_content = File.read(gemfile_path) - expected_version = ReactOnRails::VERSION + expected_version = generator.send(:pro_gem_version_requirement) expect(gemfile_content).to include("gem \"react_on_rails_pro\", \"#{expected_version}\", require: false") expect(gemfile_content).to include("if: ENV[\"ENABLE_ROR\"]") expect(gemfile_content).not_to include("gem \"react_on_rails\",") @@ -129,7 +174,7 @@ generator.send(:swap_base_gem_for_pro_in_gemfile) gemfile_content = File.read(gemfile_path) - expected_version = ReactOnRails::VERSION + expected_version = generator.send(:pro_gem_version_requirement) expect(gemfile_content).to include("gem \"react_on_rails_pro\", \"#{expected_version}\", require: false") expect(gemfile_content).not_to include(">= 15.0") expect(gemfile_content).not_to include("< 16.0") @@ -148,7 +193,7 @@ generator.send(:swap_base_gem_for_pro_in_gemfile) gemfile_content = File.read(gemfile_path) - expected_version = ReactOnRails::VERSION + expected_version = generator.send(:pro_gem_version_requirement) expect(gemfile_content).to include(" gem \"react_on_rails_pro\", \"#{expected_version}\"") expect(generator).to have_received(:bundle_install_after_gem_swap) end @@ -164,7 +209,7 @@ generator.send(:swap_base_gem_for_pro_in_gemfile) gemfile_content = File.read(gemfile_path) - expected_version = ReactOnRails::VERSION + expected_version = generator.send(:pro_gem_version_requirement) expect(gemfile_content).to include("gem \"react_on_rails_pro\", \"#{expected_version}\"") expect(gemfile_content).not_to include("gem \"react_on_rails\",") expect(gemfile_content).not_to include(" \"~> 16.0\"") @@ -182,7 +227,7 @@ generator.send(:swap_base_gem_for_pro_in_gemfile) gemfile_content = File.read(gemfile_path) - expected_version = ReactOnRails::VERSION + expected_version = generator.send(:pro_gem_version_requirement) expect(gemfile_content).to include("gem \"react_on_rails_pro\", \"#{expected_version}\"") expect(gemfile_content).not_to include("gem \"react_on_rails\", # pinned for compatibility") expect(gemfile_content).not_to include(" \"~> 16.0\"") @@ -199,7 +244,7 @@ generator.send(:swap_base_gem_for_pro_in_gemfile) gemfile_content = File.read(gemfile_path) - expected_version = ReactOnRails::VERSION + expected_version = generator.send(:pro_gem_version_requirement) expect(gemfile_content).to include("gem \"react_on_rails_pro\", \"#{expected_version}\"") expect(gemfile_content).not_to include("gem \"react_on_rails\",#pinned for compatibility") expect(gemfile_content).not_to include(" \"~> 16.0\"") @@ -218,7 +263,7 @@ generator.send(:swap_base_gem_for_pro_in_gemfile) gemfile_content = File.read(gemfile_path) - expected_version = ReactOnRails::VERSION + expected_version = generator.send(:pro_gem_version_requirement) expect(gemfile_content).to include("gem \"react_on_rails_pro\", \"#{expected_version}\"") expect(gemfile_content).to include("gem \"rails\"") expect(gemfile_content).not_to include("~> 16.0") @@ -237,7 +282,7 @@ generator.send(:swap_base_gem_for_pro_in_gemfile) gemfile_content = File.read(gemfile_path) - expected_version = ReactOnRails::VERSION + expected_version = generator.send(:pro_gem_version_requirement) expect(gemfile_content).to include("gem \"react_on_rails_pro\", \"#{expected_version}\"") expect(gemfile_content).to include("gem \"rails\"") expect(gemfile_content).not_to include("~> 16.0") @@ -254,7 +299,7 @@ generator.send(:swap_base_gem_for_pro_in_gemfile) gemfile_content = File.read(gemfile_path) - expected_version = ReactOnRails::VERSION + expected_version = generator.send(:pro_gem_version_requirement) expect(gemfile_content).to include("gem \"react_on_rails_pro\", \"#{expected_version}\"") expect(gemfile_content).to include("gem \"rails\"") end @@ -269,7 +314,7 @@ generator.send(:swap_base_gem_for_pro_in_gemfile) gemfile_content = File.read(gemfile_path) - expected_version = ReactOnRails::VERSION + expected_version = generator.send(:pro_gem_version_requirement) expect(gemfile_content).to include("gem 'react_on_rails_pro', '#{expected_version}'") expect(generator).to have_received(:bundle_install_after_gem_swap) end @@ -284,7 +329,7 @@ generator.send(:swap_base_gem_for_pro_in_gemfile) gemfile_content = File.read(gemfile_path) - expected_version = ReactOnRails::VERSION + expected_version = generator.send(:pro_gem_version_requirement) expect(gemfile_content).to include("gem \"react_on_rails_pro\", \"#{expected_version}\"") expect(gemfile_content).not_to include("gem \"react_on_rails_pro\", \"#{expected_version}\")") expect(gemfile_content).not_to include('gem("react_on_rails"') @@ -301,7 +346,7 @@ generator.send(:swap_base_gem_for_pro_in_gemfile) gemfile_content = File.read(gemfile_path) - expected_version = ReactOnRails::VERSION + expected_version = generator.send(:pro_gem_version_requirement) expect(gemfile_content).to include("gem \"react_on_rails_pro\", \"#{expected_version}\", require: false") expect(gemfile_content).to include('if: ENV.fetch("ENABLE_ROR", false)') expect(gemfile_content).not_to include("false))") @@ -320,7 +365,7 @@ generator.send(:swap_base_gem_for_pro_in_gemfile) gemfile_content = File.read(gemfile_path) - expected_version = ReactOnRails::VERSION + expected_version = generator.send(:pro_gem_version_requirement) expect(gemfile_content).to include("gem \"react_on_rails_pro\", \"#{expected_version}\"") expect(gemfile_content).not_to include('"react_on_rails"') expect(gemfile_content).not_to include('"~> 16.0"') @@ -340,7 +385,7 @@ generator.send(:swap_base_gem_for_pro_in_gemfile) gemfile_content = File.read(gemfile_path) - expected_version = ReactOnRails::VERSION + expected_version = generator.send(:pro_gem_version_requirement) expect(gemfile_content).to include("gem \"react_on_rails_pro\", \"#{expected_version}\",") expect(gemfile_content).to include("require: false") expect(gemfile_content).not_to include('gem("react_on_rails"') @@ -362,7 +407,7 @@ generator.send(:swap_base_gem_for_pro_in_gemfile) gemfile_content = File.read(gemfile_path) - expected_version = ReactOnRails::VERSION + expected_version = generator.send(:pro_gem_version_requirement) expect(gemfile_content).to include("gem \"react_on_rails_pro\", \"#{expected_version}\",") expect(gemfile_content).to include("require: false,") expect(gemfile_content).to include('if: ENV["ENABLE_ROR"]') @@ -384,7 +429,7 @@ generator.send(:swap_base_gem_for_pro_in_gemfile) gemfile_content = File.read(gemfile_path) - expected_version = ReactOnRails::VERSION + expected_version = generator.send(:pro_gem_version_requirement) expect(gemfile_content).to include("gem \"react_on_rails_pro\", \"#{expected_version}\"") expect(gemfile_content).not_to include('"~> 16.0"') expect(gemfile_content).not_to include("gem(") @@ -404,7 +449,7 @@ expect { generator.send(:swap_base_gem_for_pro_in_gemfile) }.not_to raise_error gemfile_content = File.read(gemfile_path) - expected_version = ReactOnRails::VERSION + expected_version = generator.send(:pro_gem_version_requirement) expect(gemfile_content).to include("gem \"react_on_rails_pro\", \"#{expected_version}\",") expect(gemfile_content).to include("require: false") expect(gemfile_content).not_to include("gem(") @@ -425,7 +470,7 @@ generator.send(:swap_base_gem_for_pro_in_gemfile) gemfile_content = File.read(gemfile_path) - expected_version = ReactOnRails::VERSION + expected_version = generator.send(:pro_gem_version_requirement) expect(gemfile_content).to include("gem \"react_on_rails_pro\", \"#{expected_version}\"") expect(gemfile_content).not_to include('"react_on_rails"') expect(gemfile_content).to include('if: ENV.fetch("ENABLE_ROR") { true }') @@ -505,7 +550,7 @@ generator.send(:swap_base_gem_for_pro_in_gemfile) gemfile_content = File.read(gemfile_path) - expected_version = ReactOnRails::VERSION + expected_version = generator.send(:pro_gem_version_requirement) expect(gemfile_content).to include("gem \"react_on_rails_pro\", \"#{expected_version}\"") expect(gemfile_content).not_to include("gem \"react_on_rails\" # pinned for compatibility") expect(generator).to have_received(:bundle_install_after_gem_swap) @@ -527,6 +572,44 @@ expect(File.read(gemfile_path)).to eq(original_content) end + it "returns false and warns when neither base nor pro gem entries are present" do + simulate_existing_file("Gemfile", <<~RUBY) + source "https://rubygems.org" + gem "rails" + RUBY + allow(generator).to receive(:bundle_install_after_gem_swap) + + result = generator.send(:swap_base_gem_for_pro_in_gemfile) + + expect(result).to be(false) + expect(File.read(gemfile_path)).to include('gem "rails"') + expect(generator).not_to have_received(:bundle_install_after_gem_swap) + expect(GeneratorMessages.messages.join("\n")) + .to include("Could not find react_on_rails or react_on_rails_pro in Gemfile") + end + + it "returns false when only an auto-installed Pro gem entry is present" do + simulate_existing_file("Gemfile", <<~RUBY) + source "https://rubygems.org" + gem "rails" + gem "react_on_rails_pro", "~> 16.0" + RUBY + allow(generator).to receive(:bundle_install_after_gem_swap) + + result = generator.send( + :swap_base_gem_for_pro_in_gemfile, + original_gemfile_content_for_rollback: <<~RUBY + source "https://rubygems.org" + gem "rails" + RUBY + ) + + expect(result).to be(false) + expect(generator).not_to have_received(:bundle_install_after_gem_swap) + expect(GeneratorMessages.messages.join("\n")) + .to include("Could not find react_on_rails or react_on_rails_pro in Gemfile") + end + it "preserves Gemfile file mode when writing updates" do simulate_existing_file("Gemfile", <<~RUBY) source "https://rubygems.org" @@ -876,6 +959,20 @@ expect(rewritten.scan("react-on-rails-pro").size).to eq(1) end + it "ignores backticks that only appear inside multiline block comments" do + source = <<~JS + /* docs start + docs use `code + end */ + import ReactOnRails from "react-on-rails"; + JS + + rewritten = generator.send(:rewrite_react_on_rails_module_specifiers, source) + + expect(rewritten).to include("docs use `code") + expect(rewritten).to include('import ReactOnRails from "react-on-rails-pro";') + end + it "detects unclosed block comments when multiple block markers appear on one line" do source_line = "/* closed */ const keep = true; /* unclosed" From c9d36f809424f40fa96025f395b2facdf88c8853 Mon Sep 17 00:00:00 2001 From: Justin Gordon Date: Mon, 30 Mar 2026 22:45:30 -1000 Subject: [PATCH 33/37] Address review: node_modules exclusion, block comment tracking, narrow rescue - Exclude node_modules from js_files_for_import_update glob to prevent corrupting installed npm packages in non-standard layouts - Use rewritten_line (not line) for unclosed_block_comment_starts? calls at lines 495 and 512, consistent with all other call sites - Narrow rescue StandardError to rescue ArgumentError in rsc_pro_prerelease_note since Gem::Version.new is the only failure path Co-Authored-By: Claude Opus 4.6 (1M context) --- .../lib/generators/react_on_rails/pro_generator.rb | 5 +++-- react_on_rails/lib/generators/react_on_rails/pro_setup.rb | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/react_on_rails/lib/generators/react_on_rails/pro_generator.rb b/react_on_rails/lib/generators/react_on_rails/pro_generator.rb index b494e1cc12..4de2b2e399 100644 --- a/react_on_rails/lib/generators/react_on_rails/pro_generator.rb +++ b/react_on_rails/lib/generators/react_on_rails/pro_generator.rb @@ -333,6 +333,7 @@ def js_files_for_import_update next [] unless Dir.exist?(root_path) Dir.glob(File.join(root_path, "**", "*.{#{js_extensions}}")) + .reject { |f| f.include?("/node_modules/") } end.uniq end @@ -491,7 +492,7 @@ def rewrite_non_comment_lines(content) ) rewritten_line, pending_multiline_module_call_depth = update_pending_multiline_module_call_tracking(rewritten_line, pending_multiline_module_call_depth) - in_block_comment = true if unclosed_block_comment_starts?(line) + in_block_comment = true if unclosed_block_comment_starts?(rewritten_line) rewritten_line else in_block_comment = true @@ -508,7 +509,7 @@ def rewrite_non_comment_lines(content) ) rewritten_line, pending_multiline_module_call_depth = update_pending_multiline_module_call_tracking(rewritten_line, pending_multiline_module_call_depth) - in_block_comment = true if unclosed_block_comment_starts?(line) + in_block_comment = true if unclosed_block_comment_starts?(rewritten_line) rewritten_line end end.join diff --git a/react_on_rails/lib/generators/react_on_rails/pro_setup.rb b/react_on_rails/lib/generators/react_on_rails/pro_setup.rb index 0693ba2240..b84301eed7 100644 --- a/react_on_rails/lib/generators/react_on_rails/pro_setup.rb +++ b/react_on_rails/lib/generators/react_on_rails/pro_setup.rb @@ -106,7 +106,7 @@ def rsc_pro_prerelease_note "Note: #{PRO_GEM_NAME} #{ReactOnRails::VERSION} may not be published yet. " \ "If you are testing from source, use a local Gemfile `path:` option." - rescue StandardError + rescue ArgumentError "" end From b78c4ddbabe71a095f94be513c4e853f2fdcb5a2 Mon Sep 17 00:00:00 2001 From: Justin Gordon Date: Wed, 1 Apr 2026 15:54:56 -1000 Subject: [PATCH 34/37] Fix unresolved review findings on pro generator swap/import handling --- .../react_on_rails/pro_generator.rb | 29 ++++++++++-- .../generators/pro_generator_spec.rb | 44 +++++++++++++++++-- 2 files changed, 66 insertions(+), 7 deletions(-) diff --git a/react_on_rails/lib/generators/react_on_rails/pro_generator.rb b/react_on_rails/lib/generators/react_on_rails/pro_generator.rb index 4de2b2e399..d36dc8169b 100644 --- a/react_on_rails/lib/generators/react_on_rails/pro_generator.rb +++ b/react_on_rails/lib/generators/react_on_rails/pro_generator.rb @@ -174,7 +174,12 @@ def swap_base_gem_for_pro_in_gemfile(original_gemfile_content_for_rollback: nil) updated_content = updated_lines.join if updated_content == gemfile_content unless base_gem_entry_found || had_pro_gem_entry_before_prerequisites - add_missing_react_on_rails_gem_warning + rollback_message = rollback_gemfile_after_failed_swap_precondition( + gemfile_path: gemfile_path, + original_gemfile_content: original_gemfile_content_for_rollback, + current_gemfile_content: gemfile_content + ) + add_missing_react_on_rails_gem_warning(rollback_message: rollback_message) return false end @@ -359,7 +364,7 @@ def rewrite_react_on_rails_module_specifiers(content) }x side_effect_import_pattern = %r{ - \A(?\s*import\s+) + \A(?\s*(?:/\*.*?\*/\s*)*import\s+) (?["']) react-on-rails(?!-pro) (?=(?:["']|/)) @@ -415,16 +420,33 @@ def add_gemfile_update_warning(gemfile_path, error) MSG end - def add_missing_react_on_rails_gem_warning + def add_missing_react_on_rails_gem_warning(rollback_message: nil) + rollback_section = rollback_message ? "\n\n#{rollback_message}" : "" GeneratorMessages.add_warning(<<~MSG.strip) âš ī¸ Could not find react_on_rails or react_on_rails_pro in Gemfile. If this app declares the gem in a .gemspec or another included file, please update it manually: replace react_on_rails with react_on_rails_pro + #{rollback_section} MSG end + def rollback_gemfile_after_failed_swap_precondition( + gemfile_path: File.join(destination_root, "Gemfile"), + original_gemfile_content: nil, + current_gemfile_content: nil + ) + return nil unless original_gemfile_content + return nil if original_gemfile_content == current_gemfile_content + + atomic_write_file(gemfile_path, original_gemfile_content) + "Gemfile has been reverted to its pre-generator state." + rescue StandardError => e + "Could not revert Gemfile automatically (#{e.class}: #{e.message}). " \ + "Gemfile remains updated with react_on_rails_pro." + end + # rubocop:disable Metrics/AbcSize, Metrics/BlockLength, Metrics/CyclomaticComplexity, Metrics/MethodLength # rubocop:disable Metrics/PerceivedComplexity, Style/ExplicitBlockArgument def rewrite_non_comment_lines(content) @@ -803,6 +825,7 @@ def build_pro_gem_replacement_line(indentation:, quote:, suffix:, parenthesized_ normalized_suffix = updated_suffix end + normalized_suffix = normalized_suffix.sub(/\A,\s*(?:#[^\n]*)?\n\z/, "\n") normalized_suffix = normalized_suffix.sub(/\A,[ \t]{2,}/, ", ") normalized_suffix = normalized_suffix.sub(/\)(\s*(?:#.*)?\n)\z/, '\1') if parenthesized_gem_call diff --git a/react_on_rails/spec/react_on_rails/generators/pro_generator_spec.rb b/react_on_rails/spec/react_on_rails/generators/pro_generator_spec.rb index 3d81e9446f..36c37ad2d7 100644 --- a/react_on_rails/spec/react_on_rails/generators/pro_generator_spec.rb +++ b/react_on_rails/spec/react_on_rails/generators/pro_generator_spec.rb @@ -216,6 +216,24 @@ expect(generator).to have_received(:bundle_install_after_gem_swap) end + it "does not leave a trailing comma when replacing multiline declarations before another gem" do + simulate_existing_file("Gemfile", <<~RUBY) + source "https://rubygems.org" + gem "react_on_rails", + "~> 16.0" + gem "rails" + RUBY + allow(generator).to receive(:bundle_install_after_gem_swap) + + generator.send(:swap_base_gem_for_pro_in_gemfile) + + gemfile_content = File.read(gemfile_path) + expected_version = generator.send(:pro_gem_version_requirement) + expect(gemfile_content).to include("gem \"react_on_rails_pro\", \"#{expected_version}\"") + expect(gemfile_content).to include('gem "rails"') + expect(gemfile_content).not_to match(/react_on_rails_pro".*,\s*\n\s*gem "rails"/) + end + it "replaces multiline declarations that have an inline comment after the trailing comma" do simulate_existing_file("Gemfile", <<~RUBY) source "https://rubygems.org" @@ -589,6 +607,10 @@ end it "returns false when only an auto-installed Pro gem entry is present" do + original_gemfile_content = <<~RUBY + source "https://rubygems.org" + gem "rails" + RUBY simulate_existing_file("Gemfile", <<~RUBY) source "https://rubygems.org" gem "rails" @@ -598,13 +620,11 @@ result = generator.send( :swap_base_gem_for_pro_in_gemfile, - original_gemfile_content_for_rollback: <<~RUBY - source "https://rubygems.org" - gem "rails" - RUBY + original_gemfile_content_for_rollback: original_gemfile_content ) expect(result).to be(false) + expect(File.read(gemfile_path)).to eq(original_gemfile_content) expect(generator).not_to have_received(:bundle_install_after_gem_swap) expect(GeneratorMessages.messages.join("\n")) .to include("Could not find react_on_rails or react_on_rails_pro in Gemfile") @@ -722,6 +742,7 @@ "react-on-rails/server" ); /* short comment */ import InlineReactOnRails from "react-on-rails"; + /* eslint-disable import/no-unassigned-import */ import "react-on-rails"; const keepRor = require("react-on-rails"); // /* not a block comment start const commentLikeString = "/* not a JS comment"; import ReactOnRailsServer from "react-on-rails/server"; @@ -765,6 +786,9 @@ expect(File.read(application_js_path)).to include( '/* short comment */ import InlineReactOnRails from "react-on-rails-pro";' ) + expect(File.read(application_js_path)).to include( + '/* eslint-disable import/no-unassigned-import */ import "react-on-rails-pro";' + ) expect(File.read(application_js_path)).to include('import ReactOnRailsServer from "react-on-rails-pro/server";') expect(File.read(application_js_path)).to include('import ReactOnRailsClient from "react-on-rails-pro/client";') expect(File.read(application_js_path)).to include('import "react-on-rails-pro";') @@ -998,6 +1022,10 @@ before do prepare_destination simulate_existing_rails_files(package_json: true) + simulate_existing_file("Gemfile", <<~RUBY) + source "https://rubygems.org" + gem "react_on_rails_pro" + RUBY simulate_npm_files(package_json: true) # Simulate base React on Rails installed simulate_existing_file("config/initializers/react_on_rails.rb", "ReactOnRails.configure {}") @@ -1076,6 +1104,10 @@ before do prepare_destination simulate_existing_rails_files(package_json: true) + simulate_existing_file("Gemfile", <<~RUBY) + source "https://rubygems.org" + gem "react_on_rails_pro" + RUBY simulate_npm_files(package_json: true) simulate_existing_file("config/initializers/react_on_rails.rb", "ReactOnRails.configure {}") simulate_existing_file("Procfile.dev", "rails: bin/rails s\n") @@ -1153,6 +1185,10 @@ before do prepare_destination simulate_existing_rails_files(package_json: true) + simulate_existing_file("Gemfile", <<~RUBY) + source "https://rubygems.org" + gem "react_on_rails_pro" + RUBY simulate_npm_files(package_json: true) simulate_existing_file("config/initializers/react_on_rails.rb", "ReactOnRails.configure {}") simulate_existing_file("Procfile.dev", "rails: bin/rails s\n") From 6be8925f845a2ef259537cbe6145f43b7a9f77d5 Mon Sep 17 00:00:00 2001 From: Justin Gordon Date: Wed, 1 Apr 2026 16:25:11 -1000 Subject: [PATCH 35/37] Avoid rewriting React on Rails imports inside template literals --- .../react_on_rails/pro_generator.rb | 33 ++++++++++++++----- .../generators/pro_generator_spec.rb | 23 +++++++++++++ 2 files changed, 48 insertions(+), 8 deletions(-) diff --git a/react_on_rails/lib/generators/react_on_rails/pro_generator.rb b/react_on_rails/lib/generators/react_on_rails/pro_generator.rb index d36dc8169b..a537cd62d2 100644 --- a/react_on_rails/lib/generators/react_on_rails/pro_generator.rb +++ b/react_on_rails/lib/generators/react_on_rails/pro_generator.rb @@ -371,16 +371,18 @@ def rewrite_react_on_rails_module_specifiers(content) }x rewrite_non_comment_lines(content) do |line| - rewritten_line = line.gsub(static_import_specifier_pattern) do - "#{Regexp.last_match[:prefix]}#{Regexp.last_match[:quote]}react-on-rails-pro" - end + rewrite_outside_inline_template_literals(line) do |line_without_templates| + rewritten_line = line_without_templates.gsub(static_import_specifier_pattern) do + "#{Regexp.last_match[:prefix]}#{Regexp.last_match[:quote]}react-on-rails-pro" + end - rewritten_line = rewritten_line.gsub(dynamic_or_require_specifier_pattern) do - "#{Regexp.last_match[:prefix]}#{Regexp.last_match[:quote]}react-on-rails-pro" - end + rewritten_line = rewritten_line.gsub(dynamic_or_require_specifier_pattern) do + "#{Regexp.last_match[:prefix]}#{Regexp.last_match[:quote]}react-on-rails-pro" + end - rewritten_line.gsub(side_effect_import_pattern) do - "#{Regexp.last_match[:prefix]}#{Regexp.last_match[:quote]}react-on-rails-pro" + rewritten_line.gsub(side_effect_import_pattern) do + "#{Regexp.last_match[:prefix]}#{Regexp.last_match[:quote]}react-on-rails-pro" + end end end end @@ -636,6 +638,21 @@ def line_without_string_literals_and_inline_comments(line, strip_ruby_comments: line_without_comments.sub(/\s*#.*$/, "") end + def rewrite_outside_inline_template_literals(line) + template_placeholders = [] + line_without_inline_templates = line.gsub(/`(?:\\.|[^`\\])*`/) do |template_literal| + placeholder = "__ROR_TEMPLATE_LITERAL_PLACEHOLDER_#{template_placeholders.length}__" + template_placeholders << [placeholder, template_literal] + placeholder + end + + rewritten_line = yield line_without_inline_templates + template_placeholders.each do |placeholder, template_literal| + rewritten_line = rewritten_line.sub(placeholder, template_literal) + end + rewritten_line + end + def line_for_template_literal_tracking(line, in_block_comment: false) line_without_quoted_literals = line.gsub(/"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'/, "") line_for_comment_aware_template_tracking( diff --git a/react_on_rails/spec/react_on_rails/generators/pro_generator_spec.rb b/react_on_rails/spec/react_on_rails/generators/pro_generator_spec.rb index 36c37ad2d7..7b362d5775 100644 --- a/react_on_rails/spec/react_on_rails/generators/pro_generator_spec.rb +++ b/react_on_rails/spec/react_on_rails/generators/pro_generator_spec.rb @@ -940,6 +940,29 @@ expect(rewritten.scan("react-on-rails-pro").size).to eq(1) end + it "does not rewrite module specifiers inside single-line template literals" do + source = <<~JS + const inlineTemplate = `require("react-on-rails") and import("react-on-rails/client")`; + JS + + rewritten = generator.send(:rewrite_react_on_rails_module_specifiers, source) + + expect(rewritten).to include('`require("react-on-rails") and import("react-on-rails/client")`') + expect(rewritten).not_to include("react-on-rails-pro") + end + + it "rewrites module specifiers outside single-line template literals on the same line" do + source = <<~JS + const inlineTemplate = `require("react-on-rails")`; const ror = require("react-on-rails"); + JS + + rewritten = generator.send(:rewrite_react_on_rails_module_specifiers, source) + + expect(rewritten).to include('`require("react-on-rails")`') + expect(rewritten).to include('const ror = require("react-on-rails-pro");') + expect(rewritten.scan("react-on-rails-pro").size).to eq(1) + end + it "rewrites all matching specifiers on a pending continuation line" do source = <<~JS import { From 7c6cd2babd3edf3c4c9148e465f40017e1479659 Mon Sep 17 00:00:00 2001 From: Justin Gordon Date: Wed, 1 Apr 2026 16:46:50 -1000 Subject: [PATCH 36/37] Avoid duplicate Pro gem entries when parenthesized Gemfile declarations include comments --- .../react_on_rails/pro_generator.rb | 2 +- .../generators/pro_generator_spec.rb | 23 +++++++++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/react_on_rails/lib/generators/react_on_rails/pro_generator.rb b/react_on_rails/lib/generators/react_on_rails/pro_generator.rb index a537cd62d2..a2b764c6df 100644 --- a/react_on_rails/lib/generators/react_on_rails/pro_generator.rb +++ b/react_on_rails/lib/generators/react_on_rails/pro_generator.rb @@ -103,7 +103,7 @@ def swap_base_gem_for_pro_in_gemfile(original_gemfile_content_for_rollback: nil) end gemfile_content = File.read(gemfile_path) - pro_gem_pattern = /^\s*gem(?:\s+|\(\s*)["']react_on_rails_pro["']/ + pro_gem_pattern = /^\s*gem(?:\s+|\(\s*(?:#.*\n\s*)*)["']react_on_rails_pro["']/ base_gem_pattern = /^(\s*)gem(?:\s+|\(\s*)(["'])react_on_rails\2(?=\s*(?:,|\)|#|$))/ has_pro_gem_entry = gemfile_content.match?(pro_gem_pattern) diff --git a/react_on_rails/spec/react_on_rails/generators/pro_generator_spec.rb b/react_on_rails/spec/react_on_rails/generators/pro_generator_spec.rb index 7b362d5775..7ffac8938c 100644 --- a/react_on_rails/spec/react_on_rails/generators/pro_generator_spec.rb +++ b/react_on_rails/spec/react_on_rails/generators/pro_generator_spec.rb @@ -515,6 +515,29 @@ expect(generator).to have_received(:bundle_install_after_gem_swap) end + it "does not add duplicate react_on_rails_pro entries when existing parenthesized declaration has comment lines" do + simulate_existing_file("Gemfile", <<~RUBY) + source "https://rubygems.org" + gem "react_on_rails", "~> 16.0" + gem( + # pinned for compatibility + "react_on_rails_pro", + "~> 16.0" + ) + RUBY + allow(generator).to receive(:bundle_install_after_gem_swap) + allow(generator).to receive(:say) + + generator.send(:swap_base_gem_for_pro_in_gemfile) + + gemfile_content = File.read(gemfile_path) + expect(gemfile_content).not_to match(/gem\s+["']react_on_rails["']/) + expect(gemfile_content.scan(/["']react_on_rails_pro["']/).size).to eq(1) + expect(generator).to have_received(:say) + .with("â„šī¸ Existing react_on_rails_pro Gemfile entry detected; preserving current version constraint", :yellow) + expect(generator).to have_received(:bundle_install_after_gem_swap) + end + it "does nothing when Gemfile has no react_on_rails entry" do simulate_existing_file("Gemfile", <<~RUBY) source "https://rubygems.org" From 7c0ecb89eb102a46b2e303f1e6d581378507812c Mon Sep 17 00:00:00 2001 From: Justin Gordon Date: Wed, 1 Apr 2026 17:25:32 -1000 Subject: [PATCH 37/37] Fix RSC React dependency warning spec to simulate fallback failure --- .../spec/react_on_rails/generators/js_dependency_manager_spec.rb | 1 + 1 file changed, 1 insertion(+) diff --git a/react_on_rails/spec/react_on_rails/generators/js_dependency_manager_spec.rb b/react_on_rails/spec/react_on_rails/generators/js_dependency_manager_spec.rb index 36607fc3b4..ddcb52e83d 100644 --- a/react_on_rails/spec/react_on_rails/generators/js_dependency_manager_spec.rb +++ b/react_on_rails/spec/react_on_rails/generators/js_dependency_manager_spec.rb @@ -449,6 +449,7 @@ def errors it "warns with the pinned React install command when the RSC add fails" do instance.use_rsc = true instance.add_npm_dependencies_result = false + instance.system_result = false instance.send(:add_react_dependencies)