ruact 0.0.6 → 0.0.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 4ecde11a9eeeaa534f73c9aba3a1c15a1bb44bbe1035467dad11b6f8517085aa
4
- data.tar.gz: 7b3077daa2fe5cef2189b40c575df223cc960b49e8ace3ce5cebf8b61afd9e29
3
+ metadata.gz: c3ae4065926e9ba0e05bc16a221311f82af38ec3968c38066d806e8c1af04b89
4
+ data.tar.gz: 67d36ca87ddd0bab244352b658784238d3a97abd6b955279d7f162d221c81313
5
5
  SHA512:
6
- metadata.gz: 2696f4a816b237e124465604bc04a2d5a54804601d413bf907429376efb8b72d17ff876f013fb4491df47c4b231a696f7213c8e431e5924f4a504e7d2b7a1214
7
- data.tar.gz: e1cf6a62a6e0d4e5d061b4dae97fe71d47df9137db0a3cc1581cb1056282a0b757a69eff7d1a7981ae3a419ee9d79be938871cb1f636d612e2a6affd026a809f
6
+ metadata.gz: 87af4e8c9df874d9d7f5d93c87144689c73d83b8cb5097696891042bdae284de2f40c7e783317aa429e2a9c97fe318668873754c41d739a2f76c333bb07dec87
7
+ data.tar.gz: fd3e4f768563a86f29b074fa8c894c0faf4718789dab950325f8c8aa7cbb51e38c34565ded5f99a5e536a01b7641929d23b74ec5cd9e2cb3540e08fa97d98fdc
data/CHANGELOG.md CHANGED
@@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.0.7] - 2026-06-30
11
+
12
+ ### Fixed
13
+
14
+ - **Boot-race that 500'd the first request in development — the manifest is now resolved from the Vite dev server over HTTP.** On a fresh app the Railtie's `config.to_prepare` read `public/react-client-manifest.json` **once at boot**, but Rails frequently booted (and read the still-missing file) **before** the Vite dev server wrote it — leaving `Ruact.manifest` `nil`. `public/` is not watched, so `to_prepare` never re-fired, and the first request to a view containing a component hit `nil.reference_for` → a cryptic 500. The bundled Vite plugin now **serves the live in-memory manifest at `GET <vite_dev_server>/__ruact/manifest`** (always fresh, reflects HMR rebuilds, internal `_sourceFile` field stripped), and in **development** the gem resolves the manifest through a new `Ruact::ManifestResolver`: it fetches that endpoint over `Net::HTTP` (~1s timeout, once per render/preprocess — not per component), **falls back** to `public/react-client-manifest.json` on disk when the dev server is down, and otherwise raises a **clear, actionable** error (`Vite dev server inacessível … rode bin/dev`) instead of a `NoMethodError`. **Production is untouched** — it still uses the boot-loaded `Ruact.manifest` (raising at boot if the build is missing); the HTTP fetch is dev-only. The Vite plugin continues to **write** `public/react-client-manifest.json` (prod build + the dev fallback). Both render and the FR100 contract validator resolve through the same path (the validator fails open to no-validation when the manifest is unreachable). The three hardcoded `http://localhost:5173` references in `ruact_js_assets` (the react-refresh preamble, `@vite/client`, and the bootstrap `<script src>`) now honor `Ruact.config.vite_dev_server` for consistency.
15
+
10
16
  ## [0.0.6] - 2026-06-30
11
17
 
12
18
  > **Server functions are route-driven.** A server **mutation** is a normal non-GET
@@ -162,5 +168,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
162
168
  - **CI matrix** — GitHub Actions: RSpec across Ruby 3.2 × 3.3 × Rails 7.0 × 7.1 × 7.2 × 8.0; RuboCop; YARD docs; memory benchmark; E2E system tests against React 19.0.0 and 19.x (Capybara + Cuprite); non-blocking React@next job with auto-issue on failure.
163
169
  - **E2E test app** — `e2e/` Rails app (no DB, in-memory Post model) with full CRUD system tests validating the complete request cycle.
164
170
 
165
- [Unreleased]: https://github.com/luizcg/ruact/compare/v0.0.6...HEAD
171
+ [Unreleased]: https://github.com/luizcg/ruact/compare/v0.0.7...HEAD
172
+ [0.0.7]: https://github.com/luizcg/ruact/releases/tag/v0.0.7
166
173
  [0.0.6]: https://github.com/luizcg/ruact/releases/tag/v0.0.6
@@ -36,10 +36,16 @@ module Ruact
36
36
  # only ever invoked internally by `ruact_html_shell`).
37
37
  private :ruact_js_assets, :__ruact_component__
38
38
 
