class ReactOnRails::PacksGenerator


‘use client’), and a .server.jsx file can be a React Client Component (if it has ‘use client’).
These are orthogonal. A .client.jsx file can be a React Server Component (if it lacks
Method: client_entrypoint?
- Lacks ‘use client’ → registerServerComponent() → React Server Component
- Has ‘use client’ → ReactOnRails.register() → React Client Component
Controls how a component is registered when RSC support is enabled (Pro feature).
2. RSC CLASSIFICATION (‘use client’ directive)
Methods: common_component_to_path, client_component_to_path, server_component_to_path
exist only in the RSC bundle.
These suffixes only make sense for client components, as server components
- Component.jsx (no suffix) → both bundles
- Component.server.jsx → server bundle (and RSC bundle when RSC enabled; requires a paired .client. file)
- Component.client.jsx → client bundle only
Controls which webpack bundle imports a file. Pre-dates React Server Components.
1. BUNDLE PLACEMENT (.client. / .server. file suffixes)
This class handles two INDEPENDENT classification systems:
rubocop:disable Metrics/ClassLength

def self.instance

def self.instance
  @instance ||= PacksGenerator.new
end

def add_generated_pack_to_server_bundle

def add_generated_pack_to_server_bundle
  return if ReactOnRails.configuration.make_generated_server_bundle_the_entrypoint
  return if ReactOnRails.configuration.server_bundle_js_file.blank?
  relative_path_to_generated_server_bundle = relative_path(server_bundle_entrypoint,
                                                           generated_server_bundle_file_path)
  content = <<~FILE_CONTENT
    // import statement added by react_on_rails:generate_packs rake task
    import "./#{relative_path_to_generated_server_bundle}"
  FILE_CONTENT
  ReactOnRails::Utils.prepend_to_file_if_text_not_present(
    file: server_bundle_entrypoint,
    text_to_prepend: content,
    regex: %r{import ['"]\./#{relative_path_to_generated_server_bundle}['"]}
  )
end

def build_expected_files_set

def build_expected_files_set
  expected_pack_files = Set.new
  common_component_to_path.each_value { |path| expected_pack_files << generated_pack_path(path) }
  client_component_to_path.each_value { |path| expected_pack_files << generated_pack_path(path) }
  # Include store packs in expected files
  store_to_path.each_value { |path| expected_pack_files << generated_store_pack_path(path) }
  if ReactOnRails.configuration.server_bundle_js_file.present?
    expected_server_bundle = generated_server_bundle_file_path
  end
  { pack_files: expected_pack_files, server_bundle: expected_server_bundle }
end

def build_server_pack_content(component_on_server_imports, server_components, client_components,

def build_server_pack_content(component_on_server_imports, server_components, client_components,
                              store_imports: [], store_names: [])
  all_imports = component_on_server_imports + store_imports
  content = <<~FILE_CONTENT
    import ReactOnRails from '#{react_on_rails_npm_package}';
    #{all_imports.join("\n")}\n
  FILE_CONTENT
  if server_components.any?
    content += <<~FILE_CONTENT
      import registerServerComponent from '#{react_on_rails_npm_package}/registerServerComponent/server';
      registerServerComponent({#{server_components.join(",\n")}});\n
    FILE_CONTENT
  end
  content += "ReactOnRails.register({#{client_components.join(",\n")}});" if client_components.any?
  content += "\nReactOnRails.registerStore({#{store_names.join(",\n")}});" if store_names.any?
  content
end

def check_for_component_store_name_conflicts

def check_for_component_store_name_conflicts
  component_names = common_component_to_path.keys + client_component_to_path.keys
  store_names = store_to_path.keys
  conflicts = component_names & store_names
  return if conflicts.empty?
  msg = <<~MSG
    **ERROR** ReactOnRails: The following names are used for both components and stores: #{conflicts.join(', ')}.
    This would cause pack file conflicts in the generated directory.
    Please rename your components or stores to have unique names.
  MSG
  raise ReactOnRails::Error, msg
end

def clean_directory_with_feedback(dir_path, verbose: false)

def clean_directory_with_feedback(dir_path, verbose: false)
  return create_directory_with_feedback(dir_path, verbose: verbose) unless Dir.exist?(dir_path)
  files = Dir.glob("#{dir_path}/**/*").select { |f| File.file?(f) }
  if files.any?
    if verbose
      puts Rainbow("   Deleting #{files.length} files from #{dir_path}:").cyan
      files.each { |file| puts Rainbow("     - #{File.basename(file)}").blue }
    end
    FileUtils.rm_rf(dir_path)
    FileUtils.mkdir_p(dir_path)
    files.length
  else
    puts Rainbow("   Directory #{dir_path} is already empty").cyan if verbose
    FileUtils.rm_rf(dir_path)
    FileUtils.mkdir_p(dir_path)
    0
  end
end

def clean_generated_directories_with_feedback(verbose: false)

def clean_generated_directories_with_feedback(verbose: false)
  directories_to_clean = [
    generated_packs_directory_path,
    generated_server_bundle_directory_path
  ].compact.uniq
  puts Rainbow("🧹 Cleaning generated directories...").yellow if verbose
  total_deleted = directories_to_clean.sum { |dir_path| clean_directory_with_feedback(dir_path, verbose: verbose) }
  return unless verbose
  if total_deleted.positive?
    puts Rainbow("🗑️  Deleted #{total_deleted} generated files total").red
  else
    puts Rainbow("✨ No files to delete, directories are clean").green
  end
end

def clean_non_generated_files_with_feedback(verbose: false)

def clean_non_generated_files_with_feedback(verbose: false)
  directories_to_clean = [generated_packs_directory_path, generated_server_bundle_directory_path].compact.uniq
  expected_files = build_expected_files_set
  puts Rainbow("🧹 Cleaning non-generated files...").yellow if verbose
  total_deleted = directories_to_clean.sum do |dir_path|
    clean_unexpected_files_from_directory(dir_path, expected_files, verbose: verbose)
  end
  display_cleanup_summary(total_deleted, verbose: verbose) if verbose
end

def clean_unexpected_files_from_directory(dir_path, expected_files, verbose: false)

def clean_unexpected_files_from_directory(dir_path, expected_files, verbose: false)
  return 0 unless Dir.exist?(dir_path)
  existing_files = Dir.glob("#{dir_path}/**/*").select { |f| File.file?(f) }
  unexpected_files = find_unexpected_files(existing_files, dir_path, expected_files)
  if unexpected_files.any?
    delete_unexpected_files(unexpected_files, dir_path, verbose: verbose)
    unexpected_files.length
  else
    puts Rainbow("   No unexpected files found in #{dir_path}").cyan if verbose
    0
  end
end

def client_component_to_path

def client_component_to_path
  client_render_components_paths = Dir.glob("#{components_search_path}/*.client.*")
  filtered_client_paths = filter_component_files(client_render_components_paths)
  client_specific_components = component_name_to_path(filtered_client_paths)
  duplicate_components = common_component_to_path.slice(*client_specific_components.keys)
  duplicate_components.each_key { |component| raise_client_component_overrides_common(component) }
  client_specific_components
end

def client_entrypoint?(file_path)

def client_entrypoint?(file_path)
  content = File.read(file_path)
  # has "use client" directive. It can be "use client" or 'use client'
  first_js_statement_in_code(content).match?(/^["']use client["'](?:;|\s|$)/)
end

def common_component_to_path

def common_component_to_path
  common_components_paths = Dir.glob("#{components_search_path}/*").grep_v(CONTAINS_CLIENT_OR_SERVER_REGEX)
  filtered_paths = filter_component_files(common_components_paths)
  component_name_to_path(filtered_paths)
end

def component_name(file_path)

def component_name(file_path)
  basename = File.basename(file_path, File.extname(file_path))
  basename.sub(CONTAINS_CLIENT_OR_SERVER_REGEX, "")
end

def component_name_to_path(paths)

def component_name_to_path(paths)
  paths.to_h { |path| [component_name(path), path] }
end

def components_search_path

def components_search_path
  source_path = ReactOnRails::PackerUtils.packer_source_path
  "#{source_path}/**/#{ReactOnRails.configuration.components_subdirectory}"
end

def create_directory_with_feedback(dir_path, verbose: false)

def create_directory_with_feedback(dir_path, verbose: false)
  puts Rainbow("   Directory #{dir_path} does not exist, creating...").cyan if verbose
  FileUtils.mkdir_p(dir_path)
  0
end

def create_pack(file_path, verbose: false)

def create_pack(file_path, verbose: false)
  output_path = generated_pack_path(file_path)
  content = pack_file_contents(file_path)
  File.write(output_path, content)
  puts(Rainbow("Generated Packs: #{output_path}").yellow) if verbose
end

def create_server_pack(verbose: false)

def create_server_pack(verbose: false)
  File.write(generated_server_bundle_file_path, generated_server_pack_file_content)
  add_generated_pack_to_server_bundle
  puts(Rainbow("Generated Server Bundle: #{generated_server_bundle_file_path}").orange) if verbose
end

def create_store_pack(file_path, verbose: false)

def create_store_pack(file_path, verbose: false)
  output_path = generated_store_pack_path(file_path)
  content = store_pack_file_contents(file_path)
  File.write(output_path, content)
  puts(Rainbow("Generated Store Pack: #{output_path}").yellow) if verbose
end

def delete_unexpected_files(unexpected_files, dir_path, verbose: false)

def delete_unexpected_files(unexpected_files, dir_path, verbose: false)
  if verbose
    puts Rainbow("   Deleting #{unexpected_files.length} unexpected files from #{dir_path}:").cyan
    unexpected_files.each do |file|
      puts Rainbow("     - #{File.basename(file)}").blue
      File.delete(file)
    end
  else
    unexpected_files.each { |file| File.delete(file) }
  end
end

def display_cleanup_summary(total_deleted, verbose: false)

def display_cleanup_summary(total_deleted, verbose: false)
  return unless verbose
  if total_deleted.positive?
    puts Rainbow("🗑️  Deleted #{total_deleted} unexpected files total").red
  else
    puts Rainbow("✨ No unexpected files to delete").green
  end
end

def filter_component_files(paths)

def filter_component_files(paths)
  paths.grep(COMPONENT_EXTENSIONS)
end

def find_unexpected_files(existing_files, dir_path, expected_files)

def find_unexpected_files(existing_files, dir_path, expected_files)
  existing_files.reject do |file|
    if dir_path == generated_server_bundle_directory_path
      file == expected_files[:server_bundle]
    else
      expected_files[:pack_files].include?(file)
    end
  end
end

def first_js_statement_in_code(content) # rubocop:disable Metrics/CyclomaticComplexity,Metrics/PerceivedComplexity

rubocop:disable Metrics/CyclomaticComplexity,Metrics/PerceivedComplexity
def first_js_statement_in_code(content) # rubocop:disable Metrics/CyclomaticComplexity,Metrics/PerceivedComplexity
  return "" if content.nil? || content.empty?
  start_index = 0
  content_length = content.length
  while start_index < content_length
    # Skip whitespace
    start_index += 1 while start_index < content_length && content[start_index].match?(/\s/)
    break if start_index >= content_length
    current_chars = content[start_index, 2]
    case current_chars
    when "//"
      # Single-line comment
      newline_index = content.index("\n", start_index)
      return "" if newline_index.nil?
      start_index = newline_index + 1
    when "/*"
      # Multi-line comment
      comment_end = content.index("*/", start_index)
      return "" if comment_end.nil?
      start_index = comment_end + 2
    else
      # Found actual content
      next_line_index = content.index("\n", start_index)
      return next_line_index ? content[start_index...next_line_index].strip : content[start_index..].strip
    end
  end
  ""
end

def generate_packs(verbose: false)

def generate_packs(verbose: false)
  # Check for name conflicts between components and stores
  check_for_component_store_name_conflicts
  common_component_to_path.each_value { |component_path| create_pack(component_path, verbose: verbose) }
  client_component_to_path.each_value { |component_path| create_pack(component_path, verbose: verbose) }
  # Generate store packs if stores_subdirectory is configured
  store_to_path.each_value { |store_path| create_store_pack(store_path, verbose: verbose) }
  create_server_pack(verbose: verbose) if ReactOnRails.configuration.server_bundle_js_file.present?
  log_rsc_classification_summary if ReactOnRails::Utils.rsc_support_enabled?
end

def generate_packs_if_stale

def generate_packs_if_stale
  return unless ReactOnRails.configuration.auto_load_bundle
  verbose = ENV["REACT_ON_RAILS_VERBOSE"] == "true"
  add_generated_pack_to_server_bundle
  # Clean any non-generated files from directories
  clean_non_generated_files_with_feedback(verbose: verbose)
  are_generated_files_present_and_up_to_date = Dir.exist?(generated_packs_directory_path) &&
                                               File.exist?(generated_server_bundle_file_path) &&
                                               !stale_or_missing_packs?
  if are_generated_files_present_and_up_to_date
    puts Rainbow("✅ Generated packs are up to date, no regeneration needed").green if verbose
    return
  end
  clean_generated_directories_with_feedback(verbose: verbose)
  generate_packs(verbose: verbose)
end

def generated_pack_path(file_path)

def generated_pack_path(file_path)
  "#{generated_packs_directory_path}/#{component_name(file_path)}.js"
end

def generated_packs_directory_path

def generated_packs_directory_path
  source_entry_path = ReactOnRails::PackerUtils.packer_source_entry_path
  "#{source_entry_path}/generated"
end

def generated_server_bundle_directory_path

def generated_server_bundle_directory_path
  return nil if ReactOnRails.configuration.make_generated_server_bundle_the_entrypoint
  source_entrypoint_parent = Pathname(ReactOnRails::PackerUtils.packer_source_entry_path).parent
  "#{source_entrypoint_parent}/generated"
end

def generated_server_bundle_file_path

def generated_server_bundle_file_path
  return server_bundle_entrypoint if ReactOnRails.configuration.make_generated_server_bundle_the_entrypoint
  entrypoint_ext = File.extname(server_bundle_entrypoint)
  generated_interim_server_bundle_path = server_bundle_entrypoint.sub(
    /#{Regexp.escape(entrypoint_ext)}$/, "-generated#{entrypoint_ext}"
  )
  generated_server_bundle_file_name = component_name(generated_interim_server_bundle_path)
  source_entrypoint_parent = Pathname(ReactOnRails::PackerUtils.packer_source_entry_path).parent
  generated_nonentrypoints_path = "#{source_entrypoint_parent}/generated"
  FileUtils.mkdir_p(generated_nonentrypoints_path)
  "#{generated_nonentrypoints_path}/#{generated_server_bundle_file_name}.js"
end

def generated_server_pack_file_content

def generated_server_pack_file_content
  common_components_for_server_bundle = common_component_to_path.delete_if { |k| server_component_to_path.key?(k) }
  component_for_server_registration_to_path = common_components_for_server_bundle.merge(server_component_to_path)
  component_on_server_imports = component_for_server_registration_to_path.map do |name, component_path|
    "import #{name} from '#{relative_path(generated_server_bundle_file_path, component_path)}';"
  end
  load_server_components = ReactOnRails::Utils.rsc_support_enabled?
  server_components = component_for_server_registration_to_path.keys.delete_if do |name|
    next true unless load_server_components
    component_path = component_for_server_registration_to_path[name]
    client_entrypoint?(component_path)
  end
  client_components = component_for_server_registration_to_path.keys - server_components
  # Include stores in server bundle
  stores = store_to_path
  store_imports = stores.map do |name, store_path|
    "import #{name} from '#{relative_path(generated_server_bundle_file_path, store_path)}';"
  end
  store_names = stores.keys
  build_server_pack_content(component_on_server_imports, server_components, client_components,
                            store_imports: store_imports, store_names: store_names)
end

def generated_store_pack_path(file_path)

def generated_store_pack_path(file_path)
  "#{generated_packs_directory_path}/#{store_name(file_path)}.js"
end

def log_rsc_classification_summary

def log_rsc_classification_summary
  all_components = common_component_to_path.merge(client_component_to_path)
  server = []
  client = []
  all_components.each do |name, path|
    if client_entrypoint?(path)
      client << name
    else
      server << name
    end
  end
  return if server.empty? && client.empty?
  summary = +"[react_on_rails] RSC component classification:\n"
  summary << "  Server components (no 'use client'): #{server.any? ? server.join(', ') : '(none)'}\n"
  summary << "  Client components ('use client' found): #{client.any? ? client.join(', ') : '(none)'}"
  puts Rainbow(summary).cyan
end

def pack_file_contents(file_path)

def pack_file_contents(file_path)
  registered_component_name = component_name(file_path)
  load_server_components = ReactOnRails::Utils.rsc_support_enabled?
  if load_server_components && !client_entrypoint?(file_path)
    warn_if_likely_client_component(file_path, registered_component_name)
    return <<~FILE_CONTENT.strip
      import registerServerComponent from '#{react_on_rails_npm_package}/registerServerComponent/client';
      registerServerComponent("#{registered_component_name}");
    FILE_CONTENT
  end
  relative_component_path = relative_component_path_from_generated_pack(file_path)
  <<~FILE_CONTENT.strip
    import ReactOnRails from '#{react_on_rails_npm_package}/client';
    import #{registered_component_name} from '#{relative_component_path}';
    ReactOnRails.register({#{registered_component_name}});
  FILE_CONTENT
end

def raise_client_component_overrides_common(component_name)

def raise_client_component_overrides_common(component_name)
  msg = <<~MSG
    **ERROR** ReactOnRails: client specific definition for Component '#{component_name}' overrides the \
    common definition. Please delete the common definition and have separate server and client files. For more \
    information, please see https://reactonrails.com/docs/core-concepts/auto-bundling/
  MSG
  raise ReactOnRails::Error, msg
end

def raise_duplicate_store_name(name, existing_path, new_path)

def raise_duplicate_store_name(name, existing_path, new_path)
  msg = <<~MSG
    **ERROR** ReactOnRails: Multiple store files resolve to the same name '#{name}':
      - #{existing_path}
      - #{new_path}
    Rename one of the store files to have a unique base name.
  MSG
  raise ReactOnRails::Error, msg
end

def raise_missing_client_component(component_name)

def raise_missing_client_component(component_name)
  msg = <<~MSG
    **ERROR** ReactOnRails: Component '#{component_name}' is missing a client specific file. For more \
    information, please see https://reactonrails.com/docs/core-concepts/auto-bundling/
  MSG
  raise ReactOnRails::Error, msg
end

def raise_server_component_overrides_common(component_name)

def raise_server_component_overrides_common(component_name)
  msg = <<~MSG
    **ERROR** ReactOnRails: server specific definition for Component '#{component_name}' overrides the \
    common definition. Please delete the common definition and have separate server and client files. For more \
    information, please see https://reactonrails.com/docs/core-concepts/auto-bundling/
  MSG
  raise ReactOnRails::Error, msg
end

def react_on_rails_npm_package

def react_on_rails_npm_package
  return "react-on-rails-pro" if ReactOnRails::Utils.react_on_rails_pro?
  "react-on-rails"
end

def relative_component_path_from_generated_pack(ror_component_path)

def relative_component_path_from_generated_pack(ror_component_path)
  component_file_pathname = Pathname.new(ror_component_path)
  component_generated_pack_path = generated_pack_path(ror_component_path)
  generated_pack_pathname = Pathname.new(component_generated_pack_path)
  relative_path(generated_pack_pathname, component_file_pathname)
end

def relative_path(from, to)

def relative_path(from, to)
  from_dir = Pathname.new(from).dirname
  to_path = Pathname.new(to)
  to_path.relative_path_from(from_dir)
end

def relative_store_path_from_generated_pack(store_path)

def relative_store_path_from_generated_pack(store_path)
  store_file_pathname = Pathname.new(store_path)
  store_generated_pack_path = generated_store_pack_path(store_path)
  generated_pack_pathname = Pathname.new(store_generated_pack_path)
  relative_path(generated_pack_pathname, store_file_pathname)
end

def server_bundle_entrypoint

def server_bundle_entrypoint
  Rails.root.join(ReactOnRails::PackerUtils.packer_source_entry_path,
                  ReactOnRails.configuration.server_bundle_js_file)
end

def server_component_to_path

def server_component_to_path
  server_render_components_paths = Dir.glob("#{components_search_path}/*.server.*")
  filtered_server_paths = filter_component_files(server_render_components_paths)
  server_specific_components = component_name_to_path(filtered_server_paths)
  duplicate_components = common_component_to_path.slice(*server_specific_components.keys)
  duplicate_components.each_key { |component| raise_server_component_overrides_common(component) }
  server_specific_components.each_key do |k|
    raise_missing_client_component(k) unless client_component_to_path.key?(k)
  end
  server_specific_components
end

def stale_or_missing_packs?

def stale_or_missing_packs?
  component_files = common_component_to_path.values + client_component_to_path.values
  store_files = store_to_path.values
  all_source_files = component_files + store_files
  return false if all_source_files.empty?
  most_recent_mtime = Utils.find_most_recent_mtime(all_source_files).to_i
  # Check component packs
  component_files.each do |file|
    path = generated_pack_path(file)
    return true if !File.exist?(path) || File.mtime(path).to_i < most_recent_mtime
  end
  # Check store packs
  store_files.each do |file|
    path = generated_store_pack_path(file)
    return true if !File.exist?(path) || File.mtime(path).to_i < most_recent_mtime
  end
  false
end

def store_name(file_path)

def store_name(file_path)
  basename = File.basename(file_path, File.extname(file_path))
  basename.sub(CONTAINS_CLIENT_OR_SERVER_REGEX, "")
end

def store_name_to_path(paths)

def store_name_to_path(paths)
  result = {}
  paths.each do |path|
    name = store_name(path)
    raise_duplicate_store_name(name, result[name], path) if result.key?(name)
    result[name] = path
  end
  result
end

def store_pack_file_contents(file_path)

def store_pack_file_contents(file_path)
  registered_store_name = store_name(file_path)
  relative_store_path = relative_store_path_from_generated_pack(file_path)
  <<~FILE_CONTENT.strip
    import ReactOnRails from '#{react_on_rails_npm_package}/client';
    import #{registered_store_name} from '#{relative_store_path}';
    ReactOnRails.registerStore({#{registered_store_name}});
  FILE_CONTENT
end

def store_to_path

def store_to_path
  return {} unless stores_search_path
  store_paths = Dir.glob("#{stores_search_path}/*")
  filtered_paths = filter_component_files(store_paths)
  store_name_to_path(filtered_paths)
end

def stores_search_path

def stores_search_path
  return nil unless ReactOnRails.configuration.stores_subdirectory.present?
  source_path = ReactOnRails::PackerUtils.packer_source_path
  "#{source_path}/**/#{ReactOnRails.configuration.stores_subdirectory}"
end

def warn_if_likely_client_component(file_path, component)

def warn_if_likely_client_component(file_path, component)
  content = File.read(file_path)
  matches = content.scan(CLIENT_API_PATTERN).flatten.compact.reject(&:empty?).uniq
  return if matches.empty?
  puts Rainbow(
    "[react_on_rails] WARNING: '#{component}' (#{file_path}) appears to use client-side APIs " \
    "(#{matches.first(3).join(', ')}#{matches.length > 3 ? ', ...' : ''}) " \
    "but is missing the 'use client' directive. It will be registered as a server component.\n" \
    "If this is a client component, add '\"use client\";' as the first line of the file."
  ).yellow
end