Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ After a release, run `/update-changelog` in Claude Code to analyze commits, writ

#### Fixed

- **Generated pack regeneration is now serialized**: `generate_packs_if_stale` now uses a Rails `tmp/` lock file, re-checks staleness after waiting, and avoids concurrent cleanup/regeneration races when multiple processes trigger auto-bundling at the same time. Fixes [Issue 1627](https://github.com/shakacode/react_on_rails/issues/1627). [PR 3231](https://github.com/shakacode/react_on_rails/pull/3231) by [justin808](https://github.com/justin808).
- **[Pro]** **Node renderer now exposes `performance` when `supportModules: true`**: React 19's development build of `React.lazy` calls `performance.now()`, which previously threw `ReferenceError: performance is not defined` inside the node renderer's VM context unless users manually added `performance` via `additionalContext`. `performance` is now included in the default globals alongside `Buffer`, `process`, etc. Fixes [Issue 3154](https://github.com/shakacode/react_on_rails/issues/3154). [PR 3158](https://github.com/shakacode/react_on_rails/pull/3158) by [justin808](https://github.com/justin808).
- **Client startup now recovers if initialization begins during `interactive` after `DOMContentLoaded` already fired**: React on Rails now still initializes the page when the client bundle starts in the browser timing window after `DOMContentLoaded` but before the document reaches `complete`. Fixes [Issue 3150](https://github.com/shakacode/react_on_rails/issues/3150). [PR 3151](https://github.com/shakacode/react_on_rails/pull/3151) by [ihabadham](https://github.com/ihabadham).
- **Doctor accepts TypeScript server bundle entrypoints**: `react_on_rails:doctor` now resolves common source entrypoint suffixes (`.js`, `.jsx`, `.ts`, `.tsx`, `.mjs`, `.cjs`) before warning that the server bundle is missing, preventing false positives when apps use `server-bundle.ts`. [PR 3111](https://github.com/shakacode/react_on_rails/pull/3111) by [justin808](https://github.com/justin808).
Expand Down
68 changes: 56 additions & 12 deletions react_on_rails/lib/react_on_rails/packs_generator.rb
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ class PacksGenerator
# Auto-registration requires nested_entries support which was added in 7.0.0
# Note: The gemspec requires Shakapacker >= 6.0 for basic functionality
MINIMUM_SHAKAPACKER_VERSION_FOR_AUTO_BUNDLING = "7.0.0"
GENERATED_PACKS_LOCK_TTL_SECONDS = 120

def self.instance
@instance ||= PacksGenerator.new
Expand All @@ -48,26 +49,69 @@ def generate_packs_if_stale

verbose = ENV["REACT_ON_RAILS_VERBOSE"] == "true"

add_generated_pack_to_server_bundle
with_generated_packs_lock(verbose: verbose) do
add_generated_pack_to_server_bundle

# Clean any non-generated files from directories
clean_non_generated_files_with_feedback(verbose: verbose)
# 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 generated_files_present_and_up_to_date?
puts Rainbow("✅ Generated packs are up to date, no regeneration needed").green if verbose
return
end
Comment on lines +58 to +61
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The return inside a yield-based block works correctly here — Ruby propagates it as a method-return from generate_packs_if_stale, and the ensure in the File.open block still runs, properly releasing the lock. However, this is a subtle Ruby behavior that could surprise contributors who refactor this code (e.g., wrapping the block in a Proc instead of yield would turn it into a LocalJumpError).

A less surprising alternative is to use a boolean guard:

unless generated_files_present_and_up_to_date?
  clean_generated_directories_with_feedback(verbose: verbose)
  generate_packs(verbose: verbose)
end

This avoids relying on return-from-block semantics entirely.


if are_generated_files_present_and_up_to_date
puts Rainbow("✅ Generated packs are up to date, no regeneration needed").green if verbose
return
clean_generated_directories_with_feedback(verbose: verbose)
generate_packs(verbose: verbose)
end
Comment on lines +52 to 65
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 All callers serialize through the lock even when packs are up to date

add_generated_pack_to_server_bundle and clean_non_generated_files_with_feedback run inside the exclusive lock on every request, before the staleness re-check. When packs are already current (the common case after warm-up), every concurrent request still serializes through these two I/O operations and the lock acquire/release cycle. A lightweight pre-lock generated_files_present_and_up_to_date? guard would let subsequent callers exit immediately without contending on the lock.


clean_generated_directories_with_feedback(verbose: verbose)
generate_packs(verbose: verbose)
end

private

def generated_files_present_and_up_to_date?
Dir.exist?(generated_packs_directory_path) &&
File.exist?(generated_server_bundle_file_path) &&
!stale_or_missing_packs?
end

def with_generated_packs_lock(verbose: false)
lock_path = generated_packs_lock_path
FileUtils.mkdir_p(lock_path.dirname)
remove_stale_generated_packs_lock(lock_path, verbose: verbose)

File.open(lock_path, File::RDWR | File::CREAT, 0o644) do |lock_file|
puts Rainbow("🔒 Waiting for generated packs lock at #{lock_path}").yellow if verbose
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The "Waiting for generated packs lock" message is printed before flock(LOCK_EX) is called, so it fires even when the lock is acquired immediately (no contention, no wait). Consider moving it after the flock call, or split into two messages — one before ("acquiring...") and one after ("acquired, proceeding...").

Suggested change
puts Rainbow("🔒 Waiting for generated packs lock at #{lock_path}").yellow if verbose
lock_file.flock(File::LOCK_EX)
puts Rainbow("🔒 Acquired generated packs lock at #{lock_path}").yellow if verbose

lock_file.flock(File::LOCK_EX)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

flock(File::LOCK_EX) blocks indefinitely. The 120-second TTL only removes a stale lock file when no holder is present (the LOCK_NB attempt fails while the holder is still alive). If pack generation hangs (process alive but stuck), every subsequent caller will hang forever with no escape.

Consider using a timeout approach, e.g. a background thread that sends a signal after N seconds, or polling with LOCK_NB in a loop:

deadline = Time.now + GENERATED_PACKS_LOCK_TTL_SECONDS
loop do
  break if lock_file.flock(File::LOCK_EX | File::LOCK_NB)
  raise "Timed out waiting for generated packs lock" if Time.now > deadline
  sleep 0.5
end

lock_file.rewind
lock_file.truncate(0)
lock_file.write("pid=#{Process.pid}\nstarted_at=#{Time.now.utc}\n")
lock_file.flush

yield
ensure
lock_file&.flock(File::LOCK_UN)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

lock_file is the block parameter of File.open and is never nil, so &. (safe navigation) is unnecessary here and in the other ensure below. Minor, but it reads as if lock_file could be nil, which could mislead future readers.

Suggested change
lock_file&.flock(File::LOCK_UN)
lock_file.flock(File::LOCK_UN)

end
end

def remove_stale_generated_packs_lock(lock_path, verbose: false)
return unless File.exist?(lock_path)
return unless File.mtime(lock_path) < Time.now - GENERATED_PACKS_LOCK_TTL_SECONDS

File.open(lock_path, File::RDWR) do |lock_file|
next unless lock_file.flock(File::LOCK_EX | File::LOCK_NB)

FileUtils.rm_f(lock_path)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Avoid unlinking lock file while other callers may have opened it

Removing the lock path in remove_stale_generated_packs_lock can break mutual exclusion when multiple callers arrive during stale-lock recovery: one caller can rm_f the stale path and then lock a newly created inode, while another caller that already opened the old inode (before unlink) later acquires a lock on that old inode. In that case both callers run generate_packs_if_stale concurrently, which defeats the serialization guarantee and can reintroduce concurrent clean/regenerate races after a crash leaves a stale lock file.

Useful? React with 👍 / 👎.

puts Rainbow("🧹 Removed stale generated packs lock at #{lock_path}").yellow if verbose
ensure
lock_file&.flock(File::LOCK_UN)
end
Comment on lines +99 to +106
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 LOCK_UN called even when LOCK_NB was never acquired

When flock(LOCK_EX | LOCK_NB) returns false, the next skips the deletion but the ensure still calls flock(LOCK_UN) on a file description that never held the lock. While benign on Linux/macOS, guarding the unlock with an acquired flag improves clarity and correctness on non-POSIX platforms.

rescue Errno::ENOENT
nil
end

def generated_packs_lock_path
Rails.root.join("tmp", "react_on_rails_generate_packs.lock")
end

def generate_packs(verbose: false)
# Check for name conflicts between components and stores
check_for_component_store_name_conflicts
Expand Down
39 changes: 39 additions & 0 deletions react_on_rails/spec/dummy/spec/packs_generator_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ def self.configuration
after do
ReactOnRails.configuration.server_bundle_js_file = old_server_bundle
ReactOnRails.configuration.components_subdirectory = old_subdirectory
ReactOnRails.configuration.auto_load_bundle = old_auto_load_bundle

FileUtils.rm_rf "#{packer_source_entry_path}/generated"
FileUtils.rm_rf generated_server_bundle_file_path
Expand Down Expand Up @@ -410,6 +411,44 @@ def self.rsc_support_enabled?
expect(File.read(server_bundle_js_file_path).scan(/(?=#{test_string})/).count).to equal(1)
end

it "serializes concurrent generation and rechecks staleness after waiting" do
generator = described_class.new
generation_started = Queue.new
release_generation = Queue.new
second_completed = Queue.new
generation_count = 0

allow(generator).to receive(:add_generated_pack_to_server_bundle)
allow(generator).to receive(:clean_non_generated_files_with_feedback)
allow(generator).to receive(:clean_generated_directories_with_feedback)
allow(generator).to receive(:generated_files_present_and_up_to_date?).and_return(false, true)
allow(generator).to receive(:generate_packs) do
generation_count += 1
generation_started << true
release_generation.pop
end

first_thread = Thread.new { generator.generate_packs_if_stale }
generation_started.pop

second_thread = Thread.new do
generator.generate_packs_if_stale
second_completed << true
end

sleep 0.1
expect { second_completed.pop(true) }.to raise_error(ThreadError)
Comment on lines +439 to +440
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This sleep 0.1 + non-blocking pop(true) pattern is a timing-based assertion: it assumes that 100 ms is long enough for thread 2 to reach the flock call and block, but not so long that CI load causes a false pass. This is a common source of flakiness in CI environments under load.

A more robust approach is to use a Queue (or Mutex + ConditionVariable) to know when thread 2 is actually blocked on the lock, rather than sleeping and hoping. For example, you could stub with_generated_packs_lock to signal a queue when it is entered by the second caller, before the block is executed.


release_generation << true
first_thread.join
second_thread.join
Comment on lines +439 to +444
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Flaky timing assertion

The sleep 0.1 before the non-blocking pop is not a reliable way to assert that the second thread has reached and is blocked on flock. On a slow CI runner (or under load), the second thread may not have even entered generate_packs_if_stale yet after 100 ms, making second_completed.pop(true) raise ThreadError for the wrong reason — the thread simply hasn't run yet, not because it's blocked on the lock. A more robust pattern uses a second queue/latch that the thread posts to immediately before blocking.


expect(generation_count).to eq(1)
ensure
first_thread&.kill if first_thread&.alive?
second_thread&.kill if second_thread&.alive?
end

it "generate packs if a new component is added" do
create_new_component("NewComponent")

Expand Down
Loading