39
- # Returns the boot-time cached manifest (set by Railtie#config.to_prepare).
40
- # No per-request file I/O (AC#6).
39
+ # Resolves the manifest for this render. In PRODUCTION this is the boot-time
40
+ # cached +Ruact.manifest+ (set by Railtie#config.to_prepare) — no per-request
41
+ # I/O. In DEVELOPMENT it fetches the live manifest from the Vite dev server
42
+ # (falling back to the on-disk file, then a clear error), killing the
43
+ # boot-race where Rails read the missing file before Vite wrote it and every
44
+ # first request 500'd on +nil.reference_for+. Called once per render; the
45
+ # returned manifest is held by RenderPipeline for the whole render (not
46
+ # re-fetched per component). See {Ruact::ManifestResolver}.
41
47
  def ruact_manifest
42
- Ruact.manifest
48
+ ManifestResolver.resolve
43
49
  end
44
50
 
45
51
  # Only activate RSC rendering when the matching .html.erb template exists AND
@@ -74,7 +74,13 @@ module Ruact
74
74
  line = result[0...match_start].count("\n") + 1
75
75
 
76
76
  begin
77
- registry = Ruact.manifest if registry == :default # lazy — only when a tag exists
77
+ # lazy — only when a tag exists. `resolve_soft` returns the dev-fetched
78
+ # manifest (same source the render path uses, so the boot-race doesn't
79
+ # silence FR100 contract checks in dev) and FAILS OPEN to nil when the
80
+ # manifest is unresolvable (contract validation is opt-in/fail-open;
81
+ # the render path surfaces the clear error). In prod this is the
82
+ # boot-loaded Ruact.manifest, unchanged.
83
+ registry = ManifestResolver.resolve_soft if registry == :default
78
84
  pairs = parse_prop_pairs(attrs_string)
79
85
  validate_contract(registry, component_name, pairs.map(&:first),
80
86
  at: { file: identifier, line: line, snippet: match.strip })
