ruact 0.0.20 → 0.0.22

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.
Files changed (39) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +46 -1
  3. data/CONTRIBUTING.md +2 -2
  4. data/lib/generators/ruact/install/install_generator.rb +121 -19
  5. data/lib/generators/ruact/install/legacy/vite.config.pinned.js +28 -0
  6. data/lib/generators/ruact/install/templates/AGENTS.md.tt +9 -9
  7. data/lib/generators/ruact/install/templates/vite.config.js.tt +56 -21
  8. data/lib/ruact/build_skew.rb +164 -0
  9. data/lib/ruact/cached_blocks.rb +30 -0
  10. data/lib/ruact/client_manifest.rb +27 -1
  11. data/lib/ruact/controller/delivery.rb +24 -9
  12. data/lib/ruact/controller/streaming.rb +64 -0
  13. data/lib/ruact/controller.rb +21 -5
  14. data/lib/ruact/declared_content.rb +33 -0
  15. data/lib/ruact/deferred_render.rb +55 -9
  16. data/lib/ruact/doctor.rb +40 -6
  17. data/lib/ruact/erb_preprocessor.rb +8 -2
  18. data/lib/ruact/flight/react_element.rb +9 -4
  19. data/lib/ruact/flight/renderer.rb +4 -1
  20. data/lib/ruact/html_converter.rb +21 -3
  21. data/lib/ruact/markers.rb +11 -8
  22. data/lib/ruact/query_watch.rb +106 -0
  23. data/lib/ruact/railtie.rb +14 -0
  24. data/lib/ruact/render_context.rb +23 -11
  25. data/lib/ruact/render_pipeline.rb +3 -1
  26. data/lib/ruact/version.rb +1 -1
  27. data/lib/ruact/view_helper/suspense.rb +10 -2
  28. data/lib/ruact/view_helper.rb +56 -30
  29. data/lib/ruact.rb +8 -2
  30. data/vendor/javascript/vite-plugin-ruact/build-version.test.mjs +101 -0
  31. data/vendor/javascript/vite-plugin-ruact/index.js +39 -4
  32. data/vendor/javascript/vite-plugin-ruact/page-mount.test.mjs +10 -2
  33. data/vendor/javascript/vite-plugin-ruact/page-stream.test.mjs +29 -4
  34. data/vendor/javascript/vite-plugin-ruact/registry.test.mjs +7 -1
  35. data/vendor/javascript/vite-plugin-ruact/ruact-router.test.mjs +185 -0
  36. data/vendor/javascript/vite-plugin-ruact/runtime/dev-overlay.js +20 -6
  37. data/vendor/javascript/vite-plugin-ruact/runtime/page-stream.js +23 -3
  38. data/vendor/javascript/vite-plugin-ruact/runtime/ruact-router.js +64 -4
  39. metadata +8 -2
@@ -37,7 +37,19 @@ module Ruact
37
37
  # existing manifest + reader is unaffected). {#contract_for} reads it for the
38
38
  # Story 13.5 preprocess-time call-site validator; +nil+ means "no contract →
39
39
  # no validation" (fail open).
40
+ #
41
+ # Story 19-3 — the Vite plugin also records which ruact built the page's
42
+ # JavaScript, under a key no component can have (a component's is PascalCase
43
+ # or +folder/_snake+):
44
+ #
45
+ # "__ruact__": { "version": "0.0.22" }
46
+ #
47
+ # {#build_version} reads it; it is not a component, for any lookup. A manifest
48
+ # written by 0.0.21 or before has no such key.
40
49
  class ClientManifest
50
+ # The manifest key of the build's record (Story 19-3).
51
+ BUILD_KEY = "__ruact__"
52
+
41
53
  # The +chunks+ of every import row (see {#resolve}). One shared frozen
42
54
  # array: a render resolves each component once, and the allocation guard
43
55
  # counts every array.
@@ -129,6 +141,13 @@ module Ruact
129
141
  entry && entry["contract"]
130
142
  end
131
143
 
144
+ # Story 19-3 — the ruact version whose Vite plugin wrote this manifest, or
145
+ # +nil+ when it records none (a build by 0.0.21 or before, or a plugin that
146
+ # could not read its gem's version).
147
+ #
148
+ # @return [String, nil]
149
+ attr_reader :build_version
150
+
132
151
  # Load from a file path (JSON).
133
152
  # Pre-warms the reference cache and freezes the manifest so it cannot be
134
153
  # mutated at runtime (AC#5). Pre-warming is required because Ruby's freeze
@@ -138,7 +157,7 @@ module Ruact
138
157
  raw = File.read(path)
139
158
  data = JSON.parse(raw)
140
159
  manifest = from_hash(data)
