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
@@ -737,8 +737,8 @@ module ReactOnRails
737
737
  end
738
738
 
739
739
  # Wraps console replay JavaScript code in a script tag with CSP nonce if available.
740
- # The console_script_code is already sanitized by scriptSanitizedVal() in the JavaScript layer,
741
- # so using html_safe here is secure.
740
+ # The console_script_code is already escaped by escapeScript() (applied inside
741
+ # consoleReplay() in the JavaScript layer), so using html_safe here is secure.
742
742
  def wrap_console_script_with_nonce(console_script_code)
743
743
  return "" if console_script_code.blank?
744
744
 
@@ -748,7 +748,7 @@ module ReactOnRails
748
748
  script_options = { id: "consoleReplayLog" }
749
749
  script_options[:nonce] = nonce if nonce.present?
750
750
 
751
- # Safe to use html_safe because content is pre-sanitized via scriptSanitizedVal()
751
+ # Safe to use html_safe because content is pre-escaped via escapeScript()
752
752
  content_tag(:script, console_script_code.html_safe, script_options)
753
753
  end
754
754
 
@@ -105,13 +105,11 @@ module ReactOnRails
105
105
  end
106
106
 
107
107
  def dom_id
108
- @dom_id ||= options.fetch(:id) do
109
- if random_dom_id
110
- generate_unique_dom_id
111
- else
112
- base_dom_id
113
- end
114
- end
108
+ @dom_id ||= if options[:id].nil?
109
+ random_dom_id ? generate_unique_dom_id : base_dom_id
110
+ else
111
+ options[:id]
112
+ end
115
113
  end
116
114
 
117
115
  def random_dom_id?
@@ -149,8 +149,7 @@ module ReactOnRails
149
149
 
150
150
  def read_bundle_js_code
151
151
  server_js_file = ReactOnRails::Utils.server_bundle_js_file_path
152
- server_bundle_path_is_http = ReactOnRails::Utils.server_bundle_path_is_http?
153
- if server_bundle_path_is_http
152
+ if ReactOnRails::Utils.server_bundle_path_is_http?
154
153
  file_url_to_string(server_js_file)
155
154
  else
156
155
  File.read(server_js_file)
@@ -160,19 +159,12 @@ module ReactOnRails
160
159
  # (a supported config convenience, e.g. https://:password@host:3800). This is the
161
160
  # public entry point that file_url_to_string is called from, so without this the
162
161
  # sanitization inside file_url_to_string's own raise/rescue is moot for any real
163
- # caller — the credential would still leak here regardless. Errors already wrapped by
164
- # file_url_to_string have passed through the same message sanitizer; scanning that
165
- # wrapper again would treat its diagnostic text as fresh untrusted input. Other errors
166
- # are scrubbed here because URI::InvalidURIError embeds the raw URL in its message.
167
- sanitized_url = sanitized_renderer_url(server_js_file)
168
- sanitized_error = if server_bundle_path_is_http && e.is_a?(ReactOnRails::ServerBundleLoadError)
169
- e.message
170
- else
171
- sanitized_renderer_error_message(e.message, server_js_file, sanitized_url)
172
- end
173
- msg = "You specified server rendering JS file: #{sanitized_url}, but it cannot " \
162
+ # caller — the credential would still leak here regardless. e.message is scrubbed too:
163
+ # a URL malformed enough that URI.parse raises embeds the raw, unsanitized URL verbatim
164
+ # in URI::InvalidURIError's own message.
165
+ msg = "You specified server rendering JS file: #{sanitized_renderer_url(server_js_file)}, but it cannot " \
174
166
  "be read. You may set the server_bundle_js_file in your configuration to be \"\" to " \
175
- "avoid this warning.\nError is: #{sanitized_error}\n\n" \
167
+ "avoid this warning.\nError is: #{strip_userinfo(e.message, configured_url: server_js_file)}\n\n" \
176
168
  "#{Utils.default_troubleshooting_section}"