@@ -0,0 +1,149 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Ruact
6
+ # Resolves the {Ruact::ClientManifest} to use for the current render /
7
+ # ERB-preprocess.
8
+ #
9
+ # PRODUCTION (unchanged): returns the boot-loaded +Ruact.manifest+ (set once by
10
+ # the Railtie's +config.to_prepare+; the Railtie raises at boot when the file is
11
+ # absent). No per-request I/O.
12
+ #
13
+ # DEVELOPMENT (the fix): the boot-time +to_prepare+ read of
14
+ # +public/react-client-manifest.json+ RACES the Vite dev server's first write —
15
+ # on a fresh app Rails can boot (and read the missing file) BEFORE Vite writes
16
+ # it, leaving +Ruact.manifest+ nil even though Vite is running and serving a
17
+ # current manifest. +public/+ is not watched, so +to_prepare+ never re-fires and
18
+ # the first request to a view with a component hits +nil.reference_for+ → 500.
19
+ #
20
+ # To kill that race, dev resolution fetches the LIVE manifest the Vite plugin
21
+ # serves in memory at +GET {vite_dev_server}/__ruact/manifest+ (always fresh,
22
+ # reflects HMR rebuilds), with graceful fallbacks:
23
+ #
24
+ # 1. HTTP fetch from the Vite dev server (short timeout).
25
+ # 2. Fallback: read +public/react-client-manifest.json+ from disk (the Vite
26
+ # plugin still writes it — prod needs it for the build, and it is the
27
+ # fallback when the dev server is down).
28
+ # 3. Neither available → a clear, actionable error (never a cryptic
29
+ # +NoMethodError+ on nil).
30
+ #
31
+ # Memoization grain: the resolver fetches ONCE PER CALL. The two call sites each
32
+ # invoke it once per their scope and reuse the result for every component —
33
+ # +RenderPipeline+ holds the returned manifest in +@manifest+ for the whole
34
+ # render, and +ErbPreprocessor#transform+ resolves it lazily into a local that
35
+ # is reused across every tag in the template. So a render does NOT re-fetch per
36
+ # component (the bug was many +reference_for+ calls all needing a manifest); at
37
+ # most one fetch for the render and one for a (re)compiled template.
38
+ module ManifestResolver
39
+ DEV_MANIFEST_PATH = "/__ruact/manifest"
40
+ # Dev is localhost; fail fast to the file/clear-error fallback. A refused
41
+ # connection (Vite simply down) raises immediately, so this timeout only
42
+ # bounds the pathological "listening but not answering" case.
43
+ HTTP_OPEN_TIMEOUT = 1
44
+ HTTP_READ_TIMEOUT = 1
45
+
46
+ # Resolve the manifest for a render. In dev, raises a clear
47
+ # {Ruact::ManifestError} when neither the dev server nor the on-disk file is
48
+ # available. In any non-development environment, returns +Ruact.manifest+
49
+ # verbatim (production behaviour is untouched).
50
+ #
51
+ # @return [Ruact::ClientManifest, nil]
52
+ def self.resolve
53
+ return Ruact.manifest unless development?
54
+
55
+ dev_manifest(soft: false)
56
+ end
57
+
58
+ # Like {.resolve} but FAIL-OPEN in dev: returns +nil+ (rather than raising)
59
+ # when nothing is resolvable. Used by the ERB preprocessor's opt-in FR100
60
+ # contract validation, which is fail-open by design — a missing manifest must
61
+ # not crash template compilation; the render path then surfaces the clear
62
+ # error from {.resolve}.
63
+ #
64
+ # @return [Ruact::ClientManifest, nil]
65
+ def self.resolve_soft
66
+ return Ruact.manifest unless development?
67
+
68
+ dev_manifest(soft: true)
69
+ end
70
+
71
+ # @return [Ruact::ClientManifest, nil]
72
+ def self.dev_manifest(soft:)
73
+ manifest = fetch_over_http
74
+ return manifest if manifest
75
+
76
+ manifest = load_from_file
77
+ return manifest if manifest
78
+
79
+ return nil if soft
80
+
81
+ raise ManifestError, <<~MSG.strip
82
+ [ruact] Vite dev server inacessível em #{base_url} e nenhum \
83
+ react-client-manifest.json encontrado em #{file_path} — rode `bin/dev`.
84
+ MSG
85
+ end
86
+
87
+ # Fetch + parse the live manifest from the Vite dev server, or +nil+ when the
88
+ # server is unreachable / returns a non-200 / unparseable body. Never raises:
89
+ # any failure degrades to the file fallback.
90
+ #
91
+ # @return [Ruact::ClientManifest, nil]
92
+ def self.fetch_over_http
93
+ require "net/http"
94
+ require "uri"
95
+
96
+ # `chomp("/")` so a base configured WITH a trailing slash
97
+ # ("http://localhost:5173/") does not yield a double-slashed
98
+ # "//__ruact/manifest" that misses the Vite middleware mount.
99
+ uri = URI.parse("#{base_url.chomp('/')}#{DEV_MANIFEST_PATH}")
100
+ response = Net::HTTP.start(
101
+ uri.host, uri.port,
102
+ use_ssl: uri.scheme == "https",
103
+ open_timeout: HTTP_OPEN_TIMEOUT, read_timeout: HTTP_READ_TIMEOUT
104
+ ) { |http| http.get(uri.request_uri) }
105
+
106
+ return nil unless response.is_a?(Net::HTTPSuccess)
107
+
108
+ ClientManifest.from_hash(JSON.parse(response.body))
109
+ rescue StandardError
110
+ nil
111
+ end
112
+
113
+ # Fallback: load the on-disk manifest the Vite plugin also writes, or +nil+
114
+ # when it is absent (the boot-race window) / unreadable.
115
+ #
116
+ # @return [Ruact::ClientManifest, nil]
117
+ def self.load_from_file
118
+ path = file_path
119
+ return nil unless path && File.exist?(path)
120
+
121
+ ClientManifest.load(path)
122
+ rescue StandardError
123
+ nil
124
+ end
125
+
126
+ # @return [String] the configured Vite dev-server base URL.
127
+ def self.base_url
128
+ Ruact.config.vite_dev_server
129
+ end
130
+
131
+ # @return [String, nil] the on-disk manifest path (config override or default).
132
+ def self.file_path
133
+ configured = Ruact.config.manifest_path
134
+ return configured.to_s if configured
135
+
136
+ return nil unless defined?(Rails) && Rails.respond_to?(:root) && Rails.root
137
+
138
+ Rails.root.join("public", "react-client-manifest.json").to_s
139
+ end
140
+
141
+ # @return [Boolean] true only in a real Rails development environment. Any
142
+ # other env (production, test, or no Rails at all) takes the untouched
143
+ # +Ruact.manifest+ path.
144
+ def self.development?
145
+ defined?(Rails) && Rails.respond_to?(:env) && Rails.env.respond_to?(:development?) &&
146
+ Rails.env.development?
147
+ end
148
+ end
149
+ end
data/lib/ruact/railtie.rb CHANGED
@@ -100,9 +100,20 @@ module Ruact
100
100
  # Extracted as a class method for direct testability without a full Rails app.
101
101
  def self.check_vite!
102
102
  require "socket"
