Module: ReactOnRails::Utils

Defined in:
lib/react_on_rails/utils.rb

Defined Under Namespace

Modules: Required

Constant Summary collapse

TRUNCATION_FILLER =
"\n... TRUNCATED #{
  Rainbow('To see the full output, set FULL_TEXT_ERRORS=true.').red
} ...\n".freeze

Class Method Summary collapse

Class Method Details

.bundle_js_file_path(bundle_name) ⇒ Object



118
119
120
121
122
123
124
125
126
127
128
129
130
# File 'lib/react_on_rails/utils.rb', line 118

def self.bundle_js_file_path(bundle_name)
  # Priority order depends on bundle type:
  # SERVER BUNDLES (normal case): Try private non-public locations first, then manifest, then legacy
  # CLIENT BUNDLES (normal case): Try manifest first, then fallback locations
  if bundle_name == "manifest.json"
    # Default to the non-hashed name in the specified output directory, which, for legacy
    # React on Rails, this is the output directory picked up by the asset pipeline.
    # For Shakapacker, this is the public output path defined in the (shaka/web)packer.yml file.
    File.join(public_bundles_full_path, bundle_name)
  else
    bundle_js_file_path_with_packer(bundle_name)
  end
end

.default_troubleshooting_sectionObject

rubocop:enable Metrics/CyclomaticComplexity



503
504
505
506
507
508
509
510
511
# File 'lib/react_on_rails/utils.rb', line 503

def self.default_troubleshooting_section
  <<~DEFAULT
    📞 Get Help & Support:
       • 🚀 Professional Support: react_on_rails@shakacode.com (fastest resolution)
       • 💬 React + Rails Slack: https://invite.reactrails.com
       • 🆓 GitHub Issues: https://github.com/shakacode/react_on_rails/issues
       • 📖 Discussions: https://github.com/shakacode/react_on_rails/discussions
  DEFAULT
end

.detect_package_managerSymbol

Detects which package manager is being used. First checks the packageManager field in package.json (Node.js Corepack standard), then falls back to checking for lock files.

Returns:

  • (Symbol)

    The package manager symbol (:npm, :yarn, :pnpm, :bun)



335
336
337
338
# File 'lib/react_on_rails/utils.rb', line 335

def self.detect_package_manager
  manager = detect_package_manager_from_package_json || detect_package_manager_from_lock_files
  manager || :yarn # Default to yarn if no detection succeeds
end

.find_most_recent_mtime(files) ⇒ Object



308
309
310
311
312
313
# File 'lib/react_on_rails/utils.rb', line 308

def self.find_most_recent_mtime(files)
  files.reduce(1.year.ago) do |newest_time, file|
    mt = File.mtime(file)
    [mt, newest_time].max
  end
end

.full_text_errors_enabled?Boolean

Returns:

  • (Boolean)


288
289
290
# File 'lib/react_on_rails/utils.rb', line 288

def self.full_text_errors_enabled?
  ENV["FULL_TEXT_ERRORS"] == "true"
end

.gem_available?(name) ⇒ Boolean

Returns:

  • (Boolean)


242
243
244
245
246
247
248
249
250
251
252
# File 'lib/react_on_rails/utils.rb', line 242

def self.gem_available?(name)
  Gem.loaded_specs[name].present?
rescue Gem::LoadError
  false
rescue StandardError
  begin
    Gem.available?(name).present?
  rescue NoMethodError
    false
  end
end

.generated_assets_full_pathObject

DEPRECATED: Use public_bundles_full_path for clarity about public vs private bundle paths



238
239
240
# File 'lib/react_on_rails/utils.rb', line 238

def self.generated_assets_full_path
  public_bundles_full_path
end

.immediate_hydration_pro_license_warning(name, type = "Component") ⇒ Object



17
18
19
20
21
# File 'lib/react_on_rails/utils.rb', line 17

def self.immediate_hydration_pro_license_warning(name, type = "Component")
  "[REACT ON RAILS] Warning: immediate_hydration: true requires a React on Rails Pro license.\n" \
    "#{type} '#{name}' will fall back to standard hydration behavior.\n" \
    "Visit https://www.shakacode.com/react-on-rails-pro/ for licensing information."
end

.invoke_and_exit_if_failed(cmd, failure_message) ⇒ Object

Invokes command, exiting with a detailed message if there’s a failure.



89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
# File 'lib/react_on_rails/utils.rb', line 89

