ruact 0.0.20 → 0.0.21

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: 69c5f93d5a39e751e7f69b953ffd1101660e464df4397cadee932c12ba81b4fd
4
- data.tar.gz: 518c3a0c1183cbc82fd13ca3d6d621502e28713b5d9dd21bd46beeca30ecc775
3
+ metadata.gz: e96181a35f53a1a25da4cd4db1ca655aeff15cdd5190f56aaafb97f1a2a19361
4
+ data.tar.gz: e9034d5a9f16dee7a5a319483364dbbf049a6b0aecf63073c3efcfe66ce6baa4
5
5
  SHA512:
6
- metadata.gz: 115756074639ab4849ae2982cf7d4e13176f20f383302f8961b1ed5f09148e1bec74560f2e1f42ef2aa26262449d6c0536c89ce205748176bad9aa0f5006fbfb
7
- data.tar.gz: c55a816d7f40810470e42f86f8111ae3fc2e1e4ab150a31153626c26dedb8de1f76166483acb004722239d1696626bbe0bef218237bb34b665da7320e7e200c2
6
+ metadata.gz: c90a2671ac0774bd51f726af1dc22ab58ba048ee6354b1078888ad2704289214caa6b6189b8909c72ab074546a37dc80676fb49576e630d09d1629eb09ff22ba
7
+ data.tar.gz: e71ea90b4a5ee1450fd5c772fe7a99c9641b5febb8465ea0e7649fc1ab00db670fcdd3333b34bffbcc870bb0d719deb9b236f40769e6277097f0a3cdaa0bc115
data/CHANGELOG.md CHANGED
@@ -7,6 +7,32 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.0.21] - 2026-10-15
11
+
12
+ ### Changed
13
+
14
+ - **Leaving a page whose document is still arriving stops loading it.** A page that streams keeps its document open until its last `<Suspense>`; when the router leaves the page (a link, a form) before that, it now calls `window.stop()` first, so the connection closes and the server, which asks before each block, starts no further block for a page nobody will see. Only a document that streams and still has rows to come: the server marks it. Stopping it also stops what that document was still loading — measured in Chrome: an image still loading stays broken, a `fetch` in flight fails with `TypeError`, and a module `import()` in flight fails (importing it again works). The boundaries of the page being left stay quiet, as for a navigation that replaced another: no "response ended before the whole page arrived" error is reported for them. Only while the document itself is loading — a page that has finished is not touched. `revalidate()` stays on the page and does not stop it: stopping would fail the page's other requests in flight, a server function or a `useQuery` among them. When the navigation that stopped it ends without replacing the page — the network, a 403 or a 500 to a form, a redirect that fails, or a second click that superseded the first and failed — the router fetches the page it is still on again, once, so its boundaries do not keep their fallback.
15
+ - **A link or Back/Forward to an `#anchor` of the page on screen is left to the browser.** The client router fetched and rendered the same page again for `/posts/1#comments` while on `/posts/1`; now the browser scrolls, nothing is fetched, and a page still streaming is not stopped. A link to another page with an anchor is the router's, as before.
16
+ - **`content_for` inside a `<Suspense>` that runs later is an error of that boundary, in development.** The layout was sent before the block ran, so what it declared never showed. Reading (`content_for?`, `yield :name`) still works.
17
+ - **The development overlay shows every `<Suspense>` that failed**, one under the other, instead of only the last; a click closes it, and the next failure opens it again.
18
+ - **A streamed Flight response is `Cache-Control: no-store`** (it was `no-cache`). A response the browser may store holds its cache entry while it streams, and Chrome then held a second request for the same URL — `revalidate()` on a page still streaming, the same link clicked again — until the first one ended. The streamed document keeps `no-cache`, so the page can still enter the back/forward cache.
19
+
20
+ - **An action declared with `ruact_streaming` that got a request over HTTP/1.0 now says so.** Live answers HTTP/1.0 with an ordinary response, so the page is sent whole — and nginx talks HTTP/1.0 to the app unless told `proxy_http_version 1.1;`, so in production a page that should stream silently did not. The app now logs a warning the first time it happens in the process, in every environment, naming the action and the nginx line (Rack::Test, in an app's request specs, speaks HTTP/1.0 too, so it shows there once). `rails ruact:doctor` (and `--json`) adds the same hint to its streaming check when a controller declares `ruact_streaming` or includes `ActionController::Live`.
21
+
22
+ - **Every script ruact writes carries the request's CSP nonce.** An app whose Content Security Policy allows scripts by nonce (`config.content_security_policy_nonce_generator`, no `'unsafe-inline'`) blocked the inline `__FLIGHT_DATA` script, the development module that loads Vite, and — on a page that streams — each `<Suspense>`'s rows: the page never mounted, or its fallbacks never resolved. The nonce is now on all of them, and on the entry `<script src>`. Without a nonce generator not a byte changes. In development React's own Flight client tries `eval()` for its debugging features and goes on without it; a policy without `'unsafe-eval'` reports that refusal in development only.
23
+
24
+ - **The `AGENTS.md` that `rails generate ruact:install` writes names what a `<Suspense>` that streams cannot hold.** Besides status, redirect, cookies, session and flash: `content_for`, and the locale, tenant or database role a middleware scopes (they belong in an `around_action`); and a `cache` block goes inside a `<Suspense>`, never around one.
25
+
26
+ ### Fixed
27
+
28
+ - **On Rack 2, a page that streams was held until its last `<Suspense>`.** Rack 2's `Rack::ETag` (in every Rails app's middleware) reads a 200 body whole to digest it, unless the response already has an `ETag` or a `Last-Modified` — `Cache-Control: no-cache` does not stop it, contrary to what ruact assumed. Rails 7.2 through 8.1 accept Rack 2.2, so an app on it got no streaming at all: no shell, no fallback, the page at the end. On Rack 2 a streamed response now carries a weak `ETag` of its own, new for every response, so no `If-None-Match` ever matches it — unless the action set its own `ETag` or `Last-Modified` (`fresh_when`), which already keeps `Rack::ETag` away and is left as it is. Measured with Rack 2.2.24. Rack 3 is unchanged.
29
+ - **A client component or a `<Suspense>` inside a `cache` block no longer comes back as another one.** A fragment cache stores the HTML a render produced, with the markers that render gave its components and blocks; read back in another render, a marker could name one of *that* render's components (the page showed the wrong component, with its props) or another boundary's block (a `<Suspense>` showed another one's content). Every marker now carries the id of the render that wrote it. A client component read from the cache raises `Ruact::HtmlConverterError`, naming the `cache` and what to do (keep the component outside it); a `<Suspense>` read from the cache gets an error row for that boundary only, saying the same, and the rest of the page renders; the server logs it and reports it to `Rails.error` (`source: "ruact.suspense"`), whether the page streams or not. A marker cached by 0.0.20 or before, read after the upgrade, is taken for one of another render too.
30
+ - **A `<Suspense>` that runs later sees its local variables as they were at the tag.** In an action that streams, the block ran after the rest of the template and read a local as it was then: every block of a `for` loop showed the last item, and a local reassigned below the tag showed the new value. Locals are now kept when the tag is met and given back to the block when it runs. What is kept is the variable, not a copy of its object: a list appended to below the tag shows the item appended. A local an earlier `<Suspense>` reassigns is not seen by the ones after it. Instance variables are still read when the block runs.
31
+ - **A query still running when a `<Suspense>` passes `suspense_timeout` is cancelled (PostgreSQL).** The timeout gave the response back, but the query went on running on the server, and its connection — back in the pool — made the next query on it wait until it ended (seconds). At the limit ruact now cancels the queries in flight on the connections the block holds or just gave back (`PG::Connection#cancel`; the block's fiber under `isolation_level = :fiber`); the connection is free again at once, and the boundary gets the timeout. A query `load_async` started is not cancelled: the async executor's thread holds its connection. Other adapters are unchanged. For those queries, and with any other adapter, the database's statement timeout stays the limit.
32
+ - **Error lines below a component tag written over several lines are right.** The preprocessor put a multi-line tag on one line, so Rails named a line too early for any error below it (an error page, a backtrace) — since component tags existed. The tag's line breaks are kept.
33
+ - **The development overlay's backtrace names the right template lines.** React's development client reads each server frame as six fields; ruact sent four, and every frame came out at line 2.
34
+ - **An exception raised after a streamed page was sent (an `after_action`) reaches `Rails.error`.** `ActionController::Live` only logs what is raised once the response has started, so an app's error reporting never heard of it; ruact reports it (`source: "ruact.stream"`). The page in the browser is unchanged.
35
+
10
36
  ## [0.0.20] - 2026-10-14
11
37
 
12
38
  ### Added
@@ -518,7 +544,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
518
544
 
519
545
  - **E2E test app** — `e2e/` Rails app (no DB, in-memory Post model) with full CRUD system tests validating the complete request cycle.
520
546
 
521
- [Unreleased]: https://github.com/luizcg/ruact/compare/v0.0.20...HEAD
547
+ [Unreleased]: https://github.com/luizcg/ruact/compare/v0.0.21...HEAD
548
+ [0.0.21]: https://github.com/luizcg/ruact/releases/tag/v0.0.21
522
549
  [0.0.20]: https://github.com/luizcg/ruact/releases/tag/v0.0.20
523
550
  [0.0.19]: https://github.com/luizcg/ruact/releases/tag/v0.0.19
524
551
  [0.0.18]: https://github.com/luizcg/ruact/releases/tag/v0.0.18
@@ -78,14 +78,15 @@ READ IT instead of simulating the name generation. Regenerate after routes/queri
78
78
  `<Card title={@t}>...</Card>` passes the ERB between its tags to the
79
79
  component as `children`. An unclosed or stray tag fails LOUDLY with a
80
80
  `PreprocessorError` naming file:line. `<Suspense fallback="...">` pairs too;
81
- under `ruact_streaming` its content runs AFTER the page is sent, so status,
82
- redirect, cookies, session and flash belong in the controller, not inside it.
81
+ under `ruact_streaming` its content runs AFTER the page is sent: status,
82
+ redirect, cookies, session, flash, `content_for` and locale/tenant/DB-role
83
+ scopes belong in the controller (`around_action`), not inside it or in a
84
+ middleware; a `cache` block goes inside a `<Suspense>`, never around one.
83
85
  In a loop of items holding components, give each item a `data-react-key`
84
86
  (the record's id): React matches them by position otherwise, silently.
85
87
  2. **`{}` props are Ruby, not JavaScript.** `<Badge label={@post.title} />`
86
- evaluates `@post.title` as a Ruby expression in the ERB. No JS ternaries,
87
- no `{...spread}`. A quoted value (`title="Hi"`) is a
88
- plain string and a bare attribute is `true`, as in JSX.
88
+ evaluates `@post.title` as a Ruby expression: no JS ternaries, no `{...spread}`.
89
+ A quoted value (`title="Hi"`) is a plain string; a bare attribute is `true`.
89
90
  3. **One action, two response shapes.** A `Ruact::Server` non-GET action
90
91
  answers JSON — its instance variables, or `204`, or `{"$redirect": path}` —
91
92
  when the request's `Accept` header is exactly `application/json`, which is
@@ -144,8 +145,7 @@ duration, or a deliberate `expires_in: nil` for a non-expiring token.
144
145
  - `bin/rails ruact:routes -- --json` — the accessor/route table (each accessor's
145
146
  name, kind, verb, path, params) as JSON, from the same route table codegen
146
147
  reads; bare `ruact:routes` prints a compact human table.
147
- - Both `--json` outputs are EXPERIMENTAL — the shape may change; gate on their
148
- `schema_version` field (currently `0`), do not treat it as a stable contract.
148
+ - Both `--json` outputs are EXPERIMENTAL: gate on `schema_version` (now `0`).
149
149
  - `bin/rails ruact:server_functions:generate` — regenerates the TS module;
150
150
  exits 1 on a naming collision or an invalid name.
151
151
  - `bin/dev` — boots Rails AND Vite (both required: Vite serves the client components).
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ruact
4
+ # Story 19-2 — a render that renders `<Suspense>` content in place writes no
5
+ # block marker: one in its HTML came out of a `cache` block a render that
6
+ # streamed wrote. Its boundary gets an error row ({Flight::Renderer}); this
7
+ # says so on the server, as {DeferredRender} does for a render that streams.
8
+ #
9
+ # Internal API: not part of the public compatibility contract.
10
+ module CachedBlocks
11
+ # Only the attribute as written in a tag (`="`): text that mentions it is
12
+ # escaped.
13
+ MARKER = %( #{Markers::BLOCK_ATTR}=").freeze
14
+ private_constant :MARKER
15
+
16
+ # Logs and reports a block marker in +html+.
17
+ #
18
+ # @param html [String]
19
+ # @param logger [Logger, nil]
20
+ # @return [void]
21
+ def self.report(html, logger)
22
+ return unless html.include?(MARKER)
23
+
24
+ error = Ruact::Error.new(Flight::DeferredContent::STALE)
25
+ logger&.error("[ruact] #{error.message}")
26
+ Rails.error.report(error, handled: true, severity: :error, source: "ruact.suspense") if
27
+ defined?(Rails) && Rails.respond_to?(:error)
28
+ end
29
+ end
30
+ 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
@@ -177,16 +177,18 @@ module Ruact
177
177
  # its headers on the first row.
178
178
  self.status = status if status
179
179
 
180
- pipeline = RenderPipeline.new(ruact_manifest,
181
- controller_path: controller_path, logger: logger,
182
- development: Flight::Renderer.development_default)
180
+ pipeline = RenderPipeline.new(ruact_manifest,
181
+ controller_path: controller_path, logger: logger,
182
+ development: Flight::Renderer.development_default)
183
183
  # Story 19-1 — in an action that streams, the first load and a
184
184
  # navigation alike, each `<Suspense>`'s content renders after the shell
185
185
  # is sent: the view keeps its blocks instead of running them. Only when
186
186
  # the response really is Live's: on HTTP/1.0 (nginx's default to its
187
187
  # upstream) Live gives an ordinary response, which the server sends as
188
188
  # 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)
189
+ declared = self.class.ruact_streaming?(action_name)
190
+ streaming = declared && response.is_a?(ActionController::Live::Response)
191
+ Streaming.note_unstreamed(self) if declared && !streaming
190
192
 
191
193
  # Allocate a per-render context and expose it to the view via a normal
192
194
  # (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
@@ -484,7 +484,30 @@ module Ruact
484
484
  def check_streaming
485
485
  mode = Ruact.streaming_mode || :buffered
486
486
  label = mode == :enabled ? "enabled" : "buffered"
487
- [:pass, "streaming: #{label} (#{streaming_server_hint})"]
487
+ message = "streaming: #{label} (#{streaming_server_hint})"
488
+ declaring = streaming_controllers.length
489
+ return [:pass, message] if declaring.zero?
490
+
491
+ # Story 19-2 (AC20) — the doctor sees no request: what it can say is
492
+ # where a page that streams silently does not.
493
+ [:pass, "#{message}; #{pluralize_count(declaring, 'controller')} #{declaring == 1 ? 'streams' : 'stream'} " \
494
+ "(ruact_streaming or ActionController::Live) — behind a proxy, the app must be reached over " \
495
+ "HTTP/1.1 (nginx: `proxy_http_version 1.1;`), or those pages are sent whole",
496
+ PROXY_HTTP11]
497
+ end
498
+
499
+ # Story 19-2 (AC20) — what makes a proxy talk HTTP/1.1 to the app.
500
+ PROXY_HTTP11 = "Behind nginx, set `proxy_http_version 1.1;` where it proxies to the app: by default it " \
501
+ "talks HTTP/1.0 upstream, and a page declared with ruact_streaming is then sent whole " \
502
+ "(the app logs it once)."
503
+ private_constant :PROXY_HTTP11
504
+
505
+ # Controller files that declare `ruact_streaming` or include
506
+ # `ActionController::Live`. Reads source, as {#island_controllers} does.
507
+ def streaming_controllers
508
+ Dir.glob(Rails.root.join("app", "controllers", "**", "*.rb").to_s).select do |file|
509
+ File.read(file).match?(/^\s*(ruact_streaming\b|include\s+ActionController::Live\b)/)
510
+ end
488
511
  end
489
512
 
490
513
  # 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?