103
- TCPSocket.new("localhost", 5173).close
104
- rescue Errno::ECONNREFUSED, Errno::EHOSTUNREACH
105
- Rails.logger.warn "[ruact] Vite dev server not detected at localhost:5173 " \
103
+ require "uri"
104
+ uri = URI.parse(Ruact.config.vite_dev_server)
105
+ host = uri.host || "localhost"
106
+ port = uri.port || 5173
107
+ # `connect_timeout` so a blackholed/remote configured host can't stall dev
108
+ # boot from `after_initialize` until the OS TCP timeout (matches
109
+ # ViewHelper#vite_dev_running?).
110
+ TCPSocket.new(host, port, connect_timeout: 1).close
111
+ rescue Errno::ECONNREFUSED, Errno::EHOSTUNREACH, Errno::ETIMEDOUT, SocketError
112
+ # Broad rescue (incl. SocketError) because the host is now configurable: a
113
+ # misconfigured/unresolvable `vite_dev_server` must downgrade to this dev
114
+ # warning, never crash boot from `after_initialize`. `host`/`port` are
115
+ # assigned before the connect attempt, so they are always in scope here.
116
+ Rails.logger.warn "[ruact] Vite dev server not detected at #{host}:#{port} " \
106
117
  "— run npm run dev for HMR"
107
118
  end
108
119
 
@@ -166,7 +166,14 @@ module Ruact
166
166
  def render_erb_enum(erb_source, binding_context, streaming:)
167
167
  Enumerator.new do |y|
168
168
  render_context = RenderContext.new
169
- transformed = ErbPreprocessor.transform(erb_source)
169
+ # `registry: nil` → fail-open, no FR100 contract validation for this
170
+ # low-level programmatic render path (contract checking lives on the
171
+ # ActionView preprocessor hook used by real controllers). This keeps the
172
+ # path off `Ruact::ManifestResolver` entirely — historically it read the
173
+ # global `Ruact.manifest` (typically nil here, so already no validation),
174
+ # so passing nil is byte-for-byte equivalent while avoiding any dev HTTP
175
+ # fetch / test-double dispatch inside callers that measure this path.
176
+ transformed = ErbPreprocessor.transform(erb_source, registry: nil)
170
177
  receiver = binding_context.eval("self")
171
178
  prev_ctx = receiver.instance_variable_get(:@__ruact_render_context__)
172
179
  inject_helper!(binding_context, render_context)
data/lib/ruact/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Ruact
4
- VERSION = "0.0.6"
4
+ VERSION = "0.0.7"
5
5
  end
@@ -83,9 +83,10 @@ module Ruact
83
83
  # @vitejs/plugin-react normally injects this preamble by processing index.html.
84
84
  # Since our HTML is generated by Rails (not Vite), we inject it manually.
85
85
  # Without it, every JSX file throws "can't detect preamble" at runtime.
86
+ dev_server = Ruact.config.vite_dev_server.chomp("/")
86
87
  react_preamble = <<~JS
87
88
  <script type="module">
88
- import RefreshRuntime from 'http://localhost:5173/@react-refresh';
89
+ import RefreshRuntime from '#{dev_server}/@react-refresh';
89
90
  RefreshRuntime.injectIntoGlobalHook(window);
90
91
  window.$RefreshReg$ = () => {};
91
92
  window.$RefreshSig$ = () => (type) => type;
@@ -94,7 +95,7 @@ module Ruact
94
95
  JS
95
96
 
96
97
  react_preamble + <<~HTML
97
- <script type="module" src="http://localhost:5173/@vite/client"></script>
98
+ <script type="module" src="#{dev_server}/@vite/client"></script>
98
99
  <script type="module" src="#{ruact_bootstrap_dev_url}"></script>
99
100
  HTML
100
101
  else
@@ -112,12 +113,18 @@ module Ruact
112
113
  # `/@id/__x00__virtual:ruact/bootstrap`. A plain `/virtual:...` URL falls
113
114
  # through to the dev server's HTML fallback, so this encoding is required.
114
115
  def ruact_bootstrap_dev_url
115
- "http://localhost:5173/@id/__x00__#{Ruact.bootstrap_virtual_id}"
116
+ "#{Ruact.config.vite_dev_server.chomp('/')}/@id/__x00__#{Ruact.bootstrap_virtual_id}"
116
117
  end
117
118
 
119
+ # Probe the CONFIGURED dev server (host:port parsed from
120
+ # `Ruact.config.vite_dev_server`), not a hardcoded localhost:5173 — so a
121
+ # custom `vite_dev_server` and the emitted dev `<script>` URLs always agree
122
+ # on whether the server is up.
118
123
  def vite_dev_running?
119
124
  require "socket"
120
- Socket.tcp("localhost", 5173, connect_timeout: 1).close
125
+ require "uri"
126
+ uri = URI.parse(Ruact.config.vite_dev_server)
127
+ Socket.tcp(uri.host || "localhost", uri.port || 5173, connect_timeout: 1).close
121
128
  true
