Skip to content
Merged
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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,10 @@ After a release, run `/update-changelog` in Claude Code to analyze commits, writ

- **Fresh app onboarding for `create-react-on-rails-app`**: New apps now land on a generated root page with links to the local demos, docs, OSS vs Pro guidance, the Pro quick start, and the marketplace RSC demo. `bin/dev` opens that page on first boot, `--rsc` scaffolds the same fresh-app experience, and the generated app records step-by-step educational git commits for each scaffold phase. [PR 2849](https://github.com/shakacode/react_on_rails/pull/2849) by [justin808](https://github.com/justin808).

#### Fixed

- **`bin/dev` browser auto-open now waits for route readiness**: `--open-browser` and `--open-browser-once` now poll the target app route and open the browser only after receiving a success or redirect response, reducing premature opens during boot. [PR 2885](https://github.com/shakacode/react_on_rails/pull/2885) by [justin808](https://github.com/justin808).

### [16.5.1] - 2026-03-27

#### Fixed
Expand Down
9 changes: 3 additions & 6 deletions packages/react-on-rails-pro/tests/tanstackRouter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,13 +40,10 @@ type ActCallback = () => void | Promise<void>;

async function compatAct(callback: ActCallback): Promise<void> {
const reactAct = (React as typeof React & { act?: (cb: ActCallback) => Promise<unknown> | unknown }).act;
if (typeof reactAct === 'function') {
await reactAct(callback);
return;
if (typeof reactAct !== 'function') {
throw new Error('React.act is not available — React 18.3+ or 19+ is required');

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.

peerDependencies and this error requirement are inconsistent

React.act was moved onto the React namespace in React 18.3+. The error message here correctly states that 18.3+ is required, but packages/react-on-rails-pro/package.json still lists peerDependencies: { react: ">= 16" }.

This change de-facto raises the minimum supported React version for this test helper. Since devDependencies already pins react-dom: "^19.0.3", consider either:

  1. Updating peerDependencies to reflect the actual minimum (e.g. "react": ">= 18.3"), or
  2. Documenting the upgrade in the CHANGELOG / README so consumers are aware.

The runtime code (src/) is unaffected — this only impacts test consumers — but it's worth making the version contract explicit.

}

const { act } = await import('react-dom/test-utils');
await act(callback);
await reactAct(callback);
}

describe('tanstack-router integration (Pro)', () => {
Expand Down
47 changes: 35 additions & 12 deletions react_on_rails/lib/react_on_rails/dev/server_manager.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

require "English"
require "fileutils"
require "net/http"
require "open3"
require "optparse"
require "rainbow"
Expand Down Expand Up @@ -864,19 +865,31 @@ def schedule_browser_open_if_requested(procfile, route:, open_browser:, open_bro
end

def build_local_url(port, route)
normalized_route = route.to_s.strip
return "http://localhost:#{port}" if normalized_route.empty? || normalized_route == "/"
path = normalize_route_path(route)
path = "" if path == "/"
"http://localhost:#{port}#{path}"
end

def build_request_path(route)
normalize_route_path(route)
end

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.

Minor: build_request_path is a pure alias

This method adds an extra name for the same concept without any distinct logic:

def build_request_path(route)
  normalize_route_path(route)
end

The two call sites (schedule_browser_open + the spec) could call normalize_route_path directly, keeping the API surface small. If the intent was to create a stable public/semantic name separate from the implementation helper, a brief comment explaining why would help future readers.


def normalize_route_path(route)
stripped = route.to_s.strip
return "/" if stripped.empty? || stripped == "/"

normalized_route = normalized_route.sub(%r{\A/+}, "")
"http://localhost:#{port}/#{normalized_route}"
stripped = stripped.sub(%r{\A/+}, "")
"/#{stripped}"
end

def schedule_browser_open(port, route:, once:)
return unless browser_auto_open_allowed?

url = build_local_url(port, route)
request_path = build_request_path(route)
Thread.new do
next unless wait_for_server_on_port(port)
Thread.current.report_on_exception = false if Thread.current.respond_to?(:report_on_exception=)

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.

Using next here exits the block (Proc semantics), which works correctly. Using return inside a Thread.new block would raise LocalJumpError at runtime, so this is the right choice — just noting it for reviewers who might expect return in a method-like position.

next unless wait_for_app_route(port, request_path)
Comment thread
justin808 marked this conversation as resolved.

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.

Silent failure when route never becomes ready

When wait_for_app_route returns false (60-second timeout expired), the thread exits via next with zero user feedback. A developer whose --open-browser silently does nothing will have no idea whether the server never started, a persistent 4xx is being returned, or there was a network issue.

Suggested change
next unless wait_for_app_route(port, request_path)
unless wait_for_app_route(port, request_path)
warn("[react_on_rails] Timed out waiting for #{request_path} to be reachable on port #{port}; browser was not opened.")
next
end

This complements the existing rescue StandardError warn at line 904 and covers the timeout path.


marker_state = prepare_browser_open_once_marker(once)
next if marker_state == :already_opened
Expand All @@ -896,25 +909,35 @@ def browser_auto_open_allowed?
!ENV.key?("CI") && $stdin.tty? && $stdout.tty?
end

def wait_for_server_on_port(port)
def wait_for_app_route(port, request_path)
Comment thread
justin808 marked this conversation as resolved.
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + OPEN_BROWSER_WAIT_TIMEOUT

loop do
return true if localhost_port_open?(port)
return true if app_route_ready?(port, request_path)
return false if Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline

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 deadline is checked after app_route_ready? returns, but each call to that method can take up to open_timeout + read_timeout per address (i.e. up to 2 s when both 127.0.0.1 and ::1 time out). The actual wall-clock wait can therefore exceed OPEN_BROWSER_WAIT_TIMEOUT by a couple of seconds on the final iteration. This is harmless in practice, but worth noting — and the deadline check should ideally happen before sleeping as well, to avoid an extra sleep(0.5) after the deadline has passed:

Suggested change
return false if Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
return false if Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
return true if app_route_ready?(port, request_path)
return false if Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline


sleep OPEN_BROWSER_POLL_INTERVAL
end
end
Comment thread
justin808 marked this conversation as resolved.

def localhost_port_open?(port)
%w[127.0.0.1 ::1].any? do |host|
Socket.tcp(host, port, connect_timeout: 1) do
true
LOCALHOST_ADDRESSES = %w[127.0.0.1 ::1].freeze

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 Badge Probe app readiness using localhost host header

Fresh evidence from this commit: LOCALHOST_ADDRESSES now contains only 127.0.0.1/::1, so readiness probes never send a Host: localhost header even though the browser URL is http://localhost:<port>. In environments where config.hosts (or host-constrained routes) allows localhost but not literal loopback IPs, each probe gets a non-2xx response and wait_for_app_route times out, so --open-browser/--open-browser-once never triggers despite the target URL being healthy. Please probe localhost (or reuse the host from build_local_url) to align readiness checks with the actual URL being opened.

Useful? React with 👍 / 👎.

private_constant :LOCALHOST_ADDRESSES

def app_route_ready?(port, request_path)
response = http_get_localhost(port, request_path)
response.is_a?(Net::HTTPSuccess) || response.is_a?(Net::HTTPRedirection)
end

def http_get_localhost(port, request_path)
LOCALHOST_ADDRESSES.each do |host|
response = Net::HTTP.start(host, port, open_timeout: 1, read_timeout: 1) do |http|
http.get(request_path)
end
return response if response

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.

return response if response is always truthy when no exception is raised

Net::HTTP.start with a block returns whatever the block returns — http.get(request_path) always returns a Net::HTTPResponse object (never nil). So the guard if response is effectively if true here; it only fails to execute when an exception escaped the rescue on the same iteration.

This works correctly, but it has an important behavioural implication: if 127.0.0.1 is reachable but returns a non-2xx response (e.g. 500), the method returns that 500 response immediately without trying ::1. That's the right design (status checking is app_route_ready?'s job), but a short comment would make the intent clearer:

Suggested change
return response if response
# Net::HTTP.start returns a response object on success (never nil),
# so this short-circuits to the first reachable host regardless of HTTP status.
return response if response

Alternatively, simplify the guard to just return response since there's no nil path.

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 if response guard is redundant — Net::HTTP#get always returns a Net::HTTPResponse object and never returns nil or false. If the request fails, it raises an exception (caught by the rescue StandardError below). You can simplify this to just return response.

Suggested change
return response if response
return response

rescue StandardError
false
next
end
nil
end
Comment on lines +931 to 941

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 IPv4→IPv6 fallback logic (rescue and next to ::1) is the key correctness improvement in this PR, but there's no direct unit test for http_get_localhost itself. The existing specs stub this method out entirely, so a regression in the fallback (e.g. accidentally breaking the rescue/next path) would be invisible to the test suite.

Consider adding a spec that verifies:

  1. When 127.0.0.1 raises (connection refused), the method falls back to ::1
  2. When both addresses raise, the method returns nil


def open_browser(url)
Expand Down
47 changes: 45 additions & 2 deletions react_on_rails/spec/react_on_rails/dev/server_manager_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -256,6 +256,49 @@ def mock_system_calls
end
end

describe "browser auto-open readiness" do
it "normalizes routes to request paths" do
expect(described_class.send(:build_request_path, nil)).to eq("/")
expect(described_class.send(:build_request_path, "/")).to eq("/")
expect(described_class.send(:build_request_path, "hello_world")).to eq("/hello_world")
expect(described_class.send(:build_request_path, "/hello_server")).to eq("/hello_server")
end

it "treats a successful response as ready" do
response = Net::HTTPOK.new("1.1", "200", "OK")
allow(described_class).to receive(:http_get_localhost).with(3000, "/").and_return(response)

expect(described_class.send(:app_route_ready?, 3000, "/")).to be true
end

it "treats a redirect response as ready" do
response = Net::HTTPFound.new("1.1", "302", "Found")
allow(described_class).to receive(:http_get_localhost).with(3000, "/").and_return(response)

expect(described_class.send(:app_route_ready?, 3000, "/")).to be true
end

it "does not treat a server error response as ready" do
Comment thread
justin808 marked this conversation as resolved.
response = Net::HTTPInternalServerError.new("1.1", "500", "Internal Server Error")
allow(described_class).to receive(:http_get_localhost).with(3000, "/").and_return(response)

expect(described_class.send(:app_route_ready?, 3000, "/")).to be false
end
Comment thread
justin808 marked this conversation as resolved.

it "waits for the route to respond successfully before opening the browser" do
allow(described_class).to receive(:browser_auto_open_allowed?).and_return(true)
original_report_on_exception = Thread.current.report_on_exception
allow(Thread).to receive(:new).and_yield
Comment thread
justin808 marked this conversation as resolved.

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.

Thread.new.and_yield executes the block on the main test thread

Because Thread.new is stubbed with and_yield, the first line of the thread block — Thread.current.report_on_exception = false — runs on the main RSpec thread. The ensure guard correctly restores the original value, but if another example runs between the assignment and the ensure (e.g. via a parallel runner or a future refactor that moves the ensure), exception reporting is silently disabled for the entire test process.

A safer approach is to guard the assignment so it only takes effect inside a real background thread:

Suggested change
allow(Thread).to receive(:new).and_yield
original_report_on_exception = Thread.current.report_on_exception
allow(Thread).to receive(:new).and_yield

No code change needed here — just note that the existing save/restore is the right mitigation, and the ensure block must stay paired with this setup.

allow(described_class).to receive(:wait_for_app_route).with(3000, "/").and_return(true)
allow(described_class).to receive(:prepare_browser_open_once_marker).with(true).and_return(:claimed)
expect(described_class).to receive(:open_browser).with("http://localhost:3000").and_return(true)

described_class.send(:schedule_browser_open, 3000, route: "/", once: true)
Comment thread
justin808 marked this conversation as resolved.
ensure
Thread.current.report_on_exception = original_report_on_exception
end
end

describe ".kill_processes" do
before do
allow_any_instance_of(Kernel).to receive(:`).and_return("")
Expand Down Expand Up @@ -1011,7 +1054,7 @@ def mock_system_calls
stub_const("#{described_class}::OPEN_BROWSER_ONCE_MARKER", File.join(marker_dir, "browser_opened_once"))
allow(described_class).to receive_messages(
browser_auto_open_allowed?: true,
wait_for_server_on_port: true
wait_for_app_route: true
)
end

Expand All @@ -1025,7 +1068,7 @@ def mock_system_calls
end

it "warns when the browser-open thread raises unexpectedly" do
allow(described_class).to receive(:wait_for_server_on_port).and_raise(SocketError, "boom")
allow(described_class).to receive(:wait_for_app_route).and_raise(SocketError, "boom")
expect(described_class).to receive(:warn).with("[react_on_rails] Browser auto-open failed: boom")

described_class.send(:schedule_browser_open, 3000, route: "/", once: false).join
Expand Down
Loading