diff --git a/CHANGELOG.md b/CHANGELOG.md index 024cc2df58..abf582b004 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,12 +26,15 @@ Changes since the last non-beta release. #### Improved - **Automatic Precompile Hook Coordination in bin/dev**: The `bin/dev` command now automatically runs Shakapacker's `precompile_hook` once before starting development processes and sets `SHAKAPACKER_SKIP_PRECOMPILE_HOOK=true` to prevent duplicate execution in spawned webpack processes. + - Eliminates the need for manual coordination, sleep hacks, and duplicate task calls in Procfile.dev - Users can configure expensive build tasks (like locale generation or ReScript compilation) once in `config/shakapacker.yml` and `bin/dev` handles coordination automatically - Includes warning for Shakapacker versions below 9.4.0 (the `SHAKAPACKER_SKIP_PRECOMPILE_HOOK` environment variable is only supported in 9.4.0+) - The `SHAKAPACKER_SKIP_PRECOMPILE_HOOK` environment variable is set for all spawned processes, making it available for custom scripts that need to detect when `bin/dev` is managing the precompile hook - Addresses [2091](https://github.com/shakacode/react_on_rails/issues/2091) by [justin808](https://github.com/justin808) +- **Idempotent Locale Generation**: The `react_on_rails:locale` rake task is now idempotent, automatically skipping generation when locale files are already up-to-date. This makes it safe to call multiple times (e.g., in Shakapacker's `precompile_hook`) without duplicate work. Added `force=true` option to override timestamp checking. [PR 2090](https://github.com/shakacode/react_on_rails/pull/2090) by [justin808](https://github.com/justin808). + ### [v16.2.0.beta.12] - 2025-11-20 #### Added diff --git a/docs/building-features/i18n.md b/docs/building-features/i18n.md index fdfd9da30d..99d2be861e 100644 --- a/docs/building-features/i18n.md +++ b/docs/building-features/i18n.md @@ -21,15 +21,26 @@ You can use [Rails internationalization (i18n)](https://guides.rubyonrails.org/i 3. The locale files must be generated before `yarn build` using `rake react_on_rails:locale`. + The locale generation task is idempotent - it will skip generation if files are already up-to-date. This makes it safe to call multiple times without duplicate work: + + ```bash + bundle exec rake react_on_rails:locale + # Subsequent calls will skip if already up-to-date + ``` + + To force regeneration: + + ```bash + bundle exec rake react_on_rails:locale force=true + ``` + **Recommended: Use Shakapacker's precompile_hook with bin/dev** (React on Rails 16.2+, Shakapacker 9.3+) - The locale generation task is idempotent and can be safely called multiple times. Configure it in Shakapacker's `precompile_hook` and `bin/dev` will handle coordination automatically: + Configure the idempotent task in `config/shakapacker.yml` to run automatically before webpack: ```yaml # config/shakapacker.yml default: &default - # Run locale generation before webpack compilation - # Safe to run multiple times - will skip if already built precompile_hook: 'bundle exec rake react_on_rails:locale' ``` diff --git a/lib/react_on_rails/locales/base.rb b/lib/react_on_rails/locales/base.rb index 335c33f153..41de597199 100644 --- a/lib/react_on_rails/locales/base.rb +++ b/lib/react_on_rails/locales/base.rb @@ -4,7 +4,7 @@ module ReactOnRails module Locales - def self.compile + def self.compile(force: false) config = ReactOnRails.configuration check_config_directory_exists( directory: config.i18n_dir, key_name: "config.i18n_dir", @@ -15,9 +15,9 @@ def self.compile remove_if: "not using this i18n with React on Rails, or if you want to use all translation files" ) if config.i18n_output_format&.downcase == "js" - ReactOnRails::Locales::ToJs.new + ReactOnRails::Locales::ToJs.new(force: force) else - ReactOnRails::Locales::ToJson.new + ReactOnRails::Locales::ToJson.new(force: force) end end @@ -36,12 +36,23 @@ def self.check_config_directory_exists(directory:, key_name:, remove_if:) private_class_method :check_config_directory_exists class Base - def initialize + def initialize(force: false) return if i18n_dir.nil? - return unless obsolete? + + if locale_files.empty? + puts "Warning: No locale files found in #{i18n_yml_dir || 'Rails i18n load path'}" + return + end + + if !force && !obsolete? + puts "Locale files are up to date, skipping generation. " \ + "Use 'rake react_on_rails:locale force=true' to force regeneration." + return + end @translations, @defaults = generate_translations convert + puts "Generated locale files in #{i18n_dir}" end private @@ -49,6 +60,7 @@ def initialize def file_format; end def obsolete? + return true if exist_files.length != files.length # Some files missing return true if exist_files.empty? files_are_outdated diff --git a/lib/tasks/locale.rake b/lib/tasks/locale.rake index a04d17cc6f..7502fa8f54 100644 --- a/lib/tasks/locale.rake +++ b/lib/tasks/locale.rake @@ -9,8 +9,13 @@ namespace :react_on_rails do Generate i18n javascript files This task generates javascript locale files: `translations.js` & `default.js` and places them in the "ReactOnRails.configuration.i18n_dir". + + Options: + force=true - Force regeneration even if files are up to date + Example: rake react_on_rails:locale force=true DESC task locale: :environment do - ReactOnRails::Locales.compile + force = %w[true 1 yes].include?(ENV["force"]&.downcase) + ReactOnRails::Locales.compile(force: force) end end diff --git a/sig/react_on_rails/locales.rbs b/sig/react_on_rails/locales.rbs new file mode 100644 index 0000000000..f5e13a56c7 --- /dev/null +++ b/sig/react_on_rails/locales.rbs @@ -0,0 +1,46 @@ +module ReactOnRails + module Locales + def self.compile: (?force: bool) -> (ToJs | ToJson) + def self.check_config_directory_exists: (directory: String?, key_name: String, remove_if: String) -> void + + class Base + def initialize: (?force: bool) -> void + + private + + def file_format: () -> String? + def obsolete?: () -> bool + def exist_files: () -> Array[String] + def files_are_outdated: () -> bool + def file_names: () -> Array[String] + def files: () -> Array[String] + def file: (String name) -> String + def locale_files: () -> Array[String] + def i18n_dir: () -> String? + def i18n_yml_dir: () -> String? + def default_locale: () -> String + def convert: () -> void + def generate_file: (String template, String path) -> void + def generate_translations: () -> [String, String] + def format: (untyped input) -> Symbol + def flatten_defaults: (Hash[untyped, untyped] val) -> Hash[Symbol, Hash[Symbol, untyped]] + def flatten: (Hash[untyped, untyped] translations) -> Hash[Symbol, untyped] + def template_translations: () -> String + def template_default: () -> String + end + + class ToJs < Base + private + + def file_format: () -> String + end + + class ToJson < Base + private + + def file_format: () -> String + def template_translations: () -> String + def template_default: () -> String + end + end +end diff --git a/spec/react_on_rails/locales_spec.rb b/spec/react_on_rails/locales_spec.rb index 3fc76489fd..947df064f7 100644 --- a/spec/react_on_rails/locales_spec.rb +++ b/spec/react_on_rails/locales_spec.rb @@ -36,6 +36,22 @@ module ReactOnRails described_class.compile end + + it "passes force parameter to ToJson" do + ReactOnRails.configuration.i18n_output_format = nil + + expect(ReactOnRails::Locales::ToJson).to receive(:new).with(force: true) + + described_class.compile(force: true) + end + + it "passes force parameter to ToJs" do + ReactOnRails.configuration.i18n_output_format = "js" + + expect(ReactOnRails::Locales::ToJs).to receive(:new).with(force: true) + + described_class.compile(force: true) + end end end end diff --git a/spec/react_on_rails/locales_to_js_spec.rb b/spec/react_on_rails/locales_to_js_spec.rb index 991a6760cf..d6455d7dc9 100644 --- a/spec/react_on_rails/locales_to_js_spec.rb +++ b/spec/react_on_rails/locales_to_js_spec.rb @@ -50,6 +50,26 @@ module ReactOnRails described_class.new expect(File.mtime(translations_path)).to eq(ref_time) end + + it "updates files when force is true" do + # Get initial mtime after first generation + initial_mtime = File.mtime(translations_path) + + # Sleep to ensure different timestamp on fast filesystems + sleep 0.01 + + # Touch files to make them newer than YAML (up-to-date) + future_time = Time.current + 1.minute + FileUtils.touch(translations_path, mtime: future_time) + FileUtils.touch(default_path, mtime: future_time) + + # Force regeneration even though files are up-to-date + described_class.new(force: true) + + # New mtime should be different from the future_time we set + expect(File.mtime(translations_path)).not_to eq(future_time) + expect(File.mtime(translations_path)).to be > initial_mtime + end end end