def self.invoke_and_exit_if_failed(cmd, failure_message)
  stdout, stderr, status = Open3.capture3(cmd)
  unless status.success?
    stdout_msg = stdout.present? ? "\nstdout:\n#{stdout.strip}\n" : ""
    stderr_msg = stderr.present? ? "\nstderr:\n#{stderr.strip}\n" : ""
    msg = <<~MSG
      React on Rails FATAL ERROR!
      #{failure_message}
      cmd: #{cmd}
      exitstatus: #{status.exitstatus}#{stdout_msg}#{stderr_msg}
    MSG

    # Use warn to ensure output is visible in CI logs (goes to stderr)
    # and flush immediately before calling exit!
    warn wrap_message(msg)
    warn ""
    warn default_troubleshooting_section
    $stderr.flush

    # Rspec catches exit without! in the exit callbacks
    exit!(1)
  end
  [stdout, stderr, status]
end

.normalize_immediate_hydration(value, name, type = "Component") ⇒ Boolean

Normalizes the immediate_hydration option value, enforcing Pro license requirements. Returns the normalized boolean value for immediate_hydration.

Logic:

  • Validates that value is true, false, or nil

  • If value is explicitly true (boolean) and no Pro license: warn and return false

  • If value is nil: return true for Pro users, false for non-Pro users

  • Otherwise: return the value as-is (allows explicit false to work)

Parameters:

  • value (Boolean, nil)

    The immediate_hydration option value

  • name (String)

    The name of the component/store (for warning messages)

  • type (String) (defaults to: "Component")

    The type (“Component” or “Store”) for warning messages

Returns:

  • (Boolean)

    The normalized immediate_hydration value

Raises:

  • (ArgumentError)

    If value is not a boolean or nil



37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
# File 'lib/react_on_rails/utils.rb', line 37

def self.normalize_immediate_hydration(value, name, type = "Component")
  # Type validation: only accept boolean or nil
  unless [true, false, nil].include?(value)
    raise ArgumentError,
          "[REACT ON RAILS] immediate_hydration must be true, false, or nil. Got: #{value.inspect} (#{value.class})"
  end

  # Strict equality check: only trigger warning for explicit boolean true
  if value == true && !react_on_rails_pro?
    Rails.logger.warn immediate_hydration_pro_license_warning(name, type)
    return false
  end

  # If nil, default based on Pro license status
  return react_on_rails_pro? if value.nil?

  # Return explicit value (including false)
  value
end

.normalize_to_relative_path(path) ⇒ String?

Converts an absolute path (String or Pathname) to a path relative to Rails.root. If the path is already relative or doesn’t contain Rails.root, returns it as-is.

This method is used to normalize paths from Shakapacker’s privateOutputPath (which is absolute) to relative paths suitable for React on Rails configuration.

Note: Absolute paths that don’t start with Rails.root are intentionally passed through unchanged. While there’s no known use case for server bundles outside Rails.root, this behavior preserves the original path for debugging and error messages.

rubocop:disable Metrics/CyclomaticComplexity

Examples:

Converting absolute paths within Rails.root

# Assuming Rails.root is "/app"
normalize_to_relative_path("/app/ssr-generated") # => "ssr-generated"
normalize_to_relative_path("/app/foo/bar")       # => "foo/bar"

Already relative paths pass through

normalize_to_relative_path("ssr-generated")      # => "ssr-generated"
normalize_to_relative_path("./ssr-generated")    # => "./ssr-generated"

Absolute paths outside Rails.root (edge case)

normalize_to_relative_path("/other/path/bundles") # => "/other/path/bundles"

Parameters:

  • path (String, Pathname)

    The path to normalize

Returns:

  • (String, nil)

    The relative path as a string, or nil if path is nil



474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
# File 'lib/react_on_rails/utils.rb', line 474