177
169
  raise ReactOnRails::ServerBundleLoadError, msg
178
170
  end
@@ -400,106 +392,27 @@ module ReactOnRails
400
392
  nil
401
393
  end
402
394
 
403
- # Strips any embedded credentials from a configured renderer URL before it is
404
- # interpolated into an error message, so a password in the URL (a supported config
405
- # convenience, e.g. https://:password@host:3800) cannot leak into logs or error
406
- # trackers. One configured value is not free-form prose: multiple schemes or line breaks
407
- # after its first scheme make it structurally ambiguous, so that URL suffix fails closed
408
- # through its final @. Any non-URL prefix is preserved for useful error context.
395
+ # Removes credentials from a renderer URL for safe display. See #5046.
409
396
  def sanitized_renderer_url(url)
410
- return url if url.nil? || url.empty?
411
-
412
- scheme = url.match(%r{https?://}i)
413
- return url unless scheme
414
-
415
- prefix = url[...scheme.begin(0)]
416
- configured_url = url[scheme.begin(0)..]
417
- sanitized_url = if configured_url.match?(/[\r\n]/) || configured_url.scan(%r{https?://}i).length > 1
418
- strip_malformed_url_userinfo(configured_url)
419
- else
420
- sanitized_valid_http_url(configured_url) || strip_malformed_url_userinfo(configured_url)
421
- end
422
-
423
- "#{prefix}#{sanitized_url}"
424
- end
425
-
426
- def sanitized_renderer_error_message(message, raw_url, sanitized_url = sanitized_renderer_url(raw_url))
427
- message = message.to_s
428
- return strip_userinfo(message) if raw_url.nil? || raw_url.empty?
429
-
430
- # URI::InvalidURIError embeds String#inspect, while other errors may embed the raw value.
431
- message = message.gsub(raw_url.inspect) { sanitized_url.inspect }
432
- .gsub(raw_url) { sanitized_url }
433
- strip_userinfo(message)
434
- end
435
-
436
- # Protects successfully parsed bare HTTP(S) tokens by assembling the result from spans,
437
- # without collision-prone placeholder text. A candidate remains unprotected when any @
438
- # follows it before the next scheme: punctuation, prose, and line breaks are not structural
439
- # proof that the @ is unrelated to malformed userinfo. When an unresolved region precedes
440
- # a credential-bearing candidate, that region is discarded and the candidate is sanitized
441
- # independently rather than allowing one fallback match to consume through another scheme.
442
- # Ambiguous text may be over-redacted for safety.
443
- def strip_userinfo(text)
444
- text = text.to_s
445
- sanitized = text.dup.clear
446
- unprotected_start = 0
447
-
448
- text.to_enum(:scan, %r{https?://(?:(?!https?://)[^\s"'])+}i).each do
449
- match = Regexp.last_match
450
- protected_url = sanitized_valid_http_url(match[0])
451
- next unless protected_url
452
- next if userinfo_delimiter_before_next_scheme?(text, match)
453
-
454
- unprotected = text[unprotected_start...match.begin(0)]
455
- sanitized << sanitized_unprotected_prefix(unprotected, match[0], protected_url)
456
- sanitized << protected_url
457
- unprotected_start = match.end(0)
458
- end
459
-
460
- sanitized << strip_unprotected_userinfo(text[unprotected_start..])
461
- end
462
-
463
- def userinfo_delimiter_before_next_scheme?(text, match)
464
- continuation = text[match.end(0)..]
465
- boundary = continuation.match(%r{https?://}i)
466
- continuation = continuation[...boundary.begin(0)] if boundary
467
- continuation.include?("@")
468
- end
469
-
470
- def sanitized_unprotected_prefix(text, raw_url, sanitized_url)
471
- unresolved_scheme = text.match(%r{https?://}i)
472
- return strip_unprotected_userinfo(text) if raw_url == sanitized_url || unresolved_scheme.nil?
473
-
474
- text[...unresolved_scheme.begin(0)]
475
- end
476
-
477
- # URI handles valid URLs structurally, so an @ in the path is never mistaken for userinfo.
478
- # Returning nil for invalid input keeps that token unprotected for the fail-closed pass.
479
- def sanitized_valid_http_url(url)
480
- uri = URI.parse(url)
481
- return url if uri.userinfo.nil?
482
-
483
- # URI rejects a password without a user, so clear password first.
484
- uri.password = nil
485
- uri.user = nil
486
- uri.to_s
487
- rescue URI::Error
488
- nil
397
+ Utils.sanitize_url_for_display(url)
489
398
  end
490
399
 
491
- def strip_unprotected_userinfo(text)
492
- text.gsub(%r{https?://(?:(?!https?://).)*@}im) { |span| strip_malformed_url_userinfo(span) }
400
+ # Scrubs credentials from error-message text. When the configured URL is
401
+ # known, replaces it exactly first (handles tricky passwords), then cleans
402
+ # any remaining URL-like patterns in the prose.
403
+ def strip_userinfo(text, configured_url: nil)
404
+ sanitized_text = if configured_url
405
+ text.to_s.gsub(configured_url) { sanitized_renderer_url(configured_url) }
406
+ else
407
+ text.to_s
408
+ end
409
+ Utils.sanitize_error_text(sanitized_text)
493
410
  end
494
411
 
495
- # For malformed URLs or unprotected scheme-delimited spans, remove through the last @. The
496
- # retained suffix is best-effort context; ambiguous malformed diagnostics may lose text.
497
- def strip_malformed_url_userinfo(url)
498
- scheme = url.match(%r{\Ahttps?://}i)
499
- userinfo_delimiter = url.rindex("@")
500
- return url unless scheme && userinfo_delimiter
412
+ def sanitized_url_error_message(error, configured_url:)
413
+ return strip_userinfo(error.message, configured_url:) unless error.is_a?(URI::InvalidURIError)
501
414
 
502
- "#{url[...scheme.end(0)]}#{url[(userinfo_delimiter + 1)..]}"
415
+ "bad URI (sanitized): #{sanitized_renderer_url(configured_url).inspect}"
503
416
  end
504
417
 
505
418
  def file_url_to_string(url)
@@ -518,10 +431,10 @@ module ReactOnRails
518
431
  # equals the destination (UTF-8). charset_from_content_type returns UTF-8 for three of
519
432
  # the four cases this method exists to handle (no Content-Type header, no charset
520
433
  # parameter, and an explicit charset=utf-8), so `.encode(Encoding::UTF_8)` alone would
521
- # silently let invalid bytes through unchanged on those three paths — only a genuine
522
- # cross-encoding transcode (e.g. a declared ISO-8859-1 body) actually validates via
523
- # `encode`. valid_encoding? is therefore checked explicitly afterward so every path
524
- # fails fast, not just the cross-encoding one.
434
+ # silently let invalid bytes through unchanged on those three paths. A genuine
435
+ # cross-encoding transcode can reject malformed input only when the source encoding has
436
+ # invalid byte sequences of its own (such as Shift_JIS). valid_encoding? is therefore
437
+ # checked explicitly afterward so every path fails fast, not just the cross-encoding one.
525
438
  #
526
439
  # An undecodable byte sequence (a charset that doesn't actually match the bytes) is
527
440
  # deliberately raised here rather than silently replaced with U+FFFD: this is source
@@ -542,10 +455,8 @@ module ReactOnRails
542
455
  # message, which Rails.logger and error trackers can persist. e.message is scrubbed as
543
456
  # well as url itself: a URL malformed enough that URI.parse raises embeds the raw,
544
457
  # unsanitized URL verbatim in URI::InvalidURIError's own message.
545
- sanitized_url = sanitized_renderer_url(url)
546
- sanitized_error = sanitized_renderer_error_message(e.message, url, sanitized_url)
547
- msg = "file_url_to_string #{sanitized_url} failed\nError is: " \
548
- "#{sanitized_error}\n\n#{Utils.default_troubleshooting_section}"
458
+ msg = "file_url_to_string #{sanitized_renderer_url(url)} failed\nError is: " \
459
+ "#{sanitized_url_error_message(e, configured_url: url)}\n\n#{Utils.default_troubleshooting_section}"
549
460
  raise ReactOnRails::ServerBundleLoadError, msg
550
461
  end
551
462
 
@@ -3,6 +3,7 @@
3
3
  require "English"
4
4
  require "open3"
5
5
  require "rainbow"
6
+ require "uri"
6
7
  require "active_support"
7
8
  require "active_support/core_ext/string"
8
9
  require "shellwords"
@@ -444,6 +445,70 @@ module ReactOnRails
444
445
  end
445
446
  end
446
447
 
448
+ # Removes credentials from a URL for safe display in logs and error messages.
449
+ # Uses URI.parse for all heavy lifting — no regex on the URL itself.
450
+ #
451
+ # URI::File silently drops userinfo during parsing, so uri.to_s is already
452
+ # clean for file:// URLs. Malformed URLs that fail URI.parse get a safe
453
+ # placeholder. See #5046.
454
+ def self.sanitize_url_for_display(url)
455
+ return url if url.nil? || url.empty?
456
+
457
+ # Prepend http:// if there's no scheme — bare "host:port" strings are
458
+ # valid targets but URI.parse misparses them (treats "host" as the scheme).
459
+ # The HTTP stack makes the same assumption.
460
+ added_scheme = false
461
+ unless url.include?("://") || url.start_with?("/")
462
+ url = "http://#{url}"
463
+ added_scheme = true
464
+ end
465
+
466
+ begin
467
+ uri = URI.parse(url)
468
+ rescue URI::InvalidURIError
469
+ return "[unparseable URL redacted]"
470
+ end
471
+
472
+ if uri.userinfo
473
+ uri.password = nil
474
+ uri.user = nil
475
+ end
476
+
477
+ result = redact_query_values(uri)
478
+ # Strip the scheme we added — output should match the input format
479
+ result = result.delete_prefix("http://") if added_scheme
480
+ result
481
+ end
482
+
483
+ # Scrubs credentials from error-message text that may contain URLs.
484
+ def self.sanitize_error_text(text)
485
+ return text if text.nil? || text.empty?
486
+
487
+ text.to_s
488
+ .gsub(%r{//[^/?#]*@}, "//")
489
+ .gsub(%r{https?://[^\s]+}) do |match|
490
+ trimmed = match.sub(/[).,;:'">\]]+\z/, "")
491
+ sanitize_url_for_display(trimmed) + match[trimmed.length..]
492
+ end
493
+ end
494
+
495
+ # Replaces query values with [REDACTED], keeping keys for diagnostics.
496
+ # Bare components without = (like ?eyJhbGciOi...) are treated as opaque
497
+ # tokens and fully redacted. Operates on a parsed URI object.
498
+ def self.redact_query_values(uri)
499
+ if uri.query && !uri.query.empty?
500
+ begin
501
+ pairs = URI.decode_www_form(uri.query)
502
+ uri.query = pairs.map { |k, v| v.empty? ? "[REDACTED]" : "#{k}=[REDACTED]" }.join("&")
503
+ rescue ArgumentError
504
+ uri.query = "[REDACTED]"
505
+ end
506
+ end
507
+
508
+ uri.to_s
509
+ end
510
+ private_class_method :redact_query_values
511
+
447
512
  def self.default_troubleshooting_section
448
513
  <<~DEFAULT
449
514
  📞 Get Help & Support:
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ReactOnRails
4
- VERSION = "17.1.0"
4
+ VERSION = "17.2.0.rc.0"
5
5
  end
@@ -0,0 +1,170 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "yaml"
5
+ require "date"
6
+ require "strscan"
7
+
8
+ module ReactOnRails
9
+ class VersionChecker
10
+ # Parsers for the lockfile formats that NodePackageVersion#resolve_version consults beyond
11
+ # Yarn classic and package-lock.json: pnpm-lock.yaml, bun.lock, and Yarn Berry's YAML
12
+ # yarn.lock (issue #5049). Semantics deliberately mirror the pre-existing parsers: entries
13
+ # are found by package name, and any unreadable or unrecognized lockfile simply yields nil
14
+ # so resolution falls back to the package.json version.
15
+ #
16
+ # The binary bun.lockb is intentionally NOT parsed (undocumented format); projects still on
17
+ # it resolve from package.json until they migrate to bun's text lockfile
18
+ # (bun install --save-text-lockfile --frozen-lockfile --lockfile-only, bun >= 1.1.39).
19
+ module LockfileResolution
20
+ # Resolve the installed version from pnpm-lock.yaml or bun.lock in +dir+ (the directory
21
+ # containing the package.json being checked). Returns a version String or nil.
22
+ def self.version(dir, package_name)
23
+ pnpm_lock = File.join(dir, "pnpm-lock.yaml")
24
+ if File.exist?(pnpm_lock)
25
+ version = guard { PnpmLockfile.version(pnpm_lock, package_name) }
26
+ return version if version
27
+ end
28
+
29
+ bun_lock = File.join(dir, "bun.lock")
30
+ if File.exist?(bun_lock)
31
+ version = guard { BunLockfile.version(bun_lock, package_name) }
32
+ return version if version
33
+ end
34
+
35
+ nil
36
+ end
37
+
38
+ # Yarn Berry (yarn 2+) yarn.lock content, dispatched from version_from_yarn_lock when the
39
+ # __metadata marker is present. Returns a version String or nil.
40
+ def self.berry_yarn_version(content, package_name)
41
+ guard { BerryYarnLockfile.version(content, package_name) }
42
+ end
43
+
44
+ # A malformed, unreadable, or unexpectedly shaped lockfile must behave exactly like a
45
+ # missing one (package.json fallback), mirroring the JSON::ParserError rescue the
46
+ # package-lock.json parser has always had — never crash the Rails initializer.
47
+ def self.guard
48
+ yield
49
+ rescue Psych::Exception, JSON::ParserError, TypeError, NoMethodError,
50
+ SystemCallError, EncodingError, ArgumentError => e
51
+ # These classes are how bad lockfile CONTENT surfaces (shape surprises from dig chains,
52
+ # invalid encodings, parse errors) — but a bug inside a parser would raise the same
53
+ # classes. Leave a debug trace so a swallowed programming error stays discoverable.
54
+ log_ignored(e)
55
+ nil
56
+ end
57
+
58
+ def self.log_ignored(error)
59
+ return unless defined?(Rails) && Rails.logger
60
+
61
+ Rails.logger.debug { "React on Rails lockfile resolution ignored #{error.class}: #{error.message}" }
62
+ end
63
+
64
+ # pnpm-lock.yaml. Handles lockfileVersion 5.x (pnpm <= 7: bare version strings), 6.0
65
+ # (pnpm 8: {specifier, version} objects), and 9.0 (pnpm 9/10: importers section),
66
+ # including the pnpm 11 multi-document form where an env document precedes the project
67
+ # document.
68
+ module PnpmLockfile
69
+ def self.version(path, package_name)
70
+ doc = project_document(path)
71
+ return nil unless doc
72
+
73
+ entry = dependencies(doc)&.[](package_name)
74
+ case entry
75
+ when Hash then strip_peer_suffix(entry["version"])
76
+ when String then strip_peer_suffix(entry)
77
+ end
78
+ end
79
+
80
+ def self.dependencies(doc)
81
+ return doc.dig("importers", ".", "dependencies") if doc.key?("importers")
82
+
83
+ doc["dependencies"]
84
+ end
85
+
86
+ # YAML.safe_load would return only the FIRST document of a pnpm 11 multi-document
87
+ # lockfile — the env document, which itself carries an importers section (with
88
+ # configDependencies/packageManagerDependencies under ".", never dependencies), so a
89
+ # first-match scan would pick it and resolution would come up empty. pnpm always writes
90
+ # the project document last (writeEnvLockfile prepends the env doc on every write), so
91
+ # scan the stream from the end. permitted_classes: some pnpm versions write unquoted
92
+ # ISO timestamps (a time: map), which Psych types as Date/Time and safe parsing would
93
+ # otherwise reject.
94
+ def self.project_document(path)
95
+ docs = YAML.safe_load_stream(File.read(path), permitted_classes: [Date, Time])
96
+ docs.reverse_each.find { |d| d.is_a?(Hash) && (d.key?("importers") || d.key?("dependencies")) }
97
+ end
98
+
99
+ # lockfileVersion >= 6: "16.6.0(react-dom@19.3.0(react@19.3.0))(react@19.3.0)" -> "16.6.0"
100
+ # lockfileVersion 5.x: "16.6.0_react@19.3.0" -> "16.6.0"
101
+ def self.strip_peer_suffix(version)
102
+ return nil if version.nil?
103
+
104
+ cut = [version.index("("), version.index("_")].compact.min
105
+ cut ? version[0...cut] : version
106
+ end
107
+ end
108
+
109
+ # bun.lock — bun's text lockfile (lockfileVersion 0 in bun 1.1.39, 1 in bun 1.2-1.3,
110
+ # 2 in bun 1.4+; same on-disk shape throughout). The file is JSONC: strict JSON.parse
111
+ # fails on its trailing commas, so comments and trailing commas are stripped first.
112
+ module BunLockfile
113
+ def self.version(path, package_name)
114
+ doc = JSON.parse(jsonc_to_json(File.read(path)))
115
+ resolution = doc.dig("packages", package_name, 0)
116
+ return nil unless resolution.is_a?(String)
117
+
118
+ # "react-on-rails@16.6.0" / "@scope/pkg@1.2.3" -> version after the last "@"
119
+ resolution.rpartition("@").last
120
+ end
121
+
122
+ # One complete JSON string literal (escape-aware), a // line comment, a /* */ block
123
+ # comment, and a trailing comma directly before } or ].
124
+ STRING_LITERAL = /"(?:[^"\\]|\\.)*"/
125
+ LINE_COMMENT = %r{//[^\n]*}
126
+ BLOCK_COMMENT = %r{/\*.*?\*/}m
127
+ TRAILING_COMMA = /,(?=\s*[}\]])/
128
+
129
+ # String-aware JSONC-to-JSON: comments and trailing commas are only stripped OUTSIDE
130
+ # string literals, so values like "x,]" or "https://..." can never be corrupted.
131
+ def self.jsonc_to_json(content)
132
+ scanner = StringScanner.new(content)
133
+ result = +""
134
+ until scanner.eos?
135
+ if (string = scanner.scan(STRING_LITERAL))
136
+ result << string
137
+ elsif scanner.scan(LINE_COMMENT) || scanner.scan(BLOCK_COMMENT) || scanner.scan(TRAILING_COMMA)
138
+ next
139
+ else
140
+ result << scanner.getch
141
+ end
142
+ end
143
+ result
144
+ end
145
+ end
146
+
147
+ # Yarn Berry yarn.lock (real YAML; entry shape is identical across __metadata versions
148
+ # 4-8, i.e. yarn 2 through yarn 4). Mirrors the Yarn classic parser's semantics: the
149
+ # first entry keyed by this package name wins.
150
+ module BerryYarnLockfile
151
+ def self.version(content, package_name)
152
+ # permitted_classes mirrors the pnpm parser: tolerate unquoted timestamp scalars.
153
+ doc = YAML.safe_load(content, permitted_classes: [Date, Time])
154
+ return nil unless doc.is_a?(Hash)
155
+
156
+ # Berry keys registry deps with the npm: protocol ("react-on-rails@npm:^16.1.1") and
157
+ # can merge several selectors into one key. Matching "#{name}@" cannot collide with
158
+ # longer names ("react-on-rails-pro@..." does not start with "react-on-rails@").
159
+ doc.each do |key, entry|
160
+ next if key == "__metadata"
161
+ next unless key.to_s.split(",").any? { |selector| selector.strip.start_with?("#{package_name}@") }
162
+
163
+ return entry["version"]&.to_s if entry.is_a?(Hash)
164
+ end
165
+ nil
166
+ end
167
+ end
168
+ end
169
+ end
170
+ end
@@ -4,6 +4,7 @@ require "json"
4
4
  require_relative "version_syntax_converter"
5
5
  require_relative "shakapacker_config_helpers"
6
6
  require_relative "rsc_rspack_support"
7
+ require_relative "version_checker/lockfile_resolution"
7
8
 
8
9
  module ReactOnRails
9
10
  # Responsible for checking versions of rubygem versus yarn node package
@@ -15,7 +16,10 @@ module ReactOnRails
15
16
  attr_reader :node_package_version
16
17
 
17
18
  # Semver uses - to separate pre-release, but RubyGems use .
18
- VERSION_PARTS_REGEX = /(\d+)\.(\d+)\.(\d+)(?:[-.]([0-9A-Za-z.-]+))?/
19
+ # Possessive quantifiers (++) keep matching linear on adversarial inputs (e.g. a version
20
+ # string of thousands of digits): a digit run never needs to backtrack for the following
21
+ # "." to match, so the semantics are unchanged.
22
+ VERSION_PARTS_REGEX = /(\d++)\.(\d++)\.(\d++)(?:[-.]([0-9A-Za-z.-]++))?/
19
23
  def self.build
20
24
  new(NodePackageVersion.build)
21
25
  end
@@ -205,8 +209,10 @@ module ReactOnRails
205
209
  Detected: #{node_package_version.raw}
206
210
  Gem: #{gem_version}
207
211
 
208
- React on Rails requires exact version matching between the gem and npm package.
209
- Do not use ^, ~, >, <, *, or other semver ranges.
212
+ React on Rails checks the INSTALLED package version against the gem version, so semver
213
+ ranges like ^ or ~ in package.json are fine when a lockfile can resolve them. No lockfile
214
+ could resolve the installed version here, so the package.json version itself must be an
215
+ exact version matching the gem.
210
216
 
211
217
  Fix:
212
218
  Run: #{install_cmd}
@@ -579,24 +585,33 @@ module ReactOnRails
579
585
 
580
586
  # Resolve version from lockfiles if available, otherwise use package.json version
581
587
  def resolve_version(package_json_version, package_name)
582
- # If package.json specifies a local path or URL, don't try to resolve from lockfiles
583
- # Lockfiles may contain placeholder versions like "0.0.0" for local links
584
- return package_json_version if local_path_or_url_version?(package_json_version)
588
+ # If package.json specifies a local path, URL, or workspace link, don't try to resolve
589
+ # from lockfiles. Lockfiles record placeholders for links (e.g. "0.0.0", the Yarn Berry
590
+ # "0.0.0-use.local"), and keeping the raw spec lets the validators apply their
591
+ # local/workspace exemptions. A JSON null dependency value stays nil (validators treat a
592
+ # nil raw as "not installed").
593
+ return package_json_version if package_json_version.nil? ||
594
+ local_path_or_url_version?(package_json_version) ||
595
+ package_json_version.start_with?("workspace:")
596
+
597
+ # Fall back to the package.json version when no lockfile resolves one.
598
+ lockfile_version(package_name) || package_json_version
599
+ end
585
600
 
586
- # Try yarn.lock first
601
+ # Try yarn.lock first, then package-lock.json, then the other package managers'
602
+ # lockfiles (pnpm-lock.yaml, bun.lock), which live in the same directory as package.json.
603
+ def lockfile_version(package_name)
587
604
  if yarn_lock && File.exist?(yarn_lock)
588
- lockfile_version = version_from_yarn_lock(package_name)
589
- return lockfile_version if lockfile_version
605
+ version = version_from_yarn_lock(package_name)
606
+ return version if version
590
607
  end
591
608
 
592
- # Try package-lock.json
593
609
  if package_lock && File.exist?(package_lock)
594
- lockfile_version = version_from_package_lock(package_name)
595
- return lockfile_version if lockfile_version
610
+ version = version_from_package_lock(package_name)
611
+ return version if version
596
612
  end
597
613
 
598
- # Fall back to package.json version
599
- package_json_version
614
+ LockfileResolution.version(File.dirname(package_json.to_s), package_name)
600
615
  end
601
616
 
602
617
  # Check if a version string represents a local path or URL
@@ -616,6 +631,13 @@ module ReactOnRails
616
631
  def version_from_yarn_lock(package_name)
617
632
  return nil unless yarn_lock && File.exist?(yarn_lock)
618
633
 
634
+ # Yarn Berry (yarn 2+) writes the same yarn.lock filename in a YAML format marked by an
635
+ # __metadata section near the top; the format is decided by content, not by the yarn
636
+ # version in use. Sniff only the file head so the classic path below keeps streaming.
637
+ if File.open(yarn_lock) { |f| f.read(1024) }.to_s.include?("__metadata:")
638
+ return LockfileResolution.berry_yarn_version(File.read(yarn_lock), package_name)
639
+ end
640
+
619
641
  in_package_block = false
620
642
  File.foreach(yarn_lock) do |line|
621
643
  # Check if we're starting the block for our package
@@ -627,15 +649,14 @@ module ReactOnRails
627
649
  end
628
650
 
629
651
  # If we're in the package block, look for the version line
630
- if in_package_block
631
- # Version line looks like: version "16.1.1"
632
- if (match = line.match(/^\s+version\s+"([^"]+)"/))
633
- return match[1]
634
- end
635
-
636
- # If we hit a blank line or new package, we've left the block
637
- break if line.strip.empty? || (line[0] != " " && line[0] != "\t")
652
+ next unless in_package_block
653
+ # Version line looks like: version "16.1.1"
654
+ if (match = line.match(/^\s+version\s+"([^"]+)"/))
655
+ return match[1]
638
656
  end
657
+
658
+ # If we hit a blank line or new package, we've left the block
659
+ break if line.strip.empty? || (line[0] != " " && line[0] != "\t")
639
660
  end
640
661
 
641
662
  nil
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "pathname"
4
+ require "shellwords"
4
5
  require "yaml"
5
6
 
6
7
  require_relative "task_helpers"
@@ -42,7 +43,11 @@ namespace :run_rspec do
42
43
  # by appending RBS runtime hook instead of replacing
43
44
  existing_rubyopt = ENV.fetch("RUBYOPT", nil)
44
45
  rubyopt_parts = ["-rrbs/test/setup", existing_rubyopt].compact.reject(&:empty?)
45
- "RBS_TEST_TARGET='ReactOnRails::*' RUBYOPT='#{rubyopt_parts.join(' ')}'"
46
+ # Configuration accepts Rails.root and documented locale directories as Pathname values.
47
+ # Load their standard-library signatures without dropping caller-supplied RBS options.
48
+ rbs_options = "#{ENV.fetch('RBS_TEST_OPT', '-I sig')} -r pathname"
49
+ "RBS_TEST_OPT=#{Shellwords.escape(rbs_options)} " \
50
+ "RBS_TEST_TARGET='ReactOnRails::*' RUBYOPT='#{rubyopt_parts.join(' ')}'"
46
51
  rescue LoadError
47
52
  # RBS not available - silently skip runtime checking
48
53
  # This is expected in environments without the rbs gem