141
- data.each_key { |name| manifest.reference_for(name) }
160
+ data.each_key { |name| manifest.reference_for(name) unless name == BUILD_KEY }
142
161
  manifest.freeze
143
162
  end
144
163
 
@@ -147,9 +166,16 @@ module Ruact
147
166
  # first-lookup path works even when +data+ is empty (otherwise
148
167
  # +reference_for+ would raise +FrozenError+ trying to memoize on a
149
168
  # frozen instance).
169
+ #
170
+ # Story 19-3 — the build's record ({BUILD_KEY}) is taken out of the entries
171
+ # (without changing the Hash given) and kept as {#build_version}.
150
172
  def self.from_hash(data)
151
173
  manifest = new
174
+ recorded = data.is_a?(Hash) && data.key?(BUILD_KEY)
175
+ build = data[BUILD_KEY] if recorded
176
+ data = data.except(BUILD_KEY) if recorded
152
177
  manifest.instance_variable_set(:@data, data)
178
+ manifest.instance_variable_set(:@build_version, build.is_a?(Hash) ? build["version"]&.to_s : nil)
153
179
  manifest.instance_variable_set(:@reference_cache, {})
154
180
  manifest
155
181
  end
@@ -18,15 +18,21 @@ module Ruact
18
18
 
19
19
  include DocumentRendering
20
20
 
21
- # The headers of a Flight body written row by row: no caching, and no
21
+ # The headers of a Flight body written row by row: not stored, and no
22
22
  # buffering by a proxy that honours `X-Accel-Buffering` (nginx).
23
+ # `no-store`, not `no-cache` (Story 19-2): a response the browser may
24
+ # store holds its cache entry while it streams, and Chrome then holds a
25
+ # second request for the same URL — a `revalidate()`, the same link
26
+ # clicked again — until the first one ends.
23
27
  STREAM_HEADERS = {
24
28
  "Content-Type" => "#{RequestKind::FLIGHT_MIME}; charset=utf-8",
25
- "Cache-Control" => "no-cache",
29
+ "Cache-Control" => "no-store",
26
30
  "X-Accel-Buffering" => "no"
27
31
  }.freeze
28
32
 