122
129
  rescue Errno::ECONNREFUSED, Errno::EHOSTUNREACH, Errno::ETIMEDOUT, SocketError
123
130
  false
data/lib/ruact.rb CHANGED
@@ -12,6 +12,7 @@ require_relative "ruact/erb_preprocessor"
12
12
  require_relative "ruact/render_context"
13
13
  require_relative "ruact/html_converter"
14
14
  require_relative "ruact/client_manifest"
15
+ require_relative "ruact/manifest_resolver"
15
16
  require_relative "ruact/render_pipeline"
16
17
  require_relative "ruact/view_helper"
17
18
  require_relative "ruact/erb_preprocessor_hook"
@@ -39,10 +39,15 @@ module Ruact
39
39
  let(:controller) { test_class.new(fake_request) }
40
40
 
41
41
  describe "#ruact_manifest" do
42
- it "reads from Ruact.manifest (AC#6)" do
42
+ # Opt OUT of spec_helper's suite-wide resolver stub here so this proves the
43
+ # actual delegation: `#ruact_manifest` resolves through ManifestResolver
44
+ # (prod returns the boot-loaded Ruact.manifest; dev fetches over HTTP).
45
+ it "delegates manifest resolution to ManifestResolver.resolve" do
43
46
  test_manifest = ClientManifest.from_hash({})
44
- allow(Ruact).to receive(:manifest).and_return(test_manifest)
47
+ allow(ManifestResolver).to receive(:resolve).and_return(test_manifest)
48
+
45
49
  expect(controller.send(:ruact_manifest)).to be test_manifest
50
+ expect(ManifestResolver).to have_received(:resolve)
46
51
  end
47
52
  end
48
53
 
