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 +4 -4
- data/CHANGELOG.md +28 -1
- data/lib/generators/ruact/install/templates/AGENTS.md.tt +7 -7
- data/lib/ruact/cached_blocks.rb +30 -0
- data/lib/ruact/controller/delivery.rb +24 -9
- data/lib/ruact/controller/streaming.rb +64 -0
- data/lib/ruact/controller.rb +7 -5
- data/lib/ruact/declared_content.rb +33 -0
- data/lib/ruact/deferred_render.rb +55 -9
- data/lib/ruact/doctor.rb +24 -1
- data/lib/ruact/erb_preprocessor.rb +8 -2
- data/lib/ruact/flight/react_element.rb +9 -4
- data/lib/ruact/flight/renderer.rb +4 -1
- data/lib/ruact/html_converter.rb +21 -3
- data/lib/ruact/markers.rb +11 -8
- data/lib/ruact/query_watch.rb +106 -0
- data/lib/ruact/render_context.rb +23 -11
- data/lib/ruact/render_pipeline.rb +3 -1
- data/lib/ruact/version.rb +1 -1
- data/lib/ruact/view_helper/suspense.rb +10 -2
- data/lib/ruact/view_helper.rb +13 -3
- data/lib/ruact.rb +3 -0
- data/vendor/javascript/vite-plugin-ruact/page-mount.test.mjs +10 -2
- data/vendor/javascript/vite-plugin-ruact/page-stream.test.mjs +29 -4
- data/vendor/javascript/vite-plugin-ruact/ruact-router.test.mjs +185 -0
- data/vendor/javascript/vite-plugin-ruact/runtime/dev-overlay.js +20 -6
- data/vendor/javascript/vite-plugin-ruact/runtime/page-stream.js +23 -3
- data/vendor/javascript/vite-plugin-ruact/runtime/ruact-router.js +64 -4
- metadata +4 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: e96181a35f53a1a25da4cd4db1ca655aeff15cdd5190f56aaafb97f1a2a19361
|
|
4
|
+
data.tar.gz: e9034d5a9f16dee7a5a319483364dbbf049a6b0aecf63073c3efcfe66ce6baa4
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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.
|
|
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
|
|
82
|
-
redirect, cookies, session
|
|
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
|
|
87
|
-
|
|
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
|
|
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:
|
|
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-
|
|
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
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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
|
-
"
|
|
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?
|
data/lib/ruact/controller.rb
CHANGED
|
@@ -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
|
|
181
|
-
|
|
182
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|
108
|
-
#
|
|
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
|
|
159
|
-
|
|
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
|
|
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.
|
|
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
|
-
|
|
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
|
-
# <!--
|
|
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
|
-
|
|
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
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
41
|
-
|
|
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
|
-
|
|
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?
|
data/lib/ruact/html_converter.rb
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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,
|
|
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?
|