29
- # The same, for a document written in parts.
33
+ # The same, for a document written in parts — `no-cache`: `no-store`
34
+ # would keep the page out of the browser's back/forward cache, and the
35
+ # router stops a document it leaves while it still loads.
30
36
  DOCUMENT_STREAM_HEADERS = {
31
37
  "Content-Type" => "text/html; charset=utf-8",
32
38
  "Cache-Control" => "no-cache",
@@ -48,6 +54,13 @@ module Ruact
48
54
  # pushed before it, as they are ready.
49
55
  BODY_CLOSE = "</body>"
50
56
 
57
+ # Story 19-2 — written around the pushed rows of a streamed document, so
58
+ # the client router knows a document still loading has rows to come: it
59
+ # stops such a document when it leaves the page, and only such a one (a
60
+ # document that is still being parsed, its rows all in, is left alone).
61
+ STREAMING_OPEN = "(self.__FLIGHT_DATA=self.__FLIGHT_DATA||[]).streaming=true"
62
+ STREAMING_DONE = "self.__FLIGHT_DATA.streaming=false"
63
+
51
64
  private
52
65
 
53
66
  # Build the wire output and write it to the response. Nothing is written
@@ -86,9 +99,9 @@ module Ruact
86
99
 
87
100
  document = ruact_document_html(shell)
88
101
  cut = document.rindex(BODY_CLOSE) || document.length
89
- __ruact_stream(run, DOCUMENT_STREAM_HEADERS, document[0...cut], document[cut..]) do |rows|
90
- __ruact_flight_push(rows)
91
- end
102
+ open = "<script#{__ruact_nonce_attr}>"
103
+ __ruact_stream(run, DOCUMENT_STREAM_HEADERS, "#{document[0...cut]}#{open}#{STREAMING_OPEN}</script>",
104
+ "#{open}#{STREAMING_DONE}</script>#{document[cut..]}") { |rows| __ruact_flight_push(rows, open) }
92
105
  end
93
106
 
94
107
  # Writes +first+, then each boundary's rows (through the block, when given),
@@ -97,6 +110,8 @@ module Ruact
97
110
  # not sent stays pending, for the browser's transport to label.
98
111
  def __ruact_stream(run, headers, first, last = nil)
99
112
  headers.each { |name, value| response.headers[name] = value }
113
+ Streaming.keep_unbuffered(response.headers)
114
+ @__ruact_streamed = true
100
115
  stream = response.stream
101
116
  begin
102
117
  stream.write(first)
@@ -122,9 +137,9 @@ module Ruact
122
137
  # One boundary's rows, as the script a streamed document carries them in:
123
138
  # pushed onto the queue the bootstrap reads. Written as
124
139
  # {InlineScript.string_literal} writes it — no byte of the payload can
125
- # close the `<script>`.
126
- def __ruact_flight_push(rows)
127
- "<script>(self.__FLIGHT_DATA=self.__FLIGHT_DATA||[]).push(#{InlineScript.string_literal(rows)})</script>"
140
+ # close the `<script>`. +open+ carries the request's CSP nonce, if any.
141
+ def __ruact_flight_push(rows, open = "<script>")
142
+ "#{open}(self.__FLIGHT_DATA=self.__FLIGHT_DATA||[]).push(#{InlineScript.string_literal(rows)})</script>"
128
143
  end
129
144
 
130
145
  # Story 19-1 (D4) — has the client closed its end? Asked before each
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "securerandom"
4
+
3
5
  module Ruact
4
6
  module Controller
5
7
  # Story 19-1 (ADR D6, option A) — which actions send their page as it
@@ -110,6 +112,60 @@ module Ruact
110
112
  end
111
113
  end
112
114
 
115
+ # Story 19-2 (AC20) — said once per process, in every environment: an
116
+ # action declared with `ruact_streaming` got a request over HTTP/1.0, so
117
+ # Live gave it an ordinary response and the page was sent whole. nginx
118
+ # talks HTTP/1.0 to its upstream unless told otherwise, so in production
119
+ # this is a proxy's configuration, found here or not at all.
120
+ UNSTREAMED = "%<action>s streams (ruact_streaming or ActionController::Live), but Live gave it an " \
121
+ "ordinary response — a request over HTTP/1.0 — so the page was sent whole, not " \
122
+ "streamed. Behind nginx, set `proxy_http_version 1.1;` where it proxies to the app. " \
123
+ "(Rack::Test and controller tests get an ordinary response too.) (Said once.)"
124
+
125
+ # Logs {UNSTREAMED} the first time an action that streams could not.
126
+ #
127
+ # @api private
128
+ # @param controller [ActionController::Metal]
129
+ # @return [void]
130
+ def self.note_unstreamed(controller)
131
+ return if @unstreamed_noted
132
+
133
+ @unstreamed_noted = true
134
+ action = "#{controller.class.name}##{controller.action_name}"
135
+ controller.logger&.warn("[ruact] #{format(UNSTREAMED, action: action)}")
136
+ nil
137
+ end
138
+
139
+ # Forgets that it logged (specs).
140
+ #
141
+ # @api private
142
+ def self.reset_unstreamed_note!
143
+ @unstreamed_noted = false
144
+ end
145
+
146
+ # Story 19-2 (AC19.3) — Rack 2's `Rack::ETag` digests a 200 body unless
147
+ # the response has an `ETag` or a `Last-Modified`, reading it whole first
148
+ # (`Cache-Control: no-cache` does not stop it): a page that streams was
149
+ # held until its last `<Suspense>`. Measured with Rack 2.2.24. A weak ETag
150
+ # of its own, new for every response, so that no `If-None-Match` ever
151
+ # matches it. Rack 3 leaves a body it cannot hold in memory alone.
152
+ RACK_ETAG_BUFFERS = Gem::Version.new(::Rack.release) < Gem::Version.new("3")
153
+
154
+ # Sets that ETag on +headers+ when the app runs on Rack 2 — unless the
155
+ # action set its own validator (`fresh_when`), which already keeps
156
+ # `Rack::ETag` away and which conditional GET needs.
157
+ #
158
+ # @api private
159
+ # @param headers [Hash, ActionDispatch::Response::Headers]
160
+ # @return [void]
161
+ def self.keep_unbuffered(headers)
162
+ return unless RACK_ETAG_BUFFERS
163
+ return if headers.key?("ETag") || headers.key?("Last-Modified")
164
+
165
+ headers["ETag"] = %(W/"ruact-stream-#{SecureRandom.hex(8)}")
166
+ nil
167
+ end
168
+
113
169
  # Includes `ActionController::Live` in +controller+ for `ruact_streaming`,
114
170
  # marked as ruact's doing so {LiveInclusion} does not take it for the app's.
115
171
  #
@@ -221,6 +277,14 @@ module Ruact
221
277
  # support runs Live inline).
222
278
  def process_action(*)
223
279
  super
280
+ rescue StandardError => e
281
+ # Story 19-2 (AC6) — raised once the page was sent (an `after_action`):
282
+ # Live only logs what is raised after the response started, so the
283
+ # app's error reporting would never hear of it.
284
+ if @__ruact_streamed && defined?(Rails) && Rails.respond_to?(:error)
285
+ Rails.error.report(e, handled: false, severity: :error, source: "ruact.stream")
286
+ end
287
+ raise
224
288
  ensure
225
289
  __ruact_check_writes_after_stream
226
290
  __ruact_release_connections if __ruact_on_live_thread?
@@ -92,7 +92,7 @@ module Ruact
92
92
  # are only ever invoked internally by `ruact_html_shell`; the others only
93
93
  # by templates).
94
94
  private :ruact_js_assets, :ruact_head_assets, :__ruact_component__, :__ruact_component_open__,
95
- :__ruact_suspense__
95
+ :__ruact_suspense__, :__ruact_nonce_attr
96
96
 
97
97
  # Resolves the manifest for this render. In PRODUCTION this is the boot-time
98
98
  # cached +Ruact.manifest+ (set by Railtie#config.to_prepare) — no per-request
@@ -106,6 +106,15 @@ module Ruact
106
106
  ManifestResolver.resolve
107
107
  end
108
108
 
109
+ # Story 19-3 — {BuildSkew.note} for the manifest this render resolved: the
110
+ # message when its build is another ruact's (logged once per version),
111
+ # else nil.
112
+ def __ruact_build_skew(manifest)
113
+ return unless manifest.respond_to?(:build_version)
114
+
115
+ BuildSkew.note(manifest.build_version, logger: logger)
116
+ end
117
+
109
118
  # Only activate RSC rendering when the matching .html.erb template exists AND
110
119
  # the client will accept an HTML response (AC FR26; Story 10.0).
111
120
  # `request.format.html?` is false for a `*/*` wildcard (curl/bots/
@@ -177,16 +186,23 @@ module Ruact
177
186
  # its headers on the first row.
178
187
  self.status = status if status
179
188
 
180
- pipeline = RenderPipeline.new(ruact_manifest,
181
- controller_path: controller_path, logger: logger,
182
- development: Flight::Renderer.development_default)
189
+ manifest = ruact_manifest
190
+ # Story 19-3 — in development, the page says loudly when its JavaScript
191
+ # was built by another ruact (`ruact_js_assets` reads this). The manifest
192
+ # this render resolved anyway: no other request to the Vite dev server.
193
+ @ruact_build_skew = __ruact_build_skew(manifest) if Rails.env.development?
194
+ pipeline = RenderPipeline.new(manifest,
195
+ controller_path: controller_path, logger: logger,
196
+ development: Flight::Renderer.development_default)
183
197
  # Story 19-1 — in an action that streams, the first load and a
184
198
  # navigation alike, each `<Suspense>`'s content renders after the shell
185
199
  # is sent: the view keeps its blocks instead of running them. Only when
186
200
  # the response really is Live's: on HTTP/1.0 (nginx's default to its
187
201
  # upstream) Live gives an ordinary response, which the server sends as
188
202
  # soon as anything is written — the page then renders whole, in place.
189
- streaming = self.class.ruact_streaming?(action_name) && response.is_a?(ActionController::Live::Response)
203
+ declared = self.class.ruact_streaming?(action_name)
204
+ streaming = declared && response.is_a?(ActionController::Live::Response)
205
+ Streaming.note_unstreamed(self) if declared && !streaming
190
206
 
191
207
  # Allocate a per-render context and expose it to the view via a normal
192
208
  # (non-`@_`-prefixed) instance variable on the controller. Rails 8's
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ruact
4
+ # Story 19-2 (AC10) — in development, `content_for`/`provide` inside a
5
+ # `<Suspense>` that runs after the shell is an error of that block: the layout
6
+ # was sent before the block ran, so nothing it declares shows. What the view
7
+ # had declared is taken before the block and compared after.
8
+ #
9
+ # Internal API: not part of the public compatibility contract.
10
+ module DeclaredContent
11
+ # What +view+ has declared so far, or nil when it keeps no record.
12
+ def self.take(view)
13
+ return nil unless view.respond_to?(:view_flow) && view.view_flow.respond_to?(:content)
14
+
15
+ # Copies: the record keeps a SafeBuffer per name, which `content_for`
16
+ # appends to in place (and whose `to_s` is itself).
17
+ view.view_flow.content.transform_values { |content| String.new(content.to_s) }
18
+ end
19
+
20
+ # Raises when +view+ declared more than +before+. A name only read
21
+ # (`content_for?`, `yield :name`) appears in the record empty: Rails'
22
+ # record defaults every name it is asked for to "".
23
+ def self.check!(view, before)
24
+ after = view.view_flow.content
25
+ changed = after.keys.reject { |name| name == :layout || after[name].to_s == before.fetch(name, "") }
26
+ return if changed.empty?
27
+
28
+ raise Ruact::Error,
29
+ "#{changed.map { |name| "content_for :#{name}" }.join(', ')} inside a <Suspense> runs after the " \
30
+ "layout was sent, so the layout never shows it. Move it outside the <Suspense>."
31
+ end
32
+ end
33
+ end
@@ -99,14 +99,24 @@ module Ruact
99
99
  def content(element)
100
100
  return element unless element.is_a?(Flight::DeferredContent)
101
101
 
102
- block = @render_context.block(element.index)
102
+ block = @render_context.block(element.key)
103
+ raise Ruact::Error, Flight::DeferredContent::STALE if block.nil?
104
+
103
105
  html = @guard ? @guard.call { evaluate(block) } : evaluate(block)
104
106
  convert(html)
105
107
  end
106
108
 
107
- # Runs the block in its view, with the template path restored, and returns
108
- # what it wrote. Only what it writes: a value it ends on is not content.
109
+ # Runs the block in its view, with the template path and its local
110
+ # variables as they were when the tag was met, and returns what it wrote.
111
+ # Only what it writes: a value it ends on is not content.
109
112
  def evaluate(pending)
113
+ declared = DeclaredContent.take(pending.view) if @development
114
+ html = with_locals(pending.block, pending.locals) { run_in_view(pending) }
115
+ DeclaredContent.check!(pending.view, declared) if declared
116
+ html
117
+ end
118
+
119
+ def run_in_view(pending)
110
120
  pending.view.instance_exec(pending) do |entry|
111
121
  saved = [@virtual_path, @current_template]
112
122
  @virtual_path = entry.virtual_path
@@ -122,6 +132,22 @@ module Ruact
122
132
  end
123
133
  end
124
134
 
135
+ # Sets the block's local variables to +locals+ while it runs, and back
136
+ # after. A block shares them with the template and with the blocks met
137
+ # after it.
138
+ def with_locals(block, locals)
139
+ return yield if locals.nil? || locals.empty?
140
+
141
+ scope = block.binding
142
+ now = locals.keys.to_h { |name| [name, scope.local_variable_get(name)] }
143
+ locals.each { |name, value| scope.local_variable_set(name, value) }
144
+ begin
145
+ yield
146
+ ensure
147
+ now.each { |name, value| scope.local_variable_set(name, value) }
148
+ end
149
+ end
150
+
125
151
  # The components registered since the last conversion are resolved and
126
152
  # added: a block registers its own as it runs. One the manifest cannot
127
153
  # resolve fails this conversion and keeps its place (nil), so the next
@@ -153,16 +179,31 @@ module Ruact
153
179
  raise Expired, "the delay (#{delay}s) is longer than suspense_timeout" if delay && delay > limit
154
180
 
155
181
  started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
182
+ # A query still running at the limit is cancelled on the server, so its
183
+ # connection is free again (Story 19-2, QueryWatch).
184
+ owner = QueryWatch.current_owner
185
+ watch = QueryWatch.start(owner, limit)
156
186
  begin
157
187
  Timeout.timeout(limit) { wait_then(delay, &) }
158
- rescue Timeout::Error => e
159
- # Ours only when the limit has passed; earlier, it is the content's.
160
- raise if Process.clock_gettime(Process::CLOCK_MONOTONIC) - started < limit
188
+ rescue StandardError => e
189
+ raise unless expired?(e, started, limit, watch)
161
190
 
191
+ watch&.cancel_now(owner)
162
192
  raise Expired, "ran past suspense_timeout (#{limit}s)", cause: e
193
+ ensure
194
+ watch&.stop
163
195
  end
164
196
  end
165
197
 
198
+ # A `Timeout::Error` is ours only when the limit has passed; earlier, it is
199
+ # the content's. Any other error is ours when the watch cancelled a query
200
+ # at the limit: what the cancelled query raised.
201
+ def expired?(error, started, limit, watch)
202
+ return watch&.cancelled? || false unless error.is_a?(Timeout::Error)
203
+
204
+ Process.clock_gettime(Process::CLOCK_MONOTONIC) - started >= limit
205
+ end
206
+
166
207
  def wait_then(delay)
167
208
  sleep(delay) if delay&.positive?
168
209
  yield
@@ -181,11 +222,16 @@ module Ruact
181
222
  payload
182
223
  end
183
224
 
184
- # React's development client reads `stack` as `[function, file, line, column]`.
225
+ # React's development client reads each frame of `stack` as
226
+ # `[function, file, line, column, enclosing line, enclosing column]` and
227
+ # rebuilds the server's call stack from it. A four-element frame (what this
228
+ # sent before Story 19-2) left the last two undefined, and every frame came
229
+ # out at line 2. Ruby has no column and no enclosing function: column 1,
230
+ # enclosing 0 (none).
185
231
  def development_stack(error)
186
232
  Array(error.backtrace).first(STACK_FRAMES).filter_map do |frame|
187
233
  match = frame.match(/\A(.+?):(\d+)(?::in [`'](.+)')?\z/)
188
- match && [match[3] || "(ruby)", match[1], Integer(match[2], 10), 0]
234
+ match && [match[3] || "(ruby)", match[1], Integer(match[2], 10), 1, 0, 0]
189
235
  end
190
236
  end
191
237
 
@@ -201,7 +247,7 @@ module Ruact
201
247
  element = boundary[:element]
202
248
  return "" unless element.is_a?(Flight::DeferredContent)
203
249
 
204
- location = @render_context.block(element.index).location
250
+ location = @render_context.block(element.key)&.location
205
251
  location ? " at #{location.path}:#{location.lineno}" : ""
206
252
  end
207
253
  end
data/lib/ruact/doctor.rb CHANGED
@@ -145,14 +145,25 @@ module Ruact
145
145
 
146
146
  private
147
147
 
148
+ # Story 19-3 — the manifest also says which ruact built the JavaScript; a
149
+ # build by another one (or by one that recorded none, 0.0.21 and before)
150
+ # fails: the page then runs another release's runtime against this server.
148
151
  def check_manifest
149
152
  path = manifest_path
150
- if Pathname(path).exist?
151
- [:pass, "Manifest found at #{path}"]
152
- else
153
- [:fail, "Manifest not found — run vite build",
154
- "Run vite build (or bin/dev) to generate the client manifest."]
153
+ unless Pathname(path).exist?
154
+ return [:fail, "Manifest not found — run vite build",
155
+ "Run vite build (or bin/dev) to generate the client manifest."]
155
156
  end
157
+
158
+ built = ClientManifest.from_hash(JSON.parse(File.read(path))).build_version
159
+ return [:pass, "Manifest found at #{path} (built by ruact #{built})"] unless BuildSkew.skewed?(built)
160
+
161
+ by = built ? "ruact #{built}" : "a ruact that did not record its version (0.0.21 or before)"
162
+ [:fail, "Manifest at #{path} was built by #{by}; this app runs ruact #{Ruact::VERSION}",
163
+ BuildSkew.remedy(Rails.root)]
164
+ rescue JSON::ParserError => e
165
+ [:fail, "Manifest at #{path} is not valid JSON (#{e.message.lines.first.strip})",
166
+ "Rebuild it: run vite build (or bin/dev)."]
156
167
  end
157
168
 
158
169
  def check_vite
@@ -484,7 +495,30 @@ module Ruact
484
495
  def check_streaming
485
496
  mode = Ruact.streaming_mode || :buffered
486
497
  label = mode == :enabled ? "enabled" : "buffered"
487
- [:pass, "streaming: #{label} (#{streaming_server_hint})"]
498
+ message = "streaming: #{label} (#{streaming_server_hint})"
499
+ declaring = streaming_controllers.length
500
+ return [:pass, message] if declaring.zero?
501
+
502
+ # Story 19-2 (AC20) — the doctor sees no request: what it can say is
503
+ # where a page that streams silently does not.
504
+ [:pass, "#{message}; #{pluralize_count(declaring, 'controller')} #{declaring == 1 ? 'streams' : 'stream'} " \
505
+ "(ruact_streaming or ActionController::Live) — behind a proxy, the app must be reached over " \
506
+ "HTTP/1.1 (nginx: `proxy_http_version 1.1;`), or those pages are sent whole",
507
+ PROXY_HTTP11]
508
+ end
509
+
510
+ # Story 19-2 (AC20) — what makes a proxy talk HTTP/1.1 to the app.
511
+ PROXY_HTTP11 = "Behind nginx, set `proxy_http_version 1.1;` where it proxies to the app: by default it " \
512
+ "talks HTTP/1.0 upstream, and a page declared with ruact_streaming is then sent whole " \
513
+ "(the app logs it once)."
514
+ private_constant :PROXY_HTTP11
515
+
516
+ # Controller files that declare `ruact_streaming` or include
517
+ # `ActionController::Live`. Reads source, as {#island_controllers} does.
518
+ def streaming_controllers
519
+ Dir.glob(Rails.root.join("app", "controllers", "**", "*.rb").to_s).select do |file|
520
+ File.read(file).match?(/^\s*(ruact_streaming\b|include\s+ActionController::Live\b)/)
521
+ end
488
522
  end
489
523
 
490
524
  # Story 19-1 (ADR D1) — whether `load_async` runs its query in the
@@ -14,7 +14,7 @@ module Ruact
14
14
  # <%= __ruact_component__("LikeButton", { "postId" => @post.id, "initialCount" => 5 }) %>
15
15
  #
16
16
  # The placeholder is replaced by an HTML comment with a unique token:
17
- # <!-- __RUACT_0__ -->
17
+ # <!-- __RUACT_a1b2c3d4_0__ -->
18
18
  #
19
19
  # A component with children (Story 18-2) keeps its content as ERB, inside a
20
20
  # wrapper element HtmlConverter turns into the component's `children`:
@@ -399,7 +399,13 @@ module Ruact
399
399
  props_ruby = pairs.map { |prop, expr| "#{prop.inspect} => #{expr}" }.join(", ")
400
400
  props_hash = props_ruby.empty? ? "{}" : "{ #{props_ruby} }"
401
401
  helper = kind == :self_closing ? "__ruact_component__" : "__ruact_component_open__"
402
- %(<%= #{helper}(#{name.inspect}, #{props_hash}) %>)
402
+ # Story 19-2 — a tag written over several lines keeps them, so every
403
+ # line below it is still the template's own in an error and a backtrace
404
+ # (the props' own code carries the line breaks it has). Nothing is
405
+ # allocated for a tag on one line: the guard on allocations counts
406
+ # every component.
407
+ missing = match.count("\n") - props_hash.count("\n")
408
+ %(<%= #{helper}(#{name.inspect}, #{props_hash}) #{"\n" * missing if missing.positive?}%>)
403
409
  rescue ComponentContractError, ComponentTagError, UnknownComponentError
404
410
  # Already carry file:line and the fix — re-raise AS-IS (do NOT append the
405
411
  # generic "at line N: snippet" tail).
@@ -31,14 +31,19 @@ module Ruact
31
31
  end
32
32
 
33
33
  # The content of a Suspense boundary that has not been rendered yet: the
34
- # index of its block in the render context. The serializer never sees one —
34
+ # key of its block in the render context. The serializer never sees one —
35
35
  # it defers a boundary's children without looking at them, and the caller
36
36
  # of {Renderer#take_boundary} renders it.
37
37
  class DeferredContent
38
- attr_reader :index
38
+ # Story 19-2 — what a boundary says when its marker came out of a `cache`
39
+ # block another render wrote: its content was never in the fragment.
40
+ STALE = "A <Suspense> inside `cache` was read from the cache, and its content is not in the " \
41
+ "cached fragment: put the <Suspense> outside the `cache` block (cache what is inside it)."
39
42
 
40
- def initialize(index)
41
- @index = index
43
+ attr_reader :key
44
+
45
+ def initialize(key)
46
+ @key = key
42
47
  end
43
48
  end
44
49
 
@@ -85,8 +85,11 @@ module Ruact
85
85
  # a {DeferredContent} needs {#take_boundary}.
86
86
  def next_boundary(streaming:)
87
87
  boundary = take_boundary
88
+ # A block this render cannot run: its marker came out of a `cache`
89
+ # block a render that streamed wrote.
88
90
  if boundary[:element].is_a?(DeferredContent)
89
- raise Ruact::Error, "ruact: Renderer#next_boundary cannot render a block; use #take_boundary"
91
+ return error_rows(boundary[:id],
92
+ RowEmitter.error_payload(DeferredContent::STALE, digest: "ruact:suspense-error"))
90
93
  end
91
94
 
92
95
  if streaming && boundary[:delay]&.positive?
@@ -8,7 +8,7 @@ module Ruact
8
8
  # Rules:
9
9
  # - HTML attributes → React equivalents (class→className, for→htmlFor, etc.)
10
10
  # - data-react-key="x" → becomes the React key on the element
11
- # - HTML comments matching __RUACT_N__ tokens → replaced by client component refs
11
+ # - HTML comments matching __RUACT_<render>_<n>__ tokens → replaced by client component refs
12
12
  # - Text nodes → plain Ruby strings
13
13
  # - Multiple root nodes → wrapped in a Fragment (array)
14
14
  # rubocop:disable Metrics/ClassLength
@@ -35,6 +35,11 @@ module Ruact
35
35
  "spellcheck" => "spellCheck"
36
36
  }.freeze
37
37
 
38
+ # A client component's marker (`__RUACT_<render>_<n>__`, the render's id
39
+ # being eight hex digits, {RenderContext}), or one 0.0.20 and before wrote
40
+ # (`__RUACT_<n>__`), which a `cache` block may still hold.
41
+ TOKEN = /\A__RUACT_(?:\h{8}_)?\d+__\z/
42
+
38
43
  # Tags whose `value` attribute maps to React's `defaultValue` (uncontrolled).
39
44
  DEFAULT_VALUE_TAGS = %w[textarea select].freeze
40
45
 
@@ -167,7 +172,11 @@ module Ruact
167
172
  # Check if this is a Ruact component placeholder
168
173
  token = Markers.comment_token(node)
169
174
  entry = @registry[token]
170
- return nil unless entry
175
+ unless entry
176
+ raise Ruact::HtmlConverterError, stale_component_message(token) if token.match?(TOKEN)
177
+
178
+ return nil
179
+ end
171
180
 
172
181
  Flight::ReactElement.new(
173
182
  type: entry[:ref],
@@ -200,7 +209,7 @@ module Ruact
200
209
  def convert_component_with_children(node)
201
210
  token = Markers.wrapper_token(node)
202
211
  entry = @registry[token]
203
- raise Ruact::HtmlConverterError, "ruact: no component registered for #{token.inspect}" unless entry
212
+ raise Ruact::HtmlConverterError, stale_component_message(token) unless entry
204
213
 
205
214
  props = entry[:props]
206
215
  children = convert_children(node)
@@ -210,6 +219,15 @@ module Ruact
210
219
  Flight::ReactElement.new(type: entry[:ref], key: nil, props: props)
211
220
  end
212
221
 
222
+ # Story 19-2 — a marker this render did not write: it came out of a
223
+ # `cache` block, which stores a component's marker and not the component.
224
+ def stale_component_message(token)
225
+ "ruact: the page holds the marker of a client component (#{token}) that this render did not " \
226
+ "register — it came out of a `cache` block, which stores the component's marker but not the " \
227
+ "component. Move the client component outside the `cache` block (cache the HTML around it, or " \
228
+ "the data it is given)."
229
+ end
230
+
213
231
  def convert_suspense_element(node)
214
232
  fallback_text = Markers.suspense_fallback(node)
215
233
  fallback = if fallback_text.empty?
data/lib/ruact/markers.rb CHANGED
@@ -8,14 +8,18 @@ module Ruact
8
8
  # Three markers:
9
9
  #
10
10
  # - a client component: an HTML comment holding the token its
11
- # {RenderContext} entry was registered under — `<!-- __RUACT_0__ -->`;
11
+ # {RenderContext} entry was registered under — `<!-- __RUACT_a1b2c3d4_0__ -->`;
12
12
  # - a client component with children: an element wrapped around the
13
- # children's HTML — `<ruact-component data-ruact-token="__RUACT_0__">…`;
13
+ # children's HTML — `<ruact-component data-ruact-token="__RUACT_a1b2c3d4_0__">…`;
14
14
  # - a Suspense boundary: an element wrapped around its content, carrying the
15
15
  # fallback text and the optional delay —
16
16
  # `<ruact-suspense data-ruact-fallback="Loading…">…</ruact-suspense>`. A
17
- # boundary whose content is rendered later is empty and carries the index
18
- # of its block in the {RenderContext} instead (`data-ruact-block="0"`).
17
+ # boundary whose content is rendered later is empty and carries the key
18
+ # of its block in the {RenderContext} instead (`data-ruact-block="a1b2c3d4-0"`).
19
+ #
20
+ # Every token and key carries the id of the render that wrote it, so a
21
+ # marker a `cache` block stored in another render is never taken for one of
22
+ # this render's.
19
23
  #
20
24
  # {ErbPreprocessor} writes the ERB that produces them, the view helpers
21
25
  # ({ViewHelper}, and {RenderPipeline} for a plain-ERB render) write the
@@ -49,7 +53,7 @@ module Ruact
49
53
  # attribute values as written in the template. Only `"` is escaped: the
50
54
  # rest of the text stays as the template wrote it (entities included, which
51
55
  # the HTML parser then decodes). The preprocessor refuses ERB in either.
52
- # +block+ is the index of the boundary's content in the render context,
56
+ # +block+ is the key of the boundary's content in the render context,
53
57
  # when that content is rendered later.
54
58
  def self.suspense_open(fallback, delay, block: nil)
55
59
  delay_attr = delay ? %( #{DELAY_ATTR}="#{delay.gsub('"', '&quot;')}") : ""
@@ -72,11 +76,10 @@ module Ruact
72
76
  node[FALLBACK_ATTR] || ""
73
77
  end
74
78
 
75
- # The index of the block a Suspense wrapper's content is rendered from
79
+ # The key of the block a Suspense wrapper's content is rendered from
76
80
  # later, or nil when the content is in the wrapper.
77
81
  def self.suspense_block(node)
78
- value = node[BLOCK_ATTR]
79
- value && Integer(value, 10)
82
+ node[BLOCK_ATTR]
80
83
  end
81
84
 
82
85
  # The delay of a Suspense wrapper, in seconds, or nil when it is absent,