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



115
116
117
118
119
120
121
122
123
124
125
126
127
# File 'lib/react_on_rails/utils.rb', line 115

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



500
501
502
503
504
505
506
507
508
# File 'lib/react_on_rails/utils.rb', line 500

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)



332
333
334
335
# File 'lib/react_on_rails/utils.rb', line 332

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



305
306
307
308
309
310
# File 'lib/react_on_rails/utils.rb', line 305

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)


285
286
287
# File 'lib/react_on_rails/utils.rb', line 285

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

.gem_available?(name) ⇒ Boolean

Returns:

  • (Boolean)


239
240
241
242
243
244
245
246
247
248
249
# File 'lib/react_on_rails/utils.rb', line 239

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



235
236
237
# File 'lib/react_on_rails/utils.rb', line 235

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
# 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

    puts wrap_message(msg)
    puts ""
    puts default_troubleshooting_section

    # 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



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

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”)



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

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”)



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

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



217
218
219
# File 'lib/react_on_rails/utils.rb', line 217

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



312
313
314
315
316
317
318
319
320
321
322
323
324
325
# File 'lib/react_on_rails/utils.rb', line 312

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



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

def self.public_bundles_full_path
  ReactOnRails::PackerUtils.packer_public_output_path
end

.rails_version_less_than(version) ⇒ Object



201
202
203
204
205
206
207
208
209
# File 'lib/react_on_rails/utils.rb', line 201

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



256
257
258
259
260
261
262
263
264
# File 'lib/react_on_rails/utils.rb', line 256

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



267
268
269
270
271
272
273
274
275
# File 'lib/react_on_rails/utils.rb', line 267

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)


279
280
281
282
283
# File 'lib/react_on_rails/utils.rb', line 279

def self.rsc_support_enabled?
  return false unless react_on_rails_pro?

  ReactOnRailsPro::Utils.rsc_support_enabled?
end

.running_on_windows?Boolean

Returns:

  • (Boolean)


197
198
199
# File 'lib/react_on_rails/utils.rb', line 197

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

.server_bundle_js_file_pathObject



190
191
192
193
194
195
# File 'lib/react_on_rails/utils.rb', line 190

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)


111
112
113
# File 'lib/react_on_rails/utils.rb', line 111

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



289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
# File 'lib/react_on_rails/utils.rb', line 289

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



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

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)


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

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