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..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 ? true : 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 @@ -151,20 +152,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..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 @@ -57,13 +57,21 @@ 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, + 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. @@ -88,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. @@ -190,7 +200,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] } @@ -202,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? @@ -274,11 +284,12 @@ 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 = pro_requirement_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}' + gem 'react_on_rails_pro', '#{pro_gem_version_requirement}' Then run: bundle install MSG return false @@ -382,7 +393,7 @@ 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 end else @@ -463,10 +474,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 @@ -478,6 +489,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 +503,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 options.rsc? flags << "--rsc" elsif options.pro? flags << "--pro" @@ -500,6 +514,17 @@ 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:/#{HELLO_SERVER_ROUTE}").cyan.underline} + 3. Confirm the page streams and the Like button hydrates on click. + MSG + 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..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 @@ -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 = "19.0.4" + 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 @@ -383,13 +387,29 @@ def pro_packages_with_version def add_rsc_dependencies say "Installing React Server Components dependencies..." - return if add_packages(RSC_DEPENDENCIES) + rsc_packages, used_version_pins = rsc_packages_with_version + return if add_packages(rsc_packages) + + 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, + 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 + 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 +420,12 @@ def add_rsc_dependencies MSG end + # 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 + def remove_base_package_if_present pj = package_json return unless pj 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..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 @@ -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" @@ -8,6 +10,7 @@ module ReactOnRails module Generators + # rubocop:disable Metrics/ClassLength class ProGenerator < Rails::Generators::Base include GeneratorHelper include JsDependencyManager @@ -27,10 +30,17 @@ 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( + original_gemfile_content_for_rollback: original_gemfile_content_before_prerequisites + ) + 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) @@ -45,6 +55,15 @@ def run_generator private + def read_current_gemfile_content + gemfile_path = File.join(destination_root, "Gemfile") + return unless File.exist?(gemfile_path) + + File.read(gemfile_path) + rescue StandardError + nil + end + def prerequisites_met? !(missing_base_installation? || missing_pro_gem?(force: true)) end @@ -75,6 +94,762 @@ def add_pro_npm_dependencies say "✅ Pro npm dependencies added", :green end + # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity + 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) + return false + end + + gemfile_content = File.read(gemfile_path) + 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) + 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 + line = gemfile_lines[line_index] + 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] + updated_lines << build_pro_gem_replacement_line( + indentation: indentation, + quote: quote, + suffix: multiline_parenthesized_match[:trailing_suffix], + parenthesized_gem_call: true + ) + pro_entry_added = true + end + + line_index = multiline_parenthesized_match[:next_index] + next + end + + match = line.match(base_gem_pattern) + + unless match + updated_lines << line + line_index += 1 + next + end + + base_gem_entry_found = true + + unless pro_entry_added + indentation = match[1] + quote = match[2] + updated_lines << build_pro_gem_replacement_line( + indentation: indentation, + quote: quote, + suffix: line[match.end(0)..], + parenthesized_gem_call: match[0].include?("(") + ) + 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 && + line_continues_with_comma?(current_line) && + gem_declaration_continues_on_next_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 + + updated_content = updated_lines.join + if updated_content == gemfile_content + unless base_gem_entry_found || had_pro_gem_entry_before_prerequisites + 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 + + return true + end + + 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 true + end + + 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( + gemfile_path: gemfile_path, + original_gemfile_content: original_gemfile_content + ) + rescue StandardError => e + add_gemfile_update_warning(gemfile_path, e) + false + end + # 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 + + temp_file.write(content) + temp_file.flush + 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? + File.delete(temp_path) if temp_path && File.exist?(temp_path) + end + + 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 true + end + + say "đŸ“Ļ Running bundle install after Gemfile update...", :yellow + install_status = Bundler.with_unbundled_env do + pid = Process.spawn( + { "BUNDLE_GEMFILE" => gemfile_path }, + "bundle", + "install", + out: $stdout, + err: $stderr, + chdir: destination_root + ) + wait_for_bundle_process(pid) + end + + return true if install_status&.success? + + rollback_message = rollback_gemfile_after_failed_bundle_install( + gemfile_path: gemfile_path, + original_gemfile_content: original_gemfile_content + ) + + 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) + âš ī¸ Could not run automatic bundle install: #{e.class}: #{e.message} + + #{rollback_message} + Please run manually: + bundle install + MSG + 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) + #{failure_header} + + #{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 + + files.each do |file| + content = File.read(file) + updated_content = rewrite_react_on_rails_module_specifiers(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 + + atomic_write_file(file, updated_content) + updated_files += 1 + rescue StandardError => e + GeneratorMessages.add_warning(<<~MSG.strip) + âš ī¸ Could not update imports in #{file}: #{e.class}: #{e.message} + + Please update react-on-rails imports to react-on-rails-pro manually. + MSG + end + + 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 + + def js_files_for_import_update + 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) + + Dir.glob(File.join(root_path, "**", "*.{#{js_extensions}}")) + .reject { |f| f.include?("/node_modules/") } + end.uniq + end + + def rewrite_react_on_rails_module_specifiers(content) + static_import_specifier_pattern = %r{ + (? + \A\s*(?:/\*.*?\*/\s*)?(?:import|export)(?:\s+type)?\s+.*?\s+from\s+| + \A\s*[\w\}\],\*\$\s]+\s+from\s+ + ) + (?["']) + react-on-rails(?!-pro) + (?=(?:["']|/)) + }x + + dynamic_or_require_specifier_pattern = %r{ + (? + (?["']) + react-on-rails(?!-pro) + (?=(?:["']|/)) + }x + + side_effect_import_pattern = %r{ + \A(?\s*(?:/\*.*?\*/\s*)*import\s+) + (?["']) + react-on-rails(?!-pro) + (?=(?:["']|/)) + }x + + rewrite_non_comment_lines(content) do |line| + 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.gsub(side_effect_import_pattern) do + "#{Regexp.last_match[:prefix]}#{Regexp.last_match[:quote]}react-on-rails-pro" + end + end + end + end + + def line_continues_with_comma?(line) + 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 true if stripped.empty? + + !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}. + + 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 add_gemfile_update_warning(gemfile_path, error) + GeneratorMessages.add_warning(<<~MSG.strip) + âš ī¸ 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 + + 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) + 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_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) + + if in_multiline_template_literal || line_contains_unescaped_backtick + line_for_state_update = in_multiline_template_literal ? line : line_for_template_literal_state + updated_template_literal_state = + update_multiline_template_literal_state(in_multiline_template_literal, line_for_state_update) + + 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 + 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 + end + elsif in_block_comment + 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?(rewritten_line) + rewritten_line + else + in_block_comment = true + line + end + elsif stripped.start_with?("//") || stripped.match?(/\A\*\s/) + 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?(rewritten_line) + rewritten_line + end + end.join + end + # 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) + 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 + + 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) + line_without_literals = line_without_string_literals_and_inline_comments(line) + return false unless line_without_literals.match?(/(?["'])react-on-rails(?!-pro)(?=(?:["']|/))}) do + "#{Regexp.last_match[:quote]}react-on-rails-pro" + end + end + + def update_pending_multiline_module_call_tracking(line, pending_depth) + if pending_depth.positive? + rewritten_line = rewrite_pending_module_specifier(line) + updated_depth = pending_depth + module_call_parenthesis_delta(rewritten_line) + updated_depth = 0 if updated_depth <= 0 + [rewritten_line, updated_depth] + elsif starts_pending_multiline_module_call?(line) + initial_depth = module_call_parenthesis_delta(line, from_module_call_start: true) + [line, initial_depth.positive? ? initial_depth : 0] + else + [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|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*)*["']}) + + 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 + line_without_literals.sub(/\A.*?(?= 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 + 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 + + 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 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| + 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] + start_match = start_line.match(/^(\s*)gem\s*\(/) + return nil unless start_match + + 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 = 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 !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(")") + + if paren_depth <= 0 + return nil unless found_base_gem_name + + 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: suffix + } + end + + line_index += 1 + end + + nil + end + # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity + + 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") + 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,\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 + + "#{indentation}gem #{quote}react_on_rails_pro#{quote}, " \ + "#{quote}#{pro_gem_version_requirement}#{quote}#{normalized_suffix}" + end + def print_success_message route = if File.exist?(File.join(destination_root, "app/controllers/hello_server_controller.rb")) "hello_server" @@ -93,5 +868,6 @@ def print_success_message MSG end end + # rubocop:enable Metrics/ClassLength end 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 7075a9c893..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 @@ -60,20 +60,17 @@ 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 + 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}', '~> #{recommended_pro_gem_version}' + gem '#{PRO_GEM_NAME}', '#{pro_gem_version_requirement}' Then run: bundle install @@ -86,6 +83,33 @@ 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? + use_pro? + end + + def pro_requirement_flag + return "--rsc-pro" if use_rsc_pro_mode? + return "--rsc" if options[:rsc] + + "--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 ArgumentError + "" + 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 +483,15 @@ def server_client_import_ready? end def pro_gem_auto_install_command - "bundle add #{PRO_GEM_NAME} --version='~> #{recommended_pro_gem_version}' --strict" + "bundle add #{PRO_GEM_NAME} --version='#{pro_gem_version_requirement}' --strict" + 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}" 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..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 @@ -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 @@ -98,16 +103,30 @@ def shell 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 + 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"] @@ -184,6 +203,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..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 @@ -1703,6 +1703,32 @@ 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) + 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["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 + 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) } @@ -1979,12 +2005,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 @@ -1995,6 +2022,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 +2053,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:/hello_server") + expect(output_text).to include("Like button hydrates on click") + end end describe "--pretend mode behavior" do @@ -2114,6 +2165,40 @@ 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 + + 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 @@ -2778,6 +2863,48 @@ 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) { ReactOnRails::VERSION } + 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("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'") + expect(error_text).to include("may not be published yet") + expect(error_text).to include("path:") + end + end + context "when auto-installing Pro gem succeeds" do let(:install_generator) { described_class.new([], { pro: true }) } let(:fake_pid) { 12_345 } @@ -2863,6 +2990,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 }) } @@ -2903,6 +3052,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 }) } @@ -3227,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 b064450c61..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 @@ -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 @@ -416,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 @@ -426,6 +445,17 @@ 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.system_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 @@ -508,6 +538,43 @@ def errors end 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]) + 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@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@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@19.0.4"], true]) + + 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@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 + describe "#add_babel_react_dependencies" do it "adds Babel React preset as dev dependency" do instance.send(:add_babel_react_dependencies) 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..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 @@ -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='#{generator.send(:pro_gem_version_requirement)}' --strict", out: anything, err: anything) error_text = GeneratorMessages.messages.join("\n") # Standalone message should NOT mention --pro flag @@ -55,6 +55,1012 @@ end end + 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 + + 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 + + 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 + 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 = 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) + 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 = 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\",") + 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 = 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") + 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 = 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 + + 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 = 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\"") + 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" + 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 = 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\"") + 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 = 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\"") + 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 = 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") + 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 = 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") + 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 = 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 + + 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 = 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 + + 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 = 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"') + 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 = 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))") + end + + it "replaces multiline 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 = 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"') + 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 = 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"') + 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" + 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 = 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"]') + 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" + 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 = 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(") + 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 = 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(") + 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" + 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 = 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 }') + 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 + 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) + 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 + + 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" + 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 + + 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 "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(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) + + 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" + 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 = 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) + 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 + + 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 + original_gemfile_content = <<~RUBY + source "https://rubygems.org" + gem "rails" + RUBY + 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: 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") + 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 + let(:generator) { described_class.new } + let(:fake_pid) { 23_456 } + let(:gemfile_path) { File.join(destination_root, "Gemfile") } + + 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 "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 "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) + + generator.send(:bundle_install_after_gem_swap) + + 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") + 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 + 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") } + 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 + 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"); + const commentedRequire = require(/* webpackIgnore: true */ "react-on-rails"); + const lazyRor = import(/* webpackChunkName: "ror" */ "react-on-rails"); + const lazyRorMultiline = import( + /* webpackMode: "lazy" */ + "react-on-rails/client" + ); + const lazyRorRequire = require( + "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"; + 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"; + const importTemplate = 'import("react-on-rails")'; + const fromTemplate = "import Example from \\"react-on-rails\\""; + // 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 + 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(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"') + 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";') + 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";') + 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";') + 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 + + 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 + + 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( + /* 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 + + 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 + + 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 + + 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 = ` + 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 "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 "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 "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 { + 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 + 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 "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 "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" + + expect(generator.send(:unclosed_block_comment_starts?, source_line)).to be true + 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 @@ -62,6 +1068,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 {}") @@ -140,6 +1150,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") @@ -217,6 +1231,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")