def self.normalize_to_relative_path(path)
  return nil if path.nil?

  path_str = path.to_s
  rails_root_str = Rails.root.to_s.chomp("/")

  # Treat as "inside Rails.root" only for exact match or a subdirectory
  inside_rails_root = rails_root_str.present? &&
                      (path_str == rails_root_str || path_str.start_with?("#{rails_root_str}/"))

  # If path is within Rails.root, remove that prefix
  if inside_rails_root
    # Remove Rails.root and any leading slash
    path_str.sub(%r{^#{Regexp.escape(rails_root_str)}/?}, "")
  else
    # Path is already relative or outside Rails.root
    # Warn if it's an absolute path outside Rails.root (edge case)
    if path_str.start_with?("/") && !inside_rails_root
      Rails.logger&.warn(
        "ReactOnRails: Detected absolute path outside Rails.root: '#{path_str}'. " \
        "Server bundles are typically stored within Rails.root. " \
        "Verify this is intentional."
      )
    end
    path_str
  end
end

.object_to_boolean(value) ⇒ Object



80
81
82
# File 'lib/react_on_rails/utils.rb', line 80

def self.object_to_boolean(value)
  [true, "true", "yes", 1, "1", "t"].include?(value.instance_of?(String) ? value.downcase : value)
end

.package_manager_install_exact_command(package_name, version) ⇒ String

Returns the appropriate install command for the detected package manager. Generates the correct command with exact version syntax.

Parameters:

  • package_name (String)

    The name of the package to install

  • version (String)

    The exact version to install

Returns:

  • (String)

    The command to run (e.g., “yarn add react-on-rails@16.0.0 –exact”)



407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
# File 'lib/react_on_rails/utils.rb', line 407

def self.package_manager_install_exact_command(package_name, version)
  validate_package_command_inputs!(package_name, version)

  manager = detect_package_manager
  # Escape shell arguments to prevent command injection
  safe_package = Shellwords.escape("#{package_name}@#{version}")

  case manager
  when :pnpm
    "pnpm add #{safe_package} --save-exact"
  when :bun
    "bun add #{safe_package} --exact"
  when :npm
    "npm install #{safe_package} --save-exact"
  else # :yarn or unknown, default to yarn
    "yarn add #{safe_package} --exact"
  end
end

.package_manager_remove_command(package_name) ⇒ String

Returns the appropriate remove command for the detected package manager.

Parameters:

  • package_name (String)

    The name of the package to remove

Returns:

  • (String)

    The command to run (e.g., “yarn remove react-on-rails”)



430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
# File 'lib/react_on_rails/utils.rb', line 430

def self.package_manager_remove_command(package_name)
  validate_package_name!(package_name)

  manager = detect_package_manager
  # Escape shell arguments to prevent command injection
  safe_package = Shellwords.escape(package_name)

  case manager
  when :pnpm
    "pnpm remove #{safe_package}"
  when :bun
    "bun remove #{safe_package}"
  when :npm
    "npm uninstall #{safe_package}"
  else # :yarn or unknown, default to yarn
    "yarn remove #{safe_package}"
  end
end

.prepend_cd_node_modules_directory(cmd) ⇒ Object



220
221
222
# File 'lib/react_on_rails/utils.rb', line 220

def self.prepend_cd_node_modules_directory(cmd)
  "cd \"#{ReactOnRails.configuration.node_modules_location}\" && #{cmd}"
end

.prepend_to_file_if_text_not_present(file:, text_to_prepend:, regex:) ⇒ Object



315
316
317
318
319
320
321
322
323
324
325
326
327
328
# File 'lib/react_on_rails/utils.rb', line 315

def self.prepend_to_file_if_text_not_present(file:, text_to_prepend:, regex:)
  if File.exist?(file)
    file_content = File.read(file)

    return if file_content.match(regex)

    content_with_prepended_text = text_to_prepend + file_content
    File.write(file, content_with_prepended_text, mode: "w")
  else
    File.write(file, text_to_prepend, mode: "w+")
  end

  puts "Prepended\n#{text_to_prepend}to #{file}."
end

.public_bundles_full_pathObject



233
234
235
# File 'lib/react_on_rails/utils.rb', line 233

def self.public_bundles_full_path
  ReactOnRails::PackerUtils.packer_public_output_path
end

.rails_version_less_than(version) ⇒ Object



204
205
206
207
208
209
210
211
212
# File 'lib/react_on_rails/utils.rb', line 204

def self.rails_version_less_than(version)
  @rails_version_less_than ||= {}

  return @rails_version_less_than[version] if @rails_version_less_than.key?(version)

  @rails_version_less_than[version] = begin
    Gem::Version.new(Rails.version) < Gem::Version.new(version)
  end
end

.react_on_rails_pro?Boolean

Checks if React on Rails Pro is installed and licensed. This method validates the license and will raise an exception if invalid.

Returns:

  • (Boolean)

    true if Pro is available with valid license

Raises:

  • (ReactOnRailsPro::Error)

    if license is invalid



259
260
261
262
263
264
265
266
267
# File 'lib/react_on_rails/utils.rb', line 259

def self.react_on_rails_pro?
  return @react_on_rails_pro if defined?(@react_on_rails_pro)

  @react_on_rails_pro = begin
    return false unless gem_available?("react_on_rails_pro")

    ReactOnRailsPro::Utils.validated_license_data!.present?
  end
end

.react_on_rails_pro_versionObject

Return an empty string if React on Rails Pro is not installed



270
271
272
273
274
275
276
277
278
# File 'lib/react_on_rails/utils.rb', line 270

def self.react_on_rails_pro_version
  return @react_on_rails_pro_version if defined?(@react_on_rails_pro_version)

  @react_on_rails_pro_version = if react_on_rails_pro?
                                  Gem.loaded_specs["react_on_rails_pro"].version.to_s
                                else
                                  ""
                                end
end

.rsc_support_enabled?Boolean

RSC support detection has been moved to React on Rails Pro See react_on_rails_pro/lib/react_on_rails_pro/utils.rb

Returns:

  • (Boolean)


282
283
284
285
286
# File 'lib/react_on_rails/utils.rb', line 282

def self.rsc_support_enabled?
  return false unless react_on_rails_pro?

  ReactOnRailsPro::Utils.rsc_support_enabled?
end

.running_on_windows?Boolean

Returns:

  • (Boolean)


200
201
202
# File 'lib/react_on_rails/utils.rb', line 200

def self.running_on_windows?
  (/cygwin|mswin|mingw|bccwin|wince|emx/ =~ RUBY_PLATFORM) != nil
end

.server_bundle_js_file_pathObject



193
194
195
196
197
198
# File 'lib/react_on_rails/utils.rb', line 193

def self.server_bundle_js_file_path
  return @server_bundle_path if @server_bundle_path && !Rails.env.development?

  bundle_name = ReactOnRails.configuration.server_bundle_js_file
  @server_bundle_path = bundle_js_file_path(bundle_name)
end

.server_bundle_path_is_http?Boolean

Returns:

  • (Boolean)


114
115
116
# File 'lib/react_on_rails/utils.rb', line 114

def self.server_bundle_path_is_http?
  server_bundle_js_file_path =~ %r{https?://}
end

.server_rendering_is_enabled?Boolean

Returns:

  • (Boolean)


84
85
86
# File 'lib/react_on_rails/utils.rb', line 84

def self.server_rendering_is_enabled?
  ReactOnRails.configuration.server_bundle_js_file.present?
end

.smart_trim(str, max_length = 1000) ⇒ Object



292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
# File 'lib/react_on_rails/utils.rb', line 292

def self.smart_trim(str, max_length = 1000)
  # From https://stackoverflow.com/a/831583/1009332
  str = str.to_s
  return str if full_text_errors_enabled?
  return str unless str.present? && max_length >= 1
  return str if str.length <= max_length

  return str[0, 1] + TRUNCATION_FILLER if max_length == 1

  midpoint = (str.length / 2.0).ceil
  to_remove = str.length - max_length
  lstrip = (to_remove / 2.0).ceil
  rstrip = to_remove - lstrip
  str[0..(midpoint - lstrip - 1)] + TRUNCATION_FILLER + str[(midpoint + rstrip)..]
end

.source_pathObject



224
225
226
# File 'lib/react_on_rails/utils.rb', line 224

def self.source_path
  ReactOnRails::PackerUtils.packer_source_path
end

.truthy_presence(obj) ⇒ Object



59
60
61
62
63
64
65
# File 'lib/react_on_rails/utils.rb', line 59

def self.truthy_presence(obj)
  if obj.nil? || obj == false
    nil
  else
    obj
  end
end

.using_packer_source_path_is_not_defined_and_custom_node_modules?Boolean

Returns:

  • (Boolean)


228
229
230
231
# File 'lib/react_on_rails/utils.rb', line 228

def self.using_packer_source_path_is_not_defined_and_custom_node_modules?
  !ReactOnRails::PackerUtils.packer_source_path_explicit? &&
    ReactOnRails.configuration.node_modules_location.present?
end

.wrap_message(msg, color = :red) ⇒ Object

Wraps message and makes it colored. Pass in the msg and color as a symbol.



69
70
71
72
73
74
75
76
77
78
# File 'lib/react_on_rails/utils.rb', line 69

def self.wrap_message(msg, color = :red)
  wrapper_line = ("=" * 80).to_s
  fenced_msg = <<~MSG
    #{wrapper_line}
    #{msg.strip}
    #{wrapper_line}
  MSG

  Rainbow(fenced_msg).color(color)
end