-
-
Notifications
You must be signed in to change notification settings - Fork 629
Wait for the app route before auto-opening bin/dev #2885
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
dd2d5dc
87f195d
1f7e481
b70d747
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -2,6 +2,7 @@ | |||||||||||||
|
|
||||||||||||||
| require "English" | ||||||||||||||
| require "fileutils" | ||||||||||||||
| require "net/http" | ||||||||||||||
| require "open3" | ||||||||||||||
| require "optparse" | ||||||||||||||
| require "rainbow" | ||||||||||||||
|
|
@@ -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 | ||||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Minor: This method adds an extra name for the same concept without any distinct logic: def build_request_path(route)
normalize_route_path(route)
endThe two call sites ( |
||||||||||||||
|
|
||||||||||||||
| 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=) | ||||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Using |
||||||||||||||
| next unless wait_for_app_route(port, request_path) | ||||||||||||||
|
justin808 marked this conversation as resolved.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Silent failure when route never becomes ready When
Suggested change
This complements the existing |
||||||||||||||
|
|
||||||||||||||
| marker_state = prepare_browser_open_once_marker(once) | ||||||||||||||
| next if marker_state == :already_opened | ||||||||||||||
|
|
@@ -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) | ||||||||||||||
|
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 | ||||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The deadline is checked after
Suggested change
|
||||||||||||||
|
|
||||||||||||||
| sleep OPEN_BROWSER_POLL_INTERVAL | ||||||||||||||
| end | ||||||||||||||
| end | ||||||||||||||
|
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 | ||||||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Fresh evidence from this commit: 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 | ||||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
This works correctly, but it has an important behavioural implication: if
Suggested change
Alternatively, simplify the guard to just
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The
Suggested change
|
||||||||||||||
| rescue StandardError | ||||||||||||||
| false | ||||||||||||||
| next | ||||||||||||||
| end | ||||||||||||||
| nil | ||||||||||||||
| end | ||||||||||||||
|
Comment on lines
+931
to
941
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The IPv4→IPv6 fallback logic (rescue and Consider adding a spec that verifies:
|
||||||||||||||
|
|
||||||||||||||
| def open_browser(url) | ||||||||||||||
|
|
||||||||||||||
| Original file line number | Diff line number | Diff line change | ||||||
|---|---|---|---|---|---|---|---|---|
|
|
@@ -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 | ||||||||
|
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 | ||||||||
|
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 | ||||||||
|
justin808 marked this conversation as resolved.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Because A safer approach is to guard the assignment so it only takes effect inside a real background thread:
Suggested change
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) | ||||||||
|
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("") | ||||||||
|
|
@@ -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 | ||||||||
|
|
||||||||
|
|
@@ -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 | ||||||||
|
|
||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
peerDependenciesand this error requirement are inconsistentReact.actwas moved onto theReactnamespace in React 18.3+. The error message here correctly states that 18.3+ is required, butpackages/react-on-rails-pro/package.jsonstill listspeerDependencies: { react: ">= 16" }.This change de-facto raises the minimum supported React version for this test helper. Since
devDependenciesalready pinsreact-dom: "^19.0.3", consider either:peerDependenciesto reflect the actual minimum (e.g."react": ">= 18.3"), orThe runtime code (
src/) is unaffected — this only impacts test consumers — but it's worth making the version contract explicit.