@@ -0,0 +1,159 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+ require "net/http"
5
+ require "tmpdir"
6
+
7
+ module Ruact
8
+ RSpec.describe ManifestResolver do
9
+ # spec_helper pins resolve/resolve_soft to the boot manifest suite-wide for
10
+ # hermeticity; this spec is the one place that exercises the REAL paths.
11
+ before do
12
+ allow(described_class).to receive(:resolve).and_call_original
13
+ allow(described_class).to receive(:resolve_soft).and_call_original
14
+ end
15
+
16
+ let(:manifest_hash) do
17
+ {
18
+ "LikeButton" => {
19
+ "id" => "/LikeButton.jsx",
20
+ "name" => "LikeButton",
21
+ "chunks" => ["/LikeButton.jsx"]
22
+ }
23
+ }
24
+ end
25
+ let(:manifest_json) { JSON.generate(manifest_hash) }
26
+
27
+ # A real Net::HTTPSuccess (HTTPOK < HTTPSuccess) whose body we override, so
28
+ # the resolver's `response.is_a?(Net::HTTPSuccess)` branch is exercised for
29
+ # real without touching the network.
30
+ def http_ok(body)
31
+ response = Net::HTTPOK.new("1.1", "200", "OK")
32
+ response.define_singleton_method(:body) { body }
33
+ response
34
+ end
35
+
36
+ def in_dev
37
+ allow(Rails).to receive(:env).and_return(ActiveSupport::StringInquirer.new("development"))
38
+ end
39
+
40
+ describe ".resolve in development (the boot-race)" do
41
+ before do
42
+ in_dev
43
+ # Simulate the race: boot read the missing file, so the cached manifest is nil.
44
+ allow(Ruact).to receive(:manifest).and_return(nil)
45
+ end
46
+
47
+ it "resolves from the Vite dev server over HTTP (no 500, ref resolvable)" do
48
+ allow(Net::HTTP).to receive(:start).and_return(http_ok(manifest_json))
49
+
50
+ manifest = described_class.resolve
51
+
52
+ expect(manifest).to be_a(ClientManifest)
53
+ ref = manifest.reference_for("LikeButton")
54
+ expect(ref.module_id).to eq("/LikeButton.jsx")
55
+ end
56
+
57
+ it "fetches the manifest from the configured vite_dev_server URL" do
58
+ allow(Net::HTTP).to receive(:start).and_return(http_ok(manifest_json))
59
+
60
+ described_class.resolve
61
+
62
+ expect(Net::HTTP).to have_received(:start).with(
63
+ "localhost", 5173, hash_including(:open_timeout, :read_timeout)
64
+ )
65
+ end
66
+
67
+ it "does not double-slash the path when vite_dev_server has a trailing slash" do
68
+ allow(described_class).to receive(:base_url).and_return("http://localhost:5173/")
69
+ http = instance_double(Net::HTTP)
70
+ allow(http).to receive(:get).and_return(http_ok(manifest_json))
71
+ allow(Net::HTTP).to receive(:start).and_yield(http).and_return(http_ok(manifest_json))
72
+
73
+ described_class.resolve
74
+
75
+ expect(http).to have_received(:get).with("/__ruact/manifest")
76
+ end
77
+
78
+ it "speaks TLS when vite_dev_server is https" do
79
+ allow(described_class).to receive(:base_url).and_return("https://localhost:5173")
80
+ allow(Net::HTTP).to receive(:start).and_return(http_ok(manifest_json))
81
+
82
+ described_class.resolve
83
+
84
+ expect(Net::HTTP).to have_received(:start).with(
85
+ "localhost", 5173, hash_including(use_ssl: true)
86
+ )
87
+ end
88
+
89
+ it "falls back to the on-disk file when the dev server is unreachable" do
90
+ allow(Net::HTTP).to receive(:start).and_raise(Errno::ECONNREFUSED)
91
+
92
+ Dir.mktmpdir do |dir|
93
+ path = File.join(dir, "react-client-manifest.json")
94
+ File.write(path, manifest_json)
95
+ allow(described_class).to receive(:file_path).and_return(path)
96
+
97
+ manifest = described_class.resolve
98
+ expect(manifest).to be_a(ClientManifest)
99
+ expect(manifest.reference_for("LikeButton").module_id).to eq("/LikeButton.jsx")
100
+ end
101
+ end
102
+
103
+ it "raises a clear, actionable error (not NoMethodError) when neither HTTP nor file is available" do
104
+ allow(Net::HTTP).to receive(:start).and_raise(Errno::ECONNREFUSED)
105
+ allow(described_class).to receive(:file_path).and_return("/no/such/manifest.json")
106
+
107
+ expect { described_class.resolve }.to raise_error(ManifestError, %r{Vite dev server.*bin/dev}m)
108
+ end
109
+
110
+ it "ignores a non-2xx dev-server response and falls back" do
111
+ allow(Net::HTTP).to receive(:start).and_return(Net::HTTPNotFound.new("1.1", "404", "Not Found"))
112
+ allow(described_class).to receive(:file_path).and_return("/no/such/manifest.json")
113
+
114
+ expect { described_class.resolve }.to raise_error(ManifestError)
115
+ end
116
+ end
117
+
118
+ describe ".resolve_soft in development (fail-open for FR100)" do
119
+ before do
120
+ in_dev
121
+ allow(Ruact).to receive(:manifest).and_return(nil)
122
+ end
123
+
124
+ it "returns nil (no raise) when nothing is resolvable" do
125
+ allow(Net::HTTP).to receive(:start).and_raise(Errno::ECONNREFUSED)
126
+ allow(described_class).to receive(:file_path).and_return("/no/such/manifest.json")
127
+
128
+ expect(described_class.resolve_soft).to be_nil
129
+ end
130
+
131
+ it "returns the dev manifest when the server is up" do
132
+ allow(Net::HTTP).to receive(:start).and_return(http_ok(manifest_json))
133
+ expect(described_class.resolve_soft).to be_a(ClientManifest)
134
+ end
135
+ end
136
+
137
+ describe "production / non-development (untouched)" do
138
+ it "returns Ruact.manifest verbatim without any HTTP call" do
139
+ allow(Rails).to receive(:env).and_return(ActiveSupport::StringInquirer.new("production"))
140
+ allow(Net::HTTP).to receive(:start)
141
+ boot_manifest = ClientManifest.from_hash(manifest_hash)
142
+ allow(Ruact).to receive(:manifest).and_return(boot_manifest)
143
+
144
+ expect(described_class.resolve).to be(boot_manifest)
145
+ expect(Net::HTTP).not_to have_received(:start)
146
+ end
147
+
148
+ it "returns Ruact.manifest in the test environment too (no dev fetch)" do
149
+ allow(Rails).to receive(:env).and_return(ActiveSupport::StringInquirer.new("test"))
150
+ allow(Net::HTTP).to receive(:start)
151
+ boot_manifest = ClientManifest.from_hash(manifest_hash)
152
+ allow(Ruact).to receive(:manifest).and_return(boot_manifest)
153
+
154
+ expect(described_class.resolve).to be(boot_manifest)
155
+ expect(Net::HTTP).not_to have_received(:start)
156
+ end
157
+ end
158
+ end
159
+ end
data/spec/spec_helper.rb CHANGED
@@ -58,5 +58,20 @@ RSpec.configure do |config|
58
58
  # before every example so no leaked double survives — examples that need a
59
59
  # specific logger set their own in their own `before` (which runs after this).
60
60
  Rails.logger = Logger.new(IO::NULL) if defined?(Rails) && Rails.respond_to?(:logger=)
61
+
62
+ # Manifest-over-HTTP fix: the gem suite runs with `Rails.env == development`
63
+ # by default, where `Ruact::ManifestResolver` would otherwise fetch the live
64
+ # manifest from the Vite dev server (localhost:5173) on every ActionView
65
+ # render — making the suite hit the network and go flaky on a dev machine
66
+ # that happens to run a foreign Vite there. Pin the pre-fix behaviour (return
67
+ # the boot-loaded `Ruact.manifest`) so the whole suite is hermetic. The
68
+ # resolver's own spec re-stubs these `.and_call_original` to exercise the real
69
+ # HTTP/fallback paths with `Net::HTTP` mocked. (The programmatic
70
+ # `RenderPipeline#render({erb:})` path passes `registry: nil` and never
71
+ # touches the resolver, so the benchmark measures the real render cost.)
72
+ if defined?(Ruact::ManifestResolver)
73
+ allow(Ruact::ManifestResolver).to receive(:resolve) { Ruact.manifest }
74
+ allow(Ruact::ManifestResolver).to receive(:resolve_soft) { Ruact.manifest }
75
+ end
61
76
  end
62
77
  end
@@ -171,6 +171,26 @@ export default function ruact(options = {}) {
171
171
  configureServer(server) {
172
172
  const dir = path.resolve(root, componentsDir);
173
173
  server.watcher.add(dir);
174
+
175
+ // Serve the LIVE in-memory manifest over HTTP so the Rails gem can
176
+ // resolve components in dev without depending on the on-disk
177
+ // public/react-client-manifest.json — whose first write races Rails's
178
+ // boot-time read (Rails can boot, and config.to_prepare read the file,
179
+ // BEFORE Vite writes it → Ruact.manifest nil → 500 on the first request).
180
+ // This endpoint always reflects the current `manifest` (rebuilt by the
181
+ // watcher below + buildStart), and omits the internal `_sourceFile` field
182
+ // so the wire shape matches the file the gem already knows how to parse.
183
+ server.middlewares.use("/__ruact/manifest", (req, res, next) => {
184
+ if (req.method !== "GET") return next();
185
+ const out = {};
186
+ for (const [name, entry] of Object.entries(manifest)) {
187
+ const { _sourceFile, ...rest } = entry;
188
+ out[name] = rest;
189
+ }
190
+ res.setHeader("Content-Type", "application/json");
191
+ res.end(JSON.stringify(out));
192
+ });
193
+
174
194
  const rebuild = (file) => {
175
195
  if (!file.startsWith(dir)) return;
176
196
  manifest = buildManifest(dir);
@@ -0,0 +1,125 @@
1
+ // Fix (manifest-over-HTTP) — the dev server exposes the live in-memory manifest
2
+ // at GET /__ruact/manifest so the Rails gem can resolve components without
3
+ // racing the on-disk react-client-manifest.json write at boot. This file
4
+ // covers the configureServer middleware:
5
+ //
6
+ // 1. responds with the current manifest (no internal `_sourceFile` field);
7
+ // 2. reflects a rebuild when a "use client" component is added/changed.
8
+
9
+ import { describe, it, expect, beforeEach, afterEach } from "vitest";
10
+ import fs from "node:fs";
11
+ import os from "node:os";
12
+ import path from "node:path";
13
+ import ruact from "./index.js";
14
+
15
+ let dir;
16
+ beforeEach(() => {
17
+ dir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "ruact-manifest-http-")));
18
+ });
19
+ afterEach(() => {
20
+ fs.rmSync(dir, { recursive: true, force: true });
21
+ });
22
+
23
+ function write(rel, body) {
24
+ const full = path.join(dir, rel);
25
+ fs.mkdirSync(path.dirname(full), { recursive: true });
26
+ fs.writeFileSync(full, body);
27
+ return full;
28
+ }
29
+
30
+ const COMPONENT = (name) =>
31
+ `"use client";\nexport function ${name}() { return null; }\n`;
32
+
33
+ // A minimal connect-style server harness: captures the path-mounted middleware
34
+ // and the watcher callbacks the plugin registers in configureServer.
35
+ function makeServer() {
36
+ const middlewares = new Map();
37
+ const watcherHandlers = { change: [], add: [], unlink: [] };
38
+ return {
39
+ middlewares: { use: (route, fn) => middlewares.set(route, fn) },
40
+ moduleGraph: { getModuleById: () => null, invalidateModule: () => {} },
41
+ ws: { send: () => {} },
42
+ watcher: {
43
+ add: () => {},
44
+ on: (event, fn) => {
45
+ (watcherHandlers[event] ||= []).push(fn);
46
+ },
47
+ },
48
+ _invokeMiddleware(route, method = "GET") {
49
+ const fn = middlewares.get(route);
50
+ let body = "";
51
+ let nextCalled = false;
52
+ const headers = {};
53
+ const res = {
54
+ setHeader: (k, v) => {
55
+ headers[k] = v;
56
+ },
57
+ end: (chunk) => {
58
+ body = chunk;
59
+ },
60
+ };
61
+ fn({ url: route, method }, res, () => {
62
+ nextCalled = true;
63
+ });
64
+ return { body, headers, nextCalled };
65
+ },
66
+ _fireChange(file) {
67
+ for (const fn of watcherHandlers.change) fn(file);
68
+ },
69
+ };
70
+ }
71
+
72
+ async function bootPlugin() {
73
+ const plugin = ruact();
74
+ await plugin.configResolved({ root: dir });
75
+ await plugin.buildStart({});
76
+ const server = makeServer();
77
+ await plugin.configureServer(server);
78
+ return server;
79
+ }
80
+
81
+ describe("GET /__ruact/manifest (dev server middleware)", () => {
82
+ it("serves the current manifest as JSON without the internal _sourceFile field", async () => {
83
+ write("app/javascript/components/LikeButton.jsx", COMPONENT("LikeButton"));
84
+ const server = await bootPlugin();
85
+
86
+ const { body, headers } = server._invokeMiddleware("/__ruact/manifest");
87
+ expect(headers["Content-Type"]).toBe("application/json");
88
+
89
+ const manifest = JSON.parse(body);
90
+ expect(manifest).toHaveProperty("LikeButton");
91
+ expect(manifest.LikeButton).toMatchObject({
92
+ id: "/LikeButton.jsx",
93
+ name: "LikeButton",
94
+ chunks: ["/LikeButton.jsx"],
95
+ });
96
+ // The internal field used only during the prod build must never go on the wire.
97
+ expect(manifest.LikeButton).not.toHaveProperty("_sourceFile");
98
+ });
99
+
100
+ it("reflects a rebuild when a new component is added", async () => {
101
+ write("app/javascript/components/LikeButton.jsx", COMPONENT("LikeButton"));
102
+ const server = await bootPlugin();
103
+
104
+ expect(JSON.parse(server._invokeMiddleware("/__ruact/manifest").body)).not.toHaveProperty(
105
+ "CommentBox"
106
+ );
107
+
108
+ const added = write("app/javascript/components/CommentBox.jsx", COMPONENT("CommentBox"));
109
+ server._fireChange(added);
110
+
111
+ const manifest = JSON.parse(server._invokeMiddleware("/__ruact/manifest").body);
112
+ expect(manifest).toHaveProperty("LikeButton");
113
+ expect(manifest).toHaveProperty("CommentBox");
114
+ expect(manifest.CommentBox).not.toHaveProperty("_sourceFile");
115
+ });
116
+
117
+ it("falls through (calls next) for non-GET methods", async () => {
118
+ write("app/javascript/components/LikeButton.jsx", COMPONENT("LikeButton"));
119
+ const server = await bootPlugin();
120
+
121
+ const { body, nextCalled } = server._invokeMiddleware("/__ruact/manifest", "POST");
122
+ expect(nextCalled).toBe(true);
123
+ expect(body).toBe("");
124
+ });
125
+ });
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ruact
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.0.6
4
+ version: 0.0.7
5
5
  platform: ruby
6
6
  authors:
7
7
  - Luiz Garcia
@@ -85,6 +85,7 @@ files:
85
85
  - lib/ruact/flight/row_emitter.rb
86
86
  - lib/ruact/flight/serializer.rb
87
87
  - lib/ruact/html_converter.rb
88
+ - lib/ruact/manifest_resolver.rb
88
89
  - lib/ruact/query.rb
89
90
  - lib/ruact/railtie.rb
90
91
  - lib/ruact/render_context.rb
@@ -159,6 +160,7 @@ files:
159
160
  - spec/ruact/flight/serializer_spec.rb
160
161
  - spec/ruact/html_converter_spec.rb
161
162
  - spec/ruact/install_generator_spec.rb
163
+ - spec/ruact/manifest_resolver_spec.rb
162
164
  - spec/ruact/query_request_spec.rb
163
165
  - spec/ruact/query_spec.rb
164
166
  - spec/ruact/railtie_spec.rb
@@ -204,6 +206,7 @@ files:
204
206
  - vendor/javascript/vite-plugin-ruact/bootstrap.test.mjs
205
207
  - vendor/javascript/vite-plugin-ruact/index.js
206
208
  - vendor/javascript/vite-plugin-ruact/manifest-contract.test.mjs
209
+ - vendor/javascript/vite-plugin-ruact/manifest-http.test.mjs
207
210
  - vendor/javascript/vite-plugin-ruact/package-lock.json
208
211
  - vendor/javascript/vite-plugin-ruact/package.json
209
212
  - vendor/javascript/vite-plugin-ruact/registry.test.mjs