react_on_rails 17.1.0 → 17.2.0.rc.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. checksums.yaml +4 -4
  2. data/Gemfile +1 -1
  3. data/Gemfile.development_dependencies +2 -2
  4. data/Gemfile.lock +65 -73
  5. data/Steepfile +1 -0
  6. data/docs/agent/install-and-upgrade.md +8 -3
  7. data/docs/agent/rsc-adoption.md +3 -0
  8. data/lib/generators/react_on_rails/base_generator.rb +2 -1
  9. data/lib/generators/react_on_rails/generated_webpack_config_pair.rb +84 -0
  10. data/lib/generators/react_on_rails/generator_helper.rb +2 -4
  11. data/lib/generators/react_on_rails/generator_messages.rb +1 -1
  12. data/lib/generators/react_on_rails/install_generator.rb +2 -1
  13. data/lib/generators/react_on_rails/js_dependency_manager.rb +14 -14
  14. data/lib/generators/react_on_rails/pro/USAGE +14 -3
  15. data/lib/generators/react_on_rails/pro_generator.rb +1 -1
  16. data/lib/generators/react_on_rails/pro_setup.rb +41 -213
  17. data/lib/generators/react_on_rails/rsc_setup.rb +7 -16
  18. data/lib/generators/react_on_rails/templates/pro/base/config/initializers/react_on_rails_pro.rb.tt +1 -1
  19. data/lib/react_on_rails/agent_guardrails.rb +217 -39
  20. data/lib/react_on_rails/dev/process_manager.rb +14 -0
  21. data/lib/react_on_rails/dev/server_manager.rb +1727 -103
  22. data/lib/react_on_rails/doctor.rb +875 -44
  23. data/lib/react_on_rails/helper.rb +3 -3
  24. data/lib/react_on_rails/react_component/render_options.rb +5 -7
  25. data/lib/react_on_rails/server_rendering_pool/ruby_embedded_java_script.rb +27 -116
  26. data/lib/react_on_rails/utils.rb +65 -0
  27. data/lib/react_on_rails/version.rb +1 -1
  28. data/lib/react_on_rails/version_checker/lockfile_resolution.rb +170 -0
  29. data/lib/react_on_rails/version_checker.rb +43 -22
  30. data/rakelib/run_rspec.rake +6 -1
  31. data/sig/react_on_rails/configuration.rbs +28 -26
  32. data/sig/react_on_rails/controller.rbs +2 -1
  33. data/sig/react_on_rails/dev/process_manager.rbs +1 -0
  34. data/sig/react_on_rails/dev/server_manager.rbs +150 -10
  35. data/sig/react_on_rails/git_utils.rbs +5 -1
  36. data/sig/react_on_rails/locales.rbs +4 -4
  37. data/sig/react_on_rails/prerender_error.rbs +2 -2
  38. data/sig/react_on_rails/test_helper.rbs +15 -1
  39. data/sig/react_on_rails/utils.rbs +2 -2
  40. data/sig/react_on_rails/version_checker/lockfile_resolution.rbs +31 -0
  41. data/sig/react_on_rails/version_checker.rbs +30 -7
  42. metadata +4 -1
@@ -3,6 +3,7 @@
3
3
  require "json"
4
4
  require "erb"
5
5
  require "open3"
6
+ require "ripper"
6
7
  require "stringio"
7
8
  require "tempfile"
8
9
  require "timeout"
@@ -71,6 +72,16 @@ module ReactOnRails
71
72
  SERVER_BUNDLE_SOURCE_EXTENSIONS = %w[.js .jsx .ts .tsx .mjs .cjs].freeze
72
73
  CUSTOM_LAUNCHER_INDICATOR_FILES = %w[dev].freeze
73
74
  RAILS_SERVER_COMMAND_REGEX = %r{\b(?:(?:bin/)?rails\s+(?:server|s)|puma|unicorn|rackup|passenger\s+start)\b}
75
+ TEMPLATE_HTML_COMMENT_PATTERN = /<!--.*?-->/m
76
+ TEMPLATE_COMMENT_PATTERN = /<!--.*?-->|<%#.*?%>/m
77
+ ERB_EXPRESSION_PATTERN = /<%(?![#%])={0,2}\s*(?<expression>.*?)-?%>/m
78
+ ERB_OUTPUT_EXPRESSION_PATTERN = /<%={1,2}\s*(?<expression>.*?)-?%>/m
79
+ ERB_NON_OUTPUT_EXPRESSION_PATTERN = /<%(?![#%=])\s*(?<expression>.*?)-?%>/m
80
+ ERB_CONTROL_FLOW_KEYWORDS = %w[
81
+ begin case do else elsif end ensure for if rescue return then unless until when while
82
+ ].freeze
83
+ REFLECTIVE_HELPER_DISPATCH_METHODS = %w[send public_send __send__ method public_method].freeze
84
+ REACT_ON_RAILS_INITIALIZER_PATH = "config/initializers/react_on_rails.rb"
74
85
 
75
86
  # Deprecated-renderer-cache scan (used by check_deprecated_renderer_cache_task):
76
87
  # look for references to the old pre_stage_bundle_for_node_renderer task in
@@ -518,6 +529,7 @@ module ReactOnRails
518
529
  check_shakapacker_configuration_details
519
530
  check_react_on_rails_configuration_details
520
531
  check_server_bundle_prerender_consistency
532
+ check_layout_files
521
533
  end
522
534
 
523
535
  def check_bin_dev_launcher
@@ -1231,7 +1243,6 @@ module ReactOnRails
1231
1243
  )
1232
1244
  end
1233
1245
 
1234
- check_layout_files
1235
1246
  check_server_rendering_engine
1236
1247
  end
1237
1248
 
@@ -1242,13 +1253,15 @@ module ReactOnRails
1242
1253
  checker.add_info("\n📄 Layout Files Analysis:")
1243
1254
 
1244
1255
  layout_files.each do |layout_file|
1245
- next unless File.exist?(layout_file)
1256
+ next unless analyzable_layout_file?(layout_file)
1246
1257
 
1247
- content = File.read(layout_file)
1248
- has_stylesheet = content.include?("stylesheet_pack_tag")
1249
- has_javascript = content.include?("javascript_pack_tag")
1258
+ template_content = File.read(layout_file)
1259
+ ambiguous_commented_erb = html_comment_with_executable_erb?(template_content)
1260
+ content = uncommented_template_content(template_content)
1261
+ has_stylesheet = stylesheet_pack_helper_evidence?(content)
1262
+ has_javascript = erb_output_helper?(content, "javascript_pack_tag")
1250
1263
 
1251
- layout_name = File.basename(layout_file, ".html.erb")
1264
+ layout_name = layout_file.delete_prefix("app/views/layouts/").delete_suffix(".html.erb")
1252
1265
 
1253
1266
  # Report detected pack helpers informationally. A JavaScript-only entrypoint
1254
1267
  # (no emitted CSS), a CSS-only pack, or a hybrid layout that loads CSS via
@@ -1260,12 +1273,764 @@ module ReactOnRails
1260
1273
  checker.add_info(" ℹ️ #{layout_name}: has stylesheet_pack_tag")
1261
1274
  elsif has_javascript
1262
1275
  checker.add_info(" ℹ️ #{layout_name}: has javascript_pack_tag")
1276
+ check_unflushed_auto_loaded_component_css(layout_name, content, ambiguous_commented_erb:)
1263
1277
  else
1264
1278
  checker.add_info(" ℹ️ #{layout_name}: no pack tags found")
1265
1279
  end
1266
1280
  end
1267
1281
  end
1268
1282
 
1283
+ def analyzable_layout_file?(layout_file)
1284
+ !File.basename(layout_file).start_with?("_") && File.exist?(layout_file)
1285
+ end
1286
+
1287
+ def check_unflushed_auto_loaded_component_css(layout_name, content, ambiguous_commented_erb: false)
1288
+ return if ambiguous_commented_erb
1289
+ return if separate_erb_control_flow?(content)
1290
+ return unless erb_output_helper?(content, "javascript_pack_tag")
1291
+ return if erb_helper_mentioned?(content, "render")
1292
+
1293
+ component_names = auto_loaded_component_names(content)
1294
+ component_names.concat(auto_loaded_component_names_from_implicit_views(layout_name)).uniq.each do |component_name|
1295
+ entrypoint = "generated/#{component_name}"
1296
+ stylesheet_assets = static_stylesheet_assets(entrypoint)
1297
+ next unless stylesheet_assets
1298
+
1299
+ checker.add_warning(
1300
+ "⚠️ #{layout_name}: auto-loaded component entrypoint #{entrypoint} emits CSS " \
1301
+ "(#{stylesheet_assets.join(', ')}), but this React layout flushes javascript_pack_tag without " \
1302
+ "stylesheet_pack_tag"
1303
+ )
1304
+ end
1305
+ end
1306
+
1307
+ def stylesheet_pack_helper_evidence?(content)
1308
+ erb_output_helper?(content, "stylesheet_pack_tag") ||
1309
+ stylesheet_pack_ambiguity_evidence?(content)
1310
+ end
1311
+
1312
+ def stylesheet_pack_ambiguity_evidence?(content)
1313
+ ignored_bare_local = "stylesheet_pack_tag" if erb_local_binding?(content, "stylesheet_pack_tag")
1314
+ stylesheet_pack_output_ambiguity_evidence?(content, ignored_bare_local:) ||
1315
+ content.scan(ERB_NON_OUTPUT_EXPRESSION_PATTERN).any? do |(expression)|
1316
+ statements = ripper_statements(expression)
1317
+ next false if statements.one? &&
1318
+ discarded_top_level_helper_call?(statements.first, "stylesheet_pack_tag")
1319
+
1320
+ statements.any? do |statement|
1321
+ helper_call_context_evidence?(statement, "stylesheet_pack_tag", ignored_bare_local:)
1322
+ end
1323
+ end
1324
+ end
1325
+
1326
+ def stylesheet_pack_output_ambiguity_evidence?(content, ignored_bare_local: nil)
1327
+ content.scan(ERB_OUTPUT_EXPRESSION_PATTERN).any? do |(expression)|
1328
+ statements = ripper_statements(expression)
1329
+ statements.any? do |statement|
1330
+ helper_call_context_evidence?(statement, "stylesheet_pack_tag", ignored_bare_local:)
1331
+ end
1332
+ end
1333
+ end
1334
+
1335
+ def static_stylesheet_assets(entrypoint)
1336
+ assets = static_manifest_assets(entrypoint)
1337
+ stylesheet_assets = assets["css"] if assets.is_a?(Hash)
1338
+ return unless stylesheet_assets.is_a?(Array) && stylesheet_assets.any?
1339
+ return unless homogeneous_manifest_css_schema?(stylesheet_assets)
1340
+
1341
+ normalized_assets = stylesheet_assets.map { |asset| static_manifest_asset_path(asset) }
1342
+ return if normalized_assets.any?(&:nil?)
1343
+
1344
+ normalized_assets
1345
+ end
1346
+
1347
+ def homogeneous_manifest_css_schema?(assets)
1348
+ assets.all?(String) || assets.all?(Hash)
1349
+ end
1350
+
1351
+ def static_manifest_asset_path(asset)
1352
+ path = asset.is_a?(Hash) ? asset["src"] : asset
1353
+ path if path.is_a?(String) && !path.empty?
1354
+ end
1355
+
1356
+ def static_manifest_assets(entrypoint)
1357
+ manifest = shakapacker_manifest_data
1358
+ return unless manifest.is_a?(Hash)
1359
+
1360
+ entrypoints = manifest["entrypoints"]
1361
+ return unless entrypoints.is_a?(Hash)
1362
+
1363
+ entrypoint_data = entrypoints[entrypoint]
1364
+ entrypoint_data["assets"] if entrypoint_data.is_a?(Hash)
1365
+ end
1366
+
1367
+ def auto_loaded_component_names_from_implicit_views(layout_name)
1368
+ Dir.glob("app/controllers/**/*_controller.rb").flat_map do |controller_file|
1369
+ implicit_view_paths_for_layout(controller_file, layout_name).flat_map do |view_path|
1370
+ auto_loaded_component_names_from_implicit_view(view_path)
1371
+ end
1372
+ rescue StandardError
1373
+ []
1374
+ end
1375
+ rescue StandardError
1376
+ []
1377
+ end
1378
+
1379
+ def auto_loaded_component_names_from_implicit_view(view_path)
1380
+ return [] unless File.exist?(view_path)
1381
+
1382
+ template_content = File.read(view_path)
1383
+ return [] if html_comment_with_executable_erb?(template_content)
1384
+
1385
+ view_content = uncommented_template_content(template_content)
1386
+ return [] if stylesheet_pack_helper_evidence?(view_content)
1387
+ return [] if erb_helper_mentioned?(view_content, "render")
1388
+
1389
+ auto_loaded_component_names(view_content)
1390
+ end
1391
+
1392
+ def implicit_view_paths_for_layout(controller_file, layout_name)
1393
+ action_names = statically_associated_action_names(controller_file, layout_name)
1394
+ return [] unless action_names
1395
+
1396
+ view_directory = controller_file.delete_prefix("app/controllers/").delete_suffix("_controller.rb")
1397
+ action_names.map do |action_name|
1398
+ "app/views/#{view_directory}/#{action_name}.html.erb"
1399
+ end
1400
+ rescue StandardError
1401
+ []
1402
+ end
1403
+
1404
+ def statically_associated_action_names(controller_file, layout_name)
1405
+ return unless File.exist?(controller_file)
1406
+
1407
+ controller = static_controller_class(controller_file)
1408
+ statements = static_body_statements(controller[3]) if controller
1409
+ return unless statements
1410
+
1411
+ layouts = statements.filter_map { |statement| static_layout_declaration(statement) }
1412
+ action_names = statements.filter_map { |statement| static_generated_action_name(statement) }
1413
+ return unless exact_generated_controller_statements?(statements, layouts, action_names, layout_name)
1414
+
1415
+ action_names
1416
+ end
1417
+
1418
+ def exact_generated_controller_statements?(statements, layouts, action_names, layout_name)
1419
+ layouts == [layout_name] && action_names.one? &&
1420
+ statements.length == layouts.length + action_names.length
1421
+ end
1422
+
1423
+ def static_controller_class(controller_file)
1424
+ statements = ripper_statements(File.read(controller_file))
1425
+ return unless statements.one?
1426
+
1427
+ controller = statements.first
1428
+ return unless ast_node?(controller, :class)
1429
+ return unless static_constant_reference(controller[2]) == "ApplicationController"
1430
+
1431
+ relative_name = controller_file.delete_prefix("app/controllers/").delete_suffix("_controller.rb")
1432
+ expected_name = "#{relative_name.split('/').map(&:camelize).join('::')}Controller"
1433
+ controller if static_constant_name(controller[1]) == expected_name
1434
+ end
1435
+
1436
+ def static_constant_name(node)
1437
+ return unless node.is_a?(Array)
1438
+
1439
+ case node.first
1440
+ when :const_ref, :top_const_ref
1441
+ static_constant_token_value(node[1])
1442
+ when :const_path_ref
1443
+ namespace = static_constant_name(node[1])
1444
+ constant = static_constant_token_value(node[2])
1445
+ "#{namespace}::#{constant}" if namespace && constant
1446
+ end
1447
+ end
1448
+
1449
+ def static_constant_token_value(node)
1450
+ node[1] if ast_node?(node, :@const)
1451
+ end
1452
+
1453
+ def static_layout_declaration(node)
1454
+ arguments = static_argument_values(static_call_arguments(node, "layout"))
1455
+ return unless arguments&.one?
1456
+
1457
+ static_string_value(arguments.first)
1458
+ end
1459
+
1460
+ def static_generated_action_name(node)
1461
+ return unless ast_node?(node, :def) && token_type?(node[1], :@ident)
1462
+ return unless empty_method_parameters?(node[2])
1463
+ return unless static_generated_action_body?(node[3])
1464
+
1465
+ node[1][1]
1466
+ end
1467
+
1468
+ def static_generated_action_body?(node)
1469
+ return false unless ast_node?(node, :bodystmt) && node.drop(2).all?(&:nil?)
1470
+
1471
+ statements = static_body_statements(node)
1472
+ statements&.all? { |statement| ast_node?(statement, :void_stmt) || static_ivar_assignment?(statement) }
1473
+ end
1474
+
1475
+ def static_ivar_assignment?(node)
1476
+ return false unless ast_node?(node, :assign)
1477
+
1478
+ field = node[1]
1479
+ ast_node?(field, :var_field) && token_type?(field[1], :@ivar) && static_data_value?(node[2])
1480
+ end
1481
+
1482
+ def empty_method_parameters?(node)
1483
+ ast_node?(node, :params) && node.drop(1).all?(&:nil?)
1484
+ end
1485
+
1486
+ def static_body_statements(node)
1487
+ statements = node[1] if ast_node?(node, :bodystmt)
1488
+ statements if statements.is_a?(Array)
1489
+ end
1490
+
1491
+ def erb_output_helper?(content, helper_name)
1492
+ unqualified_call_ambiguous = erb_local_binding?(content, helper_name)
1493
+
1494
+ content.scan(ERB_OUTPUT_EXPRESSION_PATTERN).any? do |(expression)|
1495
+ statements = ripper_statements(expression)
1496
+ statements.one? && emitted_top_level_helper_call?(
1497
+ statements.first,
1498
+ helper_name,
1499
+ unqualified_call_ambiguous:
1500
+ )
1501
+ end
1502
+ end
1503
+
1504
+ def emitted_top_level_helper_call?(node, helper_name, unqualified_call_ambiguous: false)
1505
+ return true if direct_self_helper_call?(node, helper_name)
1506
+ return true if parenthesized_self_javascript_helper_call?(node, helper_name)
1507
+ return false if unqualified_call_ambiguous && bare_unqualified_helper_vcall?(node, helper_name)
1508
+
1509
+ direct_unqualified_helper_call?(node, helper_name)
1510
+ end
1511
+
1512
+ def erb_local_binding?(content, local_name)
1513
+ content.scan(ERB_EXPRESSION_PATTERN).any? do |(expression)|
1514
+ ripper_statements(expression).any? { |statement| source_proven_local_binding?(statement, local_name) }
1515
+ end
1516
+ end
1517
+
1518
+ def source_proven_local_binding?(node, local_name)
1519
+ return false unless node.is_a?(Array)
1520
+ return source_proven_local_binding?(node[1], local_name) if ast_node?(node, :method_add_block)
1521
+ return false if nested_local_binding_scope?(node)
1522
+ return true if local_variable_field_named?(node, local_name)
1523
+ return true if required_parameter_named?(node, local_name)
1524
+
1525
+ children = node.first.is_a?(Symbol) ? node.drop(1) : node
1526
+ children.any? { |child| source_proven_local_binding?(child, local_name) }
1527
+ end
1528
+
1529
+ def nested_local_binding_scope?(node)
1530
+ %i[brace_block do_block lambda def defs].any? { |type| ast_node?(node, type) }
1531
+ end
1532
+
1533
+ def local_variable_field_named?(node, local_name)
1534
+ ast_node?(node, :var_field) && token_named?(node[1], local_name)
1535
+ end
1536
+
1537
+ def required_parameter_named?(node, local_name)
1538
+ ast_node?(node, :params) && node[1].is_a?(Array) &&
1539
+ node[1].any? { |parameter| token_named?(parameter, local_name) }
1540
+ end
1541
+
1542
+ def direct_self_helper_call?(node, helper_name)
1543
+ return false unless ast_node?(node, :call) || ast_node?(node, :command_call)
1544
+
1545
+ receiver = node[1]
1546
+ self_token = receiver[1] if ast_node?(receiver, :var_ref)
1547
+ token_type?(self_token, :@kw) && self_token[1] == "self" && token_named?(node[3], helper_name)
1548
+ end
1549
+
1550
+ def parenthesized_self_javascript_helper_call?(node, helper_name)
1551
+ return false unless helper_name == "javascript_pack_tag" && ast_node?(node, :method_add_arg)
1552
+
1553
+ argument_parentheses = node[2]
1554
+ ast_node?(argument_parentheses, :arg_paren) && direct_self_helper_call?(node[1], helper_name)
1555
+ end
1556
+
1557
+ def erb_helper_mentioned?(content, helper_name)
1558
+ helper_call_context_in_expressions?(content.scan(ERB_EXPRESSION_PATTERN), helper_name)
1559
+ end
1560
+
1561
+ def helper_call_context_in_expressions?(expressions, helper_name)
1562
+ expressions.any? do |(expression)|
1563
+ ripper_statements(expression).any? { |statement| helper_call_context_evidence?(statement, helper_name) }
1564
+ end
1565
+ end
1566
+
1567
+ def helper_call_context_evidence?(node, helper_name, ignored_bare_local: nil)
1568
+ return false unless node.is_a?(Array)
1569
+ return false if ignored_bare_local == helper_name && bare_unqualified_helper_vcall?(node, helper_name)
1570
+ return true if helper_call_node?(node, helper_name)
1571
+ return true if reflective_helper_dispatch_may_call?(node, helper_name)
1572
+
1573
+ children = node.first.is_a?(Symbol) ? node.drop(1) : node
1574
+ children.any? { |child| helper_call_context_evidence?(child, helper_name, ignored_bare_local:) }
1575
+ end
1576
+
1577
+ def helper_call_node?(node, helper_name)
1578
+ method_token = call_method_token(node)
1579
+ token_named?(method_token, helper_name)
1580
+ end
1581
+
1582
+ def reflective_helper_dispatch_may_call?(node, helper_name)
1583
+ arguments = reflective_dispatch_arguments(node)
1584
+ return false unless arguments&.any?
1585
+
1586
+ dispatched_name = static_string_value(arguments.first) || static_symbol_value(arguments.first)
1587
+ dispatched_name.nil? || dispatched_name == helper_name
1588
+ end
1589
+
1590
+ def reflective_dispatch_arguments(node)
1591
+ if ast_node?(node, :method_add_arg)
1592
+ return unless reflective_dispatch_call?(node[1])
1593
+
1594
+ argument_parentheses = node[2]
1595
+ return unless ast_node?(argument_parentheses, :arg_paren)
1596
+
1597
+ static_argument_values(argument_parentheses[1])
1598
+ elsif ast_node?(node, :command) && reflective_dispatch_call?(node)
1599
+ static_argument_values(node[2])
1600
+ elsif reflective_command_call?(node)
1601
+ static_argument_values(node[4])
1602
+ end
1603
+ end
1604
+
1605
+ def reflective_command_call?(node)
1606
+ return false unless ast_node?(node, :command_call)
1607
+
1608
+ method_token = node[3]
1609
+ token_type?(method_token, :@ident) && REFLECTIVE_HELPER_DISPATCH_METHODS.include?(method_token[1])
1610
+ end
1611
+
1612
+ def reflective_dispatch_call?(node)
1613
+ method_token = call_method_token(node)
1614
+ token_type?(method_token, :@ident) && REFLECTIVE_HELPER_DISPATCH_METHODS.include?(method_token[1])
1615
+ end
1616
+
1617
+ def call_method_token(node)
1618
+ case node&.first
1619
+ when :vcall, :fcall, :command
1620
+ node[1]
1621
+ when :call
1622
+ node[3]
1623
+ end
1624
+ end
1625
+
1626
+ def direct_unqualified_helper_call?(node, helper_name)
1627
+ return token_named?(node[1], helper_name) if ast_node?(node, :vcall) || ast_node?(node, :command)
1628
+
1629
+ ast_node?(node, :method_add_arg) && fcall_named?(node[1], helper_name)
1630
+ end
1631
+
1632
+ def bare_unqualified_helper_vcall?(node, helper_name)
1633
+ ast_node?(node, :vcall) && token_named?(node[1], helper_name)
1634
+ end
1635
+
1636
+ def discarded_top_level_helper_call?(node, helper_name)
1637
+ return true if direct_unqualified_helper_call?(node, helper_name)
1638
+ return true if direct_self_helper_call?(node, helper_name)
1639
+
1640
+ ast_node?(node, :method_add_arg) && direct_self_helper_call?(node[1], helper_name)
1641
+ end
1642
+
1643
+ def uncommented_template_content(content)
1644
+ utf8_content = content.encode(Encoding::UTF_8, invalid: :replace, undef: :replace)
1645
+ loop do
1646
+ uncommented_content = utf8_content.gsub(TEMPLATE_COMMENT_PATTERN, "")
1647
+ return uncommented_content if uncommented_content == utf8_content
1648
+
1649
+ utf8_content = uncommented_content
1650
+ end
1651
+ rescue EncodingError
1652
+ ""
1653
+ end
1654
+
1655
+ def html_comment_with_executable_erb?(content)
1656
+ utf8_content = content.encode(Encoding::UTF_8, invalid: :replace, undef: :replace)
1657
+ loop do
1658
+ return true if utf8_content.scan(TEMPLATE_HTML_COMMENT_PATTERN).any? do |comment|
1659
+ comment.match?(ERB_EXPRESSION_PATTERN)
1660
+ end
1661
+
1662
+ uncommented_content = utf8_content.gsub(TEMPLATE_COMMENT_PATTERN, "")
1663
+ return false if uncommented_content == utf8_content
1664
+
1665
+ utf8_content = uncommented_content
1666
+ end
1667
+ rescue EncodingError
1668
+ true
1669
+ end
1670
+
1671
+ def auto_loaded_component_names(content)
1672
+ return [] if separate_erb_control_flow?(content)
1673
+
1674
+ auto_loaded_component_arguments(content).filter_map do |arguments|
1675
+ next unless statically_auto_loaded_component?(arguments)
1676
+
1677
+ static_string_value(arguments.first)&.camelize
1678
+ end.uniq
1679
+ end
1680
+
1681
+ def auto_loaded_component_arguments(content)
1682
+ expressions = content.scan(ERB_OUTPUT_EXPRESSION_PATTERN).map(&:first)
1683
+ assignments = content.scan(ERB_NON_OUTPUT_EXPRESSION_PATTERN).map(&:first)
1684
+ component_arguments = expressions.filter_map { |expression| static_react_component_arguments(expression) }
1685
+ component_arguments.concat(
1686
+ assignments.filter_map { |expression| static_react_component_assignment_arguments(expression) }
1687
+ )
1688
+ end
1689
+
1690
+ def separate_erb_control_flow?(content)
1691
+ content.scan(ERB_NON_OUTPUT_EXPRESSION_PATTERN).any? do |(expression)|
1692
+ next true unless Ripper.sexp(expression)
1693
+
1694
+ Ripper.lex(expression).any? do |(_position, event, token)|
1695
+ event == :on_kw && ERB_CONTROL_FLOW_KEYWORDS.include?(token)
1696
+ end
1697
+ end
1698
+ end
1699
+
1700
+ def statically_auto_loaded_component?(arguments)
1701
+ options = static_component_options(arguments.drop(1))
1702
+ return false unless options
1703
+ return static_global_auto_load_bundle == true if options == :omitted
1704
+
1705
+ component_option = static_boolean_keyword(options, "auto_load_bundle")
1706
+ return component_option == true unless component_option == :omitted
1707
+
1708
+ static_global_auto_load_bundle == true
1709
+ end
1710
+
1711
+ def static_react_component_arguments(expression)
1712
+ statements = ripper_statements(expression)
1713
+ return unless statements.one?
1714
+
1715
+ %w[react_component react_component_hash].filter_map do |helper_name|
1716
+ static_named_component_arguments(statements.first, helper_name)
1717
+ end.first
1718
+ end
1719
+
1720
+ def static_react_component_assignment_arguments(expression)
1721
+ statements = ripper_statements(expression)
1722
+ return unless statements.one?
1723
+
1724
+ assignment = statements.first
1725
+ return unless ast_node?(assignment, :assign)
1726
+
1727
+ field = assignment[1]
1728
+ return unless ast_node?(field, :var_field) && token_type?(field[1], :@ident)
1729
+
1730
+ %w[react_component react_component_hash].filter_map do |helper_name|
1731
+ static_named_component_arguments(assignment[2], helper_name)
1732
+ end.first
1733
+ end
1734
+
1735
+ def static_named_component_arguments(node, helper_name)
1736
+ node = static_self_component_hash_call(node) || node if helper_name == "react_component_hash"
1737
+ call_arguments = static_call_arguments(node, helper_name) || static_self_call_arguments(node, helper_name)
1738
+ arguments = static_argument_values(call_arguments)
1739
+ component_name = static_string_value(arguments.first) if arguments&.any?
1740
+ arguments if component_name&.match?(/\A[A-Za-z0-9_:]+\z/)
1741
+ end
1742
+
1743
+ def static_self_call_arguments(node, helper_name)
1744
+ return unless ast_node?(node, :method_add_arg) && direct_self_helper_call?(node[1], helper_name)
1745
+
1746
+ argument_parentheses = node[2]
1747
+ argument_parentheses[1] if ast_node?(argument_parentheses, :arg_paren)
1748
+ end
1749
+
1750
+ def static_self_component_hash_call(node)
1751
+ return unless ast_node?(node, :aref)
1752
+
1753
+ call = node[1]
1754
+ return unless ast_node?(call, :method_add_arg) && direct_self_helper_call?(call[1], "react_component_hash")
1755
+
1756
+ access_arguments = static_argument_values(node[2])
1757
+ call if access_arguments&.one? && static_string_value(access_arguments.first) == "componentHtml"
1758
+ end
1759
+
1760
+ def ripper_statements(source)
1761
+ program = Ripper.sexp(source)
1762
+ statements = program[1] if ast_node?(program, :program)
1763
+ statements.is_a?(Array) ? statements : []
1764
+ end
1765
+
1766
+ def static_call_arguments(node, method_name)
1767
+ return node[2] if ast_node?(node, :command) && token_named?(node[1], method_name)
1768
+ return unless ast_node?(node, :method_add_arg) && fcall_named?(node[1], method_name)
1769
+
1770
+ argument_parentheses = node[2]
1771
+ argument_parentheses[1] if ast_node?(argument_parentheses, :arg_paren)
1772
+ end
1773
+
1774
+ def static_argument_values(node)
1775
+ return unless ast_node?(node, :args_add_block) && node[2] == false
1776
+
1777
+ node[1] if node[1].is_a?(Array)
1778
+ end
1779
+
1780
+ def ast_node?(node, type)
1781
+ node.is_a?(Array) && node.first == type
1782
+ end
1783
+
1784
+ def token_type?(node, type)
1785
+ ast_node?(node, type) && node[1].is_a?(String)
1786
+ end
1787
+
1788
+ def fcall_named?(node, name)
1789
+ ast_node?(node, :fcall) && token_named?(node[1], name)
1790
+ end
1791
+
1792
+ def token_named?(node, name)
1793
+ token_type?(node, :@ident) && node[1] == name
1794
+ end
1795
+
1796
+ def static_string_value(node)
1797
+ return unless ast_node?(node, :string_literal)
1798
+
1799
+ content = node[1]
1800
+ return unless ast_node?(content, :string_content)
1801
+
1802
+ parts = content.drop(1)
1803
+ return unless parts.all? { |part| token_type?(part, :@tstring_content) }
1804
+
1805
+ parts.map { |part| part[1] }.join
1806
+ end
1807
+
1808
+ def static_boolean_keyword(associations, keyword)
1809
+ matches = associations.select do |association|
1810
+ static_label_association?(association, "#{keyword}:")
1811
+ end
1812
+ return :omitted if matches.empty?
1813
+ return unless matches.one?
1814
+
1815
+ static_boolean_value(matches.first[2])
1816
+ end
1817
+
1818
+ def static_component_options(arguments)
1819
+ return :omitted if arguments.empty?
1820
+ return unless arguments.one?
1821
+
1822
+ associations = static_hash_associations(arguments.first)
1823
+ associations if associations&.all? { |association| static_data_association?(association) }
1824
+ end
1825
+
1826
+ def static_data_association?(node)
1827
+ static_label_association?(node) && static_data_value?(node[2])
1828
+ end
1829
+
1830
+ def static_data_value?(node)
1831
+ return true if static_scalar_value?(node)
1832
+
1833
+ case node&.first
1834
+ when :array
1835
+ static_data_array?(node[1])
1836
+ when :hash
1837
+ static_data_hash?(node)
1838
+ when :symbol_literal
1839
+ static_symbol_literal?(node)
1840
+ when :unary
1841
+ static_numeric_unary_literal?(node)
1842
+ else
1843
+ false
1844
+ end
1845
+ end
1846
+
1847
+ def static_data_array?(values)
1848
+ values.nil? || (values.is_a?(Array) && values.all? { |value| static_data_value?(value) })
1849
+ end
1850
+
1851
+ def static_data_hash?(node)
1852
+ associations = static_hash_associations(node)
1853
+ associations&.all? { |association| static_data_association?(association) }
1854
+ end
1855
+
1856
+ def static_symbol_literal?(node)
1857
+ !static_symbol_value(node).nil?
1858
+ end
1859
+
1860
+ def static_symbol_value(node)
1861
+ symbol = node[1] if ast_node?(node, :symbol_literal)
1862
+ token = symbol[1] if ast_node?(symbol, :symbol)
1863
+ token[1] if token_type?(token, :@ident) || token_type?(token, :@op)
1864
+ end
1865
+
1866
+ def static_numeric_unary_literal?(node)
1867
+ %i[+@ -@].include?(node[1]) && (token_type?(node[2], :@int) || token_type?(node[2], :@float))
1868
+ end
1869
+
1870
+ def static_scalar_value?(node)
1871
+ return true unless static_string_value(node).nil?
1872
+ return true if token_type?(node, :@int) || token_type?(node, :@float)
1873
+
1874
+ token = node[1] if ast_node?(node, :var_ref)
1875
+ token_type?(token, :@ivar) || (token_type?(token, :@kw) && %w[true false nil].include?(token[1]))
1876
+ end
1877
+
1878
+ def static_hash_associations(node)
1879
+ return node[1] if ast_node?(node, :bare_assoc_hash)
1880
+ return unless ast_node?(node, :hash)
1881
+
1882
+ association_list = node[1]
1883
+ return [] unless association_list
1884
+
1885
+ association_list[1] if ast_node?(association_list, :assoclist_from_args)
1886
+ end
1887
+
1888
+ def static_label_association?(node, label = nil)
1889
+ return false unless ast_node?(node, :assoc_new) && ast_node?(node[1], :@label)
1890
+
1891
+ label.nil? || node[1][1] == label
1892
+ end
1893
+
1894
+ def static_global_auto_load_bundle
1895
+ return @static_global_auto_load_bundle if defined?(@static_global_auto_load_bundle)
1896
+ return (@static_global_auto_load_bundle = nil) unless File.exist?(REACT_ON_RAILS_INITIALIZER_PATH)
1897
+
1898
+ source = File.read(REACT_ON_RAILS_INITIALIZER_PATH)
1899
+ @static_global_auto_load_bundle = static_configured_boolean(source, "auto_load_bundle")
1900
+ rescue StandardError
1901
+ @static_global_auto_load_bundle = nil
1902
+ end
1903
+
1904
+ def static_configured_boolean(source, setting_name)
1905
+ configure_block = unique_top_level_configure_block(source)
1906
+ return unless configure_block
1907
+
1908
+ config_name = configure_block_parameter_name(configure_block)
1909
+ block_body = configure_block.dig(2, 2)
1910
+ direct_assignments = static_direct_config_assignments(block_body, config_name)
1911
+ return unless direct_assignments
1912
+ return unless ast_identifier_count(block_body, setting_name) == 1
1913
+
1914
+ assignments = direct_assignments.select do |statement|
1915
+ config_assignment?(statement, config_name, setting_name)
1916
+ end
1917
+ static_boolean_value(assignments.first[2]) if assignments.one?
1918
+ end
1919
+
1920
+ def unique_top_level_configure_block(source)
1921
+ source_statements = ripper_statements(source)
1922
+ return unless source_statements.one?
1923
+
1924
+ configure_block = source_statements.first
1925
+ configure_block if react_on_rails_configure_block?(configure_block)
1926
+ end
1927
+
1928
+ def static_direct_config_assignments(block_body, config_name)
1929
+ statements = static_body_statements(block_body)
1930
+ return unless config_name && statements
1931
+ return unless statements.all? { |statement| direct_config_assignment?(statement, config_name) }
1932
+ return unless statements.all? { |statement| static_literal_data_value?(statement[2]) }
1933
+ return unless ast_identifier_count(block_body, config_name) == statements.length
1934
+
1935
+ statements
1936
+ end
1937
+
1938
+ def static_literal_data_value?(node)
1939
+ return true if static_literal_scalar_value?(node)
1940
+ return static_literal_array?(node[1]) if ast_node?(node, :array)
1941
+ return static_literal_hash?(node) if ast_node?(node, :hash)
1942
+ return static_symbol_literal?(node) if ast_node?(node, :symbol_literal)
1943
+ return static_numeric_unary_literal?(node) if ast_node?(node, :unary)
1944
+
1945
+ false
1946
+ end
1947
+
1948
+ def static_literal_scalar_value?(node)
1949
+ return true unless static_string_value(node).nil?
1950
+ return true if token_type?(node, :@int) || token_type?(node, :@float)
1951
+
1952
+ keyword = node[1] if ast_node?(node, :var_ref)
1953
+ token_type?(keyword, :@kw) && %w[true false nil].include?(keyword[1])
1954
+ end
1955
+
1956
+ def static_literal_array?(values)
1957
+ values.nil? || (values.is_a?(Array) && values.all? { |value| static_literal_data_value?(value) })
1958
+ end
1959
+
1960
+ def static_literal_hash?(node)
1961
+ associations = static_hash_associations(node)
1962
+ associations&.all? do |association|
1963
+ static_label_association?(association) && static_literal_data_value?(association[2])
1964
+ end
1965
+ end
1966
+
1967
+ def ast_identifier_count(node, identifier)
1968
+ return 0 unless node.is_a?(Array)
1969
+
1970
+ count = token_named?(node, identifier) ? 1 : 0
1971
+ children = node.first.is_a?(Symbol) ? node.drop(1) : node
1972
+ count + children.sum { |child| ast_identifier_count(child, identifier) }
1973
+ end
1974
+
1975
+ def configure_block_parameter_name(node)
1976
+ block_var = node.dig(2, 1)
1977
+ parameters = block_var[1] if ast_node?(block_var, :block_var)
1978
+ required = parameters[1] if ast_node?(parameters, :params)
1979
+ required.first[1] if required&.one? && token_type?(required.first, :@ident)
1980
+ end
1981
+
1982
+ def react_on_rails_configure_block?(node)
1983
+ return false unless ast_node?(node, :method_add_block)
1984
+
1985
+ call = node[1]
1986
+ block = node[2]
1987
+ ast_node?(call, :call) && static_constant_reference(call[1]) == "ReactOnRails" &&
1988
+ token_named?(call[3], "configure") && ast_node?(block, :do_block)
1989
+ end
1990
+
1991
+ def static_constant_reference(node)
1992
+ static_constant_token_value(node[1]) if ast_node?(node, :var_ref)
1993
+ end
1994
+
1995
+ def config_assignment?(node, config_name, setting_name)
1996
+ return false unless direct_config_assignment?(node, config_name)
1997
+
1998
+ field = node[1]
1999
+ token_named?(field[3], setting_name)
2000
+ end
2001
+
2002
+ def direct_config_assignment?(node, config_name)
2003
+ return false unless ast_node?(node, :assign)
2004
+
2005
+ field = node[1]
2006
+ ast_node?(field, :field) && static_identifier_reference(field[1]) == config_name
2007
+ end
2008
+
2009
+ def static_identifier_reference(node)
2010
+ token = node[1] if ast_node?(node, :var_ref)
2011
+ token[1] if token_type?(token, :@ident)
2012
+ end
2013
+
2014
+ def static_boolean_value(node)
2015
+ return unless ast_node?(node, :var_ref)
2016
+
2017
+ keyword = node[1]
2018
+ return true if token_type?(keyword, :@kw) && keyword[1] == "true"
2019
+ return false if token_type?(keyword, :@kw) && keyword[1] == "false"
2020
+
2021
+ nil
2022
+ end
2023
+
2024
+ def shakapacker_manifest_data
2025
+ return @shakapacker_manifest_data if defined?(@shakapacker_manifest_data)
2026
+
2027
+ require "shakapacker"
2028
+ manifest_path = ::Shakapacker.config.manifest_path
2029
+ @shakapacker_manifest_data = JSON.parse(manifest_path.read) if manifest_path&.file?
2030
+ rescue LoadError, StandardError
2031
+ @shakapacker_manifest_data = nil
2032
+ end
2033
+
1269
2034
  # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
1270
2035
  def check_server_rendering_engine
1271
2036
  return unless defined?(ReactOnRails)
@@ -4301,26 +5066,34 @@ module ReactOnRails
4301
5066
  RSC_PACKAGE_NAME = "react-on-rails-rsc"
4302
5067
  RSC_MINIMUM_PACKAGE_VERSION = "19.2.1"
4303
5068
  RSC_SUPPORTED_PACKAGE_MAJOR = 19
4304
- # Package and React minors differ: RSC 19.3 uses the React 19.2.8 runtime.
4305
- RSC_REACT_MINIMUM_BY_PACKAGE_MINOR = { 2 => "19.2.7", 3 => "19.2.8" }.freeze
4306
- RSC_SUPPORTED_PACKAGE_MINORS = RSC_REACT_MINIMUM_BY_PACKAGE_MINOR.keys.freeze
5069
+ # Keep in sync with packages/react-on-rails-pro-node-renderer/src/shared/rscPeerSupport.ts.
5070
+ # Each row covers react-on-rails-rsc patches rsc_min_patch..rsc_max_patch (nil = open-ended)
5071
+ # on rsc_minor. React must match the Flight line the RSC package bundles:
5072
+ # 19.2.x ships Flight 19.2, published 19.3.0 ships Flight 19.2.8 (React on Rails 17.1.0),
5073
+ # and 19.3.1+ (starting with 19.3.1-rc.0) ships Flight 19.3.0.
5074
+ RSC_REACT_SUPPORT_RANGES = [
5075
+ { rsc_minor: 2, rsc_min_patch: 1, rsc_max_patch: nil, minor: 2, min_patch: 7 },
5076
+ { rsc_minor: 3, rsc_min_patch: 0, rsc_max_patch: 0, minor: 2, min_patch: 8 },
5077
+ { rsc_minor: 3, rsc_min_patch: 1, rsc_max_patch: nil, minor: 3, min_patch: 0 }
5078
+ ].freeze
5079
+ RSC_SUPPORTED_PACKAGE_MINORS = RSC_REACT_SUPPORT_RANGES.map { |range| range.fetch(:rsc_minor) }.uniq.freeze
4307
5080
  RSC_SUPPORTED_PACKAGE_LINE = RSC_SUPPORTED_PACKAGE_MINORS.map do |minor|
4308
- "#{RSC_SUPPORTED_PACKAGE_MAJOR}.#{minor}.x"
5081
+ min_patch = RSC_REACT_SUPPORT_RANGES.select { |range| range.fetch(:rsc_minor) == minor }
5082
+ .map { |range| range.fetch(:rsc_min_patch) }.min
5083
+ line = "#{RSC_SUPPORTED_PACKAGE_MAJOR}.#{minor}.x"
5084
+ min_patch.positive? ? "#{line} starting at #{RSC_SUPPORTED_PACKAGE_MAJOR}.#{minor}.#{min_patch}" : line
4309
5085
  end.join(" or ")
4310
5086
  RSC_PACKAGE_INSTALL_VERSION = ReactOnRails::Generators::JsDependencyManager::RSC_PACKAGE_VERSION_PIN
4311
- # A temporary prerelease pin may define a matching exception during a future soak.
4312
- # Stable pins leave this nil so prereleases do not satisfy the package floor.
4313
- RSC_MINIMUM_PACKAGE_PRERELEASE_VERSION =
4314
- RSC_PACKAGE_INSTALL_VERSION.include?("-") ? RSC_PACKAGE_INSTALL_VERSION : nil
5087
+ # Admits the 19.3.1-rc.x soak. The generator pin stays on stable 19.2.1.
5088
+ # Remove once react-on-rails-rsc 19.3.1 ships stable.
5089
+ RSC_MINIMUM_PACKAGE_PRERELEASE_VERSION = "19.3.1-rc.0"
4315
5090
  RSC_MINIMUM_REACT_VERSION = "19.2.7"
4316
5091
  RSC_MINIMUM_REACT_VERSION_TUPLE = RSC_MINIMUM_REACT_VERSION.split(".").map(&:to_i).freeze
4317
5092
  RSC_SUPPORTED_REACT_MAJOR = RSC_MINIMUM_REACT_VERSION_TUPLE.fetch(0)
4318
- RSC_SUPPORTED_REACT_MINORS = RSC_REACT_MINIMUM_BY_PACKAGE_MINOR.values.map do |version|
4319
- version.split(".").fetch(1).to_i
4320
- end.uniq.freeze
4321
- RSC_SUPPORTED_REACT_LINE = RSC_SUPPORTED_REACT_MINORS.map do |minor|
4322
- "#{RSC_SUPPORTED_REACT_MAJOR}.#{minor}.x"
4323
- end.join(" or ")
5093
+ RSC_SUPPORTED_REACT_MINORS = RSC_REACT_SUPPORT_RANGES.map { |range| range.fetch(:minor) }.uniq.freeze
5094
+ RSC_SUPPORTED_REACT_LINE = RSC_REACT_SUPPORT_RANGES.map do |range|
5095
+ "#{RSC_SUPPORTED_REACT_MAJOR}.#{range.fetch(:minor)}.x"
5096
+ end.uniq.join(" or ")
4324
5097
  RSC_DIST_TAGS_TO_CHECK = %w[next rc].freeze
4325
5098
  NPM_VIEW_FETCH_TIMEOUT_MS = 5_000
4326
5099
  NPM_VIEW_FETCH_TIMEOUT_SECONDS = NPM_VIEW_FETCH_TIMEOUT_MS / 1000.0
@@ -4608,14 +5381,17 @@ module ReactOnRails
4608
5381
  return true unless check_rsc_package_minimum_version(rsc_package)
4609
5382
 
4610
5383
  unless rsc_package_declares_react_peer_dependencies?(rsc_package)
4611
- return true unless check_rsc_supported_react_packages_for_package(rsc_package, react_version)
4612
-
4613
5384
  checker.add_warning(<<~MSG.strip)
4614
5385
  ⚠️ #{RSC_PACKAGE_NAME} #{rsc_package['version']} does not declare React peer dependencies.
4615
5386
 
4616
- Falling back to the legacy React version heuristic.
5387
+ Checking React against the React on Rails Pro support window for this package instead.
4617
5388
  MSG
4618
- return false
5389
+ if check_rsc_supported_react_packages_for_package(rsc_package, react_version)
5390
+ checker.add_success(
5391
+ "✅ React #{react_version} is compatible with #{RSC_PACKAGE_NAME} #{rsc_package['version']}"
5392
+ )
5393
+ end
5394
+ return true
4619
5395
  end
4620
5396
 
4621
5397
  peer_compatible = check_rsc_package_peer_compatibility(rsc_package, react_version)
@@ -4631,27 +5407,40 @@ module ReactOnRails
4631
5407
  rsc_version = rsc_package["version"].to_s
4632
5408
  return true if rsc_package_version_at_or_above_minimum?(rsc_version)
4633
5409
 
4634
- react_requirements = RSC_REACT_MINIMUM_BY_PACKAGE_MINOR.map do |minor, minimum|
4635
- "RSC #{RSC_SUPPORTED_PACKAGE_MAJOR}.#{minor}.x requires stable React/React DOM ~#{minimum}"
4636
- end.join("; ")
4637
5410
  prerelease_requirement = if RSC_MINIMUM_PACKAGE_PRERELEASE_VERSION.present?
4638
5411
  "\n(or #{RSC_MINIMUM_PACKAGE_PRERELEASE_VERSION} during the RC soak)"
4639
5412
  else
4640
5413
  ""
4641
5414
  end
4642
5415
 
5416
+ rsc_install, react_install = rsc_package_floor_install_versions(rsc_version)
5417
+
4643
5418
  checker.add_error(<<~MSG.strip)
4644
5419
  🚫 #{RSC_PACKAGE_NAME} #{rsc_version.presence || 'unknown'} is not supported by React on Rails Pro 17 RSC.
4645
5420
 
4646
5421
  React on Rails Pro 17 requires #{RSC_PACKAGE_NAME} >= #{RSC_MINIMUM_PACKAGE_VERSION}#{prerelease_requirement}
4647
- on the supported #{RSC_SUPPORTED_PACKAGE_LINE} package line.
4648
- #{react_requirements}.
5422
+ on the supported #{RSC_SUPPORTED_PACKAGE_LINE} package line
5423
+ with React/React DOM #{react_install}+.
4649
5424
 
4650
- Fix: npm install react@#{ReactOnRails::Generators::JsDependencyManager::RSC_REACT_VERSION_RANGE} react-dom@#{ReactOnRails::Generators::JsDependencyManager::RSC_REACT_VERSION_RANGE} && npm install #{RSC_PACKAGE_NAME}@#{RSC_PACKAGE_INSTALL_VERSION} --save-exact
5425
+ Fix: npm install react@~#{react_install} react-dom@~#{react_install} #{RSC_PACKAGE_NAME}@#{rsc_install} --save-exact
4651
5426
  MSG
4652
5427
  false
4653
5428
  end
4654
5429
 
5430
+ # Only the 19.3.0 transition has a known published stable successor. Compatibility does not establish
5431
+ # publication for other rejected prereleases; use the generator pin for those versions.
5432
+ # Returns [rsc_version_to_install, react_version_to_install].
5433
+ def rsc_package_floor_install_versions(rsc_version)
5434
+ if npm_prerelease(rsc_version).present? && npm_version_tuple(rsc_version) == [19, 3, 0]
5435
+ stable_version = npm_version_tuple(rsc_version).join(".")
5436
+ if rsc_stable_package_version_supported?(stable_version)
5437
+ return [stable_version, recommended_react_install_version_for_rsc_package(stable_version)]
5438
+ end
5439
+ end
5440
+
5441
+ [RSC_PACKAGE_INSTALL_VERSION, RSC_MINIMUM_REACT_VERSION]
5442
+ end
5443
+
4655
5444
  def rsc_package_version_at_or_above_minimum?(rsc_version)
4656
5445
  return false if rsc_version.blank?
4657
5446
  return true if rsc_stable_package_version_supported?(rsc_version)
@@ -4664,19 +5453,28 @@ module ReactOnRails
4664
5453
  def rsc_stable_package_version_supported?(rsc_version)
4665
5454
  return false if npm_prerelease(rsc_version).present?
4666
5455
 
4667
- major, minor, = npm_version_tuple(rsc_version)
5456
+ major, = npm_version_tuple(rsc_version)
4668
5457
  major == RSC_SUPPORTED_PACKAGE_MAJOR &&
4669
- RSC_SUPPORTED_PACKAGE_MINORS.include?(minor) &&
5458
+ rsc_react_support_range_for(rsc_version) &&
4670
5459
  npm_version_compare(rsc_version, RSC_MINIMUM_PACKAGE_VERSION) >= 0
4671
5460
  end
4672
5461
 
5462
+ def rsc_react_support_range_for(rsc_version)
5463
+ _rsc_major, rsc_minor, rsc_patch = npm_version_tuple(rsc_version)
5464
+ RSC_REACT_SUPPORT_RANGES.find do |range|
5465
+ max_patch = range.fetch(:rsc_max_patch)
5466
+ range.fetch(:rsc_minor) == rsc_minor &&
5467
+ rsc_patch >= range.fetch(:rsc_min_patch) &&
5468
+ (max_patch.nil? || rsc_patch <= max_patch)
5469
+ end
5470
+ end
5471
+
4673
5472
  def unsupported_rsc_react_version?(react_version)
4674
5473
  !supported_rsc_react_version?(react_version)
4675
5474
  end
4676
5475
 
4677
5476
  def supported_rsc_react_version?(react_version)
4678
- npm_prerelease(react_version).empty? &&
4679
- supported_rsc_react_line?(react_version) &&
5477
+ supported_rsc_react_line?(react_version) &&
4680
5478
  !npm_version_less_than?(react_version, RSC_MINIMUM_REACT_VERSION)
4681
5479
  end
4682
5480
 
@@ -4701,25 +5499,51 @@ module ReactOnRails
4701
5499
 
4702
5500
  def check_rsc_supported_react_version_for_package(rsc_package, package_name, package_version)
4703
5501
  return true if package_version.blank?
4704
-
4705
- rsc_minor = npm_version_tuple(rsc_package["version"])[1]
4706
- minimum_version = RSC_REACT_MINIMUM_BY_PACKAGE_MINOR.fetch(rsc_minor)
4707
- return true if npm_prerelease(package_version).blank? && supported_rsc_react_line?(package_version) &&
4708
- !npm_version_less_than?(package_version, minimum_version)
5502
+ return true if supported_rsc_react_version_for_package?(package_version, rsc_package["version"].to_s)
4709
5503
 
4710
5504
  package_label = package_name == "react" ? "React" : "React DOM"
5505
+ rsc_version = rsc_package["version"].to_s
5506
+ react_range_label = supported_react_range_label_for_rsc_package(rsc_version)
5507
+ react_install = recommended_react_install_version_for_rsc_package(rsc_version)
4711
5508
 
4712
5509
  checker.add_error(<<~MSG.strip)
4713
5510
  🚫 #{RSC_PACKAGE_NAME} #{rsc_package['version']} is installed with unsupported #{package_label} #{package_version}.
4714
5511
 
4715
- React on Rails Pro 17 RSC currently supports stable React/React DOM #{RSC_SUPPORTED_REACT_LINE} with patch >= #{minimum_version}.
5512
+ React on Rails Pro 17 RSC currently supports React/React DOM #{react_range_label}.
4716
5513
  The node renderer enforces the same support window at startup.
4717
5514
 
4718
- Fix: npm install react@~#{minimum_version} react-dom@~#{minimum_version}
5515
+ Fix: npm install react@~#{react_install} react-dom@~#{react_install} --save-exact
4719
5516
  MSG
4720
5517
  false
4721
5518
  end
4722
5519
 
5520
+ def supported_rsc_react_version_for_package?(react_version, rsc_version)
5521
+ range = rsc_react_support_range_for(rsc_version)
5522
+ return false unless range
5523
+ # The prerelease exception is for the RSC package soak, not its React peers.
5524
+ return false if npm_prerelease(react_version).present?
5525
+
5526
+ major, minor, patch = npm_version_tuple(react_version)
5527
+ major == RSC_SUPPORTED_REACT_MAJOR &&
5528
+ minor == range.fetch(:minor) &&
5529
+ patch >= range.fetch(:min_patch)
5530
+ end
5531
+
5532
+ def supported_react_range_label_for_rsc_package(rsc_version)
5533
+ range = rsc_react_support_range_for(rsc_version)
5534
+ return RSC_SUPPORTED_REACT_LINE unless range
5535
+
5536
+ "#{RSC_SUPPORTED_REACT_MAJOR}.#{range.fetch(:minor)}.x with patch >= " \
5537
+ "#{RSC_SUPPORTED_REACT_MAJOR}.#{range.fetch(:minor)}.#{range.fetch(:min_patch)} (stable releases only)"
5538
+ end
5539
+
5540
+ def recommended_react_install_version_for_rsc_package(rsc_version)
5541
+ range = rsc_react_support_range_for(rsc_version)
5542
+ return RSC_MINIMUM_REACT_VERSION unless range
5543
+
5544
+ "#{RSC_SUPPORTED_REACT_MAJOR}.#{range.fetch(:minor)}.#{range.fetch(:min_patch)}"
5545
+ end
5546
+
4723
5547
  def check_rsc_react_dom_matches_react_for_package(rsc_package, react_version, react_dom_version)
4724
5548
  return true if react_dom_version.blank? || react_dom_version == react_version
4725
5549
 
@@ -4802,11 +5626,19 @@ module ReactOnRails
4802
5626
  React Server Components depend on React internal server APIs that can change between React minors.
4803
5627
 
4804
5628
  Fix: install matching versions, for example:
4805
- npm install #{package_name}@#{peer_range} #{RSC_PACKAGE_NAME}@#{rsc_version} --save-exact
5629
+ npm install #{package_name}@#{rsc_peer_install_spec(rsc_version, peer_range)} #{RSC_PACKAGE_NAME}@#{rsc_version} --save-exact
4806
5630
  MSG
4807
5631
  false
4808
5632
  end
4809
5633
 
5634
+ # A package peer range such as ^19.2.8 can admit React minors the support table rejects for that RSC
5635
+ # release, so prefer the table's React line when the installed RSC version has one.
5636
+ def rsc_peer_install_spec(rsc_version, peer_range)
5637
+ return peer_range unless rsc_react_support_range_for(rsc_version)
5638
+
5639
+ "~#{recommended_react_install_version_for_rsc_package(rsc_version)}"
5640
+ end
5641
+
4810
5642
  def check_rsc_package_dist_tags(rsc_package, package_root)
4811
5643
  if ENV[SKIP_NPM_DIST_TAG_CHECK_ENV].present?
4812
5644
  checker.add_info(
@@ -4825,8 +5657,7 @@ module ReactOnRails
4825
5657
  checker.add_warning(<<~MSG.strip)
4826
5658
  ⚠️ #{RSC_PACKAGE_NAME} #{installed_version} is behind the npm #{tag} dist-tag #{tag_version}.
4827
5659
 
4828
- Before upgrading, check the candidate package's React peer requirements
4829
- and the React runtime versions supported by this React on Rails Pro release.
5660
+ React Server Components track React minor versions. If your React version is on the #{tag_version.split('.')[0, 2].join('.')} line, install the matching RSC package instead of relying on a stale latest tag.
4830
5661
 
4831
5662
  Check peer requirements with:
4832
5663
  npm view #{RSC_PACKAGE_NAME}@#{tag_version} peerDependencies