ruact 0.0.19 → 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.
Files changed (46) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +57 -1
  3. data/CONTRIBUTING.md +10 -3
  4. data/README.md +1 -1
  5. data/lib/generators/ruact/install/templates/AGENTS.md.tt +13 -13
  6. data/lib/generators/ruact/install/templates/initializer.rb.tt +4 -1
  7. data/lib/ruact/cached_blocks.rb +30 -0
  8. data/lib/ruact/configuration.rb +6 -1
  9. data/lib/ruact/controller/delivery.rb +262 -0
  10. data/lib/ruact/controller/document_rendering.rb +15 -12
  11. data/lib/ruact/controller/streaming.rb +305 -0
  12. data/lib/ruact/controller.rb +34 -51
  13. data/lib/ruact/declared_content.rb +33 -0
  14. data/lib/ruact/deferred_render.rb +254 -0
  15. data/lib/ruact/doctor.rb +42 -2
  16. data/lib/ruact/erb_preprocessor.rb +211 -24
  17. data/lib/ruact/flight/react_element.rb +22 -2
  18. data/lib/ruact/flight/renderer.rb +94 -41
  19. data/lib/ruact/flight/request.rb +71 -27
  20. data/lib/ruact/flight/serializer.rb +3 -10
  21. data/lib/ruact/html_converter.rb +44 -34
  22. data/lib/ruact/inline_script.rb +24 -0
  23. data/lib/ruact/load_async_note.rb +51 -0
  24. data/lib/ruact/markers.rb +98 -0
  25. data/lib/ruact/navigation_boundary.rb +2 -2
  26. data/lib/ruact/query_watch.rb +106 -0
  27. data/lib/ruact/railtie.rb +12 -0
  28. data/lib/ruact/render_context.rb +73 -6
  29. data/lib/ruact/render_pipeline.rb +32 -22
  30. data/lib/ruact/request_kind.rb +36 -0
  31. data/lib/ruact/server.rb +3 -7
  32. data/lib/ruact/testing/flight_extractor.rb +21 -4
  33. data/lib/ruact/version.rb +1 -1
  34. data/lib/ruact/view_helper/suspense.rb +70 -0
  35. data/lib/ruact/view_helper.rb +35 -33
  36. data/lib/ruact.rb +7 -0
  37. data/vendor/javascript/vite-plugin-ruact/bootstrap.test.mjs +16 -3
  38. data/vendor/javascript/vite-plugin-ruact/page-mount.test.mjs +75 -0
  39. data/vendor/javascript/vite-plugin-ruact/page-stream.test.mjs +132 -0
  40. data/vendor/javascript/vite-plugin-ruact/ruact-router.test.mjs +185 -0
  41. data/vendor/javascript/vite-plugin-ruact/runtime/bootstrap.jsx +23 -18
  42. data/vendor/javascript/vite-plugin-ruact/runtime/dev-overlay.js +53 -0
  43. data/vendor/javascript/vite-plugin-ruact/runtime/page-stream.js +108 -0
  44. data/vendor/javascript/vite-plugin-ruact/runtime/ruact-router.js +74 -20
  45. data/vendor/javascript/vite-plugin-ruact/runtime/suspense-boundary.js +1 -0
  46. metadata +17 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 7d7fb20ee09bec81b05b4e1cafba8f511e46a77d2bee2afc7bb92ca8e026483d
4
- data.tar.gz: 6111ba333bf9067854a13c62ec66f13297c78c3a6c4c419f39284d001d57d2c4
3
+ metadata.gz: e96181a35f53a1a25da4cd4db1ca655aeff15cdd5190f56aaafb97f1a2a19361
4
+ data.tar.gz: e9034d5a9f16dee7a5a319483364dbbf049a6b0aecf63073c3efcfe66ce6baa4
5
5
  SHA512:
6
- metadata.gz: 1b9ed6e86719935a5494f4a847c1938e903c4440f0b026ba3c1780eb0407fe6cf2214b448b3a6e34b8c10e45f42c3e4964dd2bcbaa14c33ec35d5e71114d7bbc
7
- data.tar.gz: c25217ef2564fc948c0aee0191878a4addeefaf4e3edb45986e6d0b1d8b547f17cdc72b15a366d8a9d886db378e522b3c7394fd00ba22f9e05fd11dab8e49a61
6
+ metadata.gz: c90a2671ac0774bd51f726af1dc22ab58ba048ee6354b1078888ad2704289214caa6b6189b8909c72ab074546a37dc80676fb49576e630d09d1629eb09ff22ba
7
+ data.tar.gz: e71ea90b4a5ee1450fd5c772fe7a99c9641b5febb8465ea0e7649fc1ab00db670fcdd3333b34bffbcc870bb0d719deb9b236f40769e6277097f0a3cdaa0bc115
data/CHANGELOG.md CHANGED
@@ -7,6 +7,60 @@ 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
+
36
+ ## [0.0.20] - 2026-10-14
37
+
38
+ ### Added
39
+
40
+ - **`ruact_streaming` — a page sent as it renders.** In an action the controller declares (`ruact_streaming`, `ruact_streaming only: :show`, `except:`; without arguments, every ruact page of the controller), the shell and the `<Suspense>` fallbacks reach the browser first, and each `<Suspense>`'s content runs after that, one at a time, in page order (a `<Suspense>` inside one waits behind the page's others), in the view and with the template path it was written in, and arrives when it is ready — on the first load (the document stays open and pushes each boundary's rows) and on a navigation. Those actions run under `ActionController::Live`, applied per action: the controller's other actions respond as before, with their `ETag`, on the request's thread. `Controller.action(:name)` endpoints included. Over HTTP/1.0 — nginx's default to its upstream; set `proxy_http_version 1.1` — Live cannot stream, and the page renders whole, as without `ruact_streaming`.
41
+ - **A `<Suspense>` that fails, in development, over the page.** A `<Suspense>` whose content raises gets an error row: its fallback stays, the next one runs, the response stays 200, and the error goes to `Rails.error` (`source: "ruact.suspense"`) and the log. Development shows it over the page, with the Ruby backtrace; production says only that a part failed (`ruact:suspense-error`).
42
+ - **`ruact:doctor` checks `load_async`.** A new check, `load_async`, warns when `config.active_record.async_query_executor` is not set (`load_async` then runs its query where it is called); in development the first such `load_async` says so in the log.
43
+
44
+ ### Fixed
45
+
46
+ - **A `<Suspense>` written as text inside ERB code stays as written.** `<%= "<Suspense>" %>` printed ruact's internal `<ruact-suspense …>` markup instead of the text (and a tag with attributes there did not compile). Tags inside `<% %>` and `<%= %>` are now left alone, as component tags already were.
47
+ - **Error lines after a `<Suspense>` tag written over several lines are right.** The tag was rewritten onto one line, so Rails named an earlier line for any error below it.
48
+
49
+ ### Changed
50
+
51
+ - **Every `<Suspense>` must close, in the same template and the same ERB block.** A `<Suspense>` with no `</Suspense>`, a `</Suspense>` that closes nothing, or a pair split across an ERB block (`<% if %><Suspense><% end %>…</Suspense>`) raises `Ruact::ComponentTagError` naming the template's line, as a component tag already does. Before, such a template rendered whatever the HTML parser made of it, sometimes with the content outside the boundary. This counts `<Suspense>` written as text in an attribute, a `<script>` or an HTML comment, and a pair opened in one partial and closed in another. `<Suspense fallback="…" />` is a boundary with nothing in it.
52
+ - **A `<Suspense>`'s content is its own Ruby scope.** A local variable assigned between the tags is not visible after `</Suspense>`; instance variables and `content_for` still cross it. `break`, `next`, `redo` and `return` written between the tags outside a loop or block of their own, and `it` or `_1` outside a block of their own, would apply to the Suspense and not to the loop around it, so they raise `Ruact::ComponentTagError` with the line: name the block's parameter, or move the statement out. An `it` the template made a local before the tag (`|it|`, `it = …`) is that local. This is what lets the content be rendered later.
53
+ - **`fallback` and `delay` take literal text.** ERB inside either now raises `Ruact::PreprocessorError` with the line. It never worked: the fallback came out empty and the page showed a stray `">`.
54
+ - **In an action that streams, a `<Suspense>`'s content runs after the shell is sent.** Code inside it that the rest of the template depended on — an instance variable read further down, a `content_for` — now runs later. Status, redirects, headers, cookies, the session and the flash are set in the controller, before the page renders: written inside a `<Suspense>` they never reach the browser, and development makes that an error of the boundary (and logs it when an `after_action` does it).
55
+ - **No default `delay`.** A `<Suspense>` waited 1.5 s before its content on every streamed navigation, even when the content was ready. `delay=` still waits that long, inside `suspense_timeout`.
56
+ - **`suspense_timeout` limits real time.** It bounds how long a `<Suspense>`'s content may run (an explicit `delay` included), with a timeout a `rescue` of `StandardError` in the content does not catch (Active Record wrapping a query's error included); past it the boundary gets the timeout error and the next one runs. It does not stop a query the database is running — set the database's statement timeout for that.
57
+ - **An error inside a `<Suspense>` no longer makes the response a 500** in an action that streams: the boundary gets the error (see Added). An exception once the response has started is reported and the response closed, without Rails' `/500.html` script.
58
+ - **The entry script is `async`, on every ruact page.** It runs as soon as it has loaded, so a page that streams shows its fallbacks; the bootstrap waits for `#root` when a layout puts `ruact_js_assets` above it.
59
+ - **A controller that includes `ActionController::Live` streams every page**, with the content of each `<Suspense>` rendered after the shell; `ruact_streaming` is the documented way. A controller with neither responds as before, and development says, once per response, which line makes an action with a `<Suspense>` stream.
60
+ - **Connections a streamed action took are given back when it ends.** Under Live the action runs in a thread of its own, and a `Model.connection` there kept its connection until the thread was reused.
61
+ - **Supported Rails is 7.2 through 8.1.** The test matrix named Rails 7.0 and 7.1 but ran 7.2 in those cells (`~> 7.0` admits every 7.x), so neither was ever tested; on them, `rails generate ruact:scaffold` fails. The matrix now runs 7.2, 8.0 and 8.1, each cell checks it loaded the Rails it names, and 7.0 and 7.1 are no longer claimed. Rails itself maintains only 8.0 and 8.1.
62
+ - **The render path was reorganised, with no change to what a page sends.** What a template leaves for the converter, how a render hands out its rows (the shell first, then each Suspense boundary), how a response is written, and how the browser opens a page's Flight stream each live in one place now; the Flight bytes are the same, checked against the wire fixtures and in a browser. Internal classes changed shape (`Ruact::RenderContext`, `Ruact::Flight::Request`, `Ruact::Flight::Renderer`, `Ruact::RenderPipeline`); none of them is public API.
63
+
10
64
  ## [0.0.19] - 2026-10-08
11
65
 
12
66
  ### Fixed
@@ -490,7 +544,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
490
544
 
491
545
  - **E2E test app** — `e2e/` Rails app (no DB, in-memory Post model) with full CRUD system tests validating the complete request cycle.
492
546
 
493
- [Unreleased]: https://github.com/luizcg/ruact/compare/v0.0.19...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
549
+ [0.0.20]: https://github.com/luizcg/ruact/releases/tag/v0.0.20
494
550
  [0.0.19]: https://github.com/luizcg/ruact/releases/tag/v0.0.19
495
551
  [0.0.18]: https://github.com/luizcg/ruact/releases/tag/v0.0.18
496
552
  [0.0.17]: https://github.com/luizcg/ruact/releases/tag/v0.0.17
data/CONTRIBUTING.md CHANGED
@@ -62,12 +62,13 @@ every pull request. Running them locally first is the difference between one rou
62
62
 
63
63
  | CI job | What it checks |
64
64
  |---|---|
65
- | `rspec` | the Ruby suite, on Ruby 3.2/3.3 × Rails 7.0/7.1/7.2/8.0 — eight cells |
65
+ | `rspec` | the Ruby suite, on Ruby 3.2/3.3 × Rails 7.2/8.0/8.1 — six cells, each checking it loaded the Rails it names |
66
66
  | `rubocop` | style, plus the three custom cops below |
67
67
  | `yard` | every public method carries YARD documentation |
68
68
  | `js` | the Vite plugin's vitest suite, including its Ruby↔JS byte-parity cases, and a type-level test over the generated accessors |
69
69
  | `benchmark` | render-pipeline allocations against a recorded baseline |
70
70
  | `name-propagation` | greps the tree for names retired before v0.1.0 |
71
+ | `browser` | the browser proof of a page that streams (`spec/browser/deferred_suspense/run`): a Rails app on Puma with the gem by path, in Chromium, in development and production, and the perturbation that must fail |
71
72
  | `gate` | that `CHANGELOG.md` gained something if this branch changed what the published gem contains, and that a version bump is one legal semver step to a version nothing has released |
72
73
 
73
74
  <!-- ci-jobs:end -->
@@ -95,8 +96,14 @@ npm test
95
96
  npm run typecheck
96
97
  ```
97
98
 
98
- To reproduce one matrix cell other than the default — `RAILS_VERSION` is what the eight `rspec` cells vary,
99
- and unset means 8.0:
99
+ and, for the `browser` job (Node and Chromium; ports 4190 and 5199):
100
+
101
+ ```bash
102
+ spec/browser/deferred_suspense/run
103
+ ```
104
+
105
+ To reproduce one matrix cell other than the default — `RAILS_VERSION` is what the six `rspec` cells vary,
106
+ and unset means 8.1:
100
107
 
101
108
  ```bash
102
109
  RAILS_VERSION=7.2 bundle install && bundle exec rspec
data/README.md CHANGED
@@ -98,7 +98,7 @@ Every item below is shipped in this gem at v0.0.11:
98
98
  - **Signed record references** — `Ruact.signed_global_id(record, for:, expires_in:)` out, `Ruact.locate_signed(token, for:)` back in; a tampered token is a `400`, not a lookup. [Docs](https://ruact.dev/docs/api/server-actions)
99
99
  - **Client-side navigation** — link interception, scroll restoration and redirect-after-POST, derived from your Rails routes. [Docs](https://ruact.dev/docs/concepts/navigation)
100
100
  - **A CRUD generator** — `rails generate ruact:scaffold Post title:string body:text` delegates the model, migration and route to Rails' own `resource` generator, then adds the ruact layer. Plain semantic HTML by default; `--shadcn` opts into the Tailwind/shadcn path. It does not run migrations — `rails db:migrate` is still yours. [Docs](https://ruact.dev/docs/api/scaffold)
101
- - **`bin/rails ruact:doctor`** — nine checks over the manifest, Vite, the layout, the client-component CSS and streaming; exits `1` when one fails. [Docs](https://ruact.dev/docs/api/ruact-doctor)
101
+ - **`bin/rails ruact:doctor`** — checks the manifest, Vite, the layout, the client-component CSS and streaming; exits `1` when one fails. [Docs](https://ruact.dev/docs/api/ruact-doctor)
102
102
  - **One runtime dependency** — `nokogiri`. Rails itself is not a declared dependency of this gem.
103
103
 
104
104
  ## AI tools and coding agents
@@ -1,8 +1,7 @@
1
1
  <!-- ruact:begin -->
2
2
  <!-- Managed by `rails generate ruact:install`. Re-run it with --force after a
3
- gem upgrade to refresh ONLY this marked section; everything you write
4
- outside the markers is preserved. Kept in sync manually with the online
5
- copy at https://ruact.dev/llms.txt -->
3
+ gem upgrade to refresh ONLY this marked section; everything outside the
4
+ markers is preserved. Kept in sync by hand with https://ruact.dev/llms.txt -->
6
5
 
7
6
  # ruact — conventions for coding agents
8
7
 
@@ -61,10 +60,9 @@ import { categories, useQuery } from "@/.ruact/server-functions";
61
60
  const { data, loading, error } = useQuery(categories);
62
61
  ```
63
62
 
64
- Query params come from the method's keyword arguments and accept ONLY
65
- `string | number | boolean | null` (arrays/objects are rejected). Queries run
66
- the host controller's callback chain (authentication etc.) before the query
67
- object is instantiated.
63
+ Query params are the method's keyword arguments, ONLY `string | number |
64
+ boolean | null` (arrays/objects are rejected). Queries run the host
65
+ controller's callback chain (authentication etc.) before the query object.
68
66
 
69
67
  ## Ground truth — read the generated file
70
68
 
@@ -79,13 +77,16 @@ READ IT instead of simulating the name generation. Regenerate after routes/queri
79
77
  1. **Every component tag closes.** `<Badge label={@x} />` has no children;
80
78
  `<Card title={@t}>...</Card>` passes the ERB between its tags to the
81
79
  component as `children`. An unclosed or stray tag fails LOUDLY with a
82
- `PreprocessorError` naming file:line. `<Suspense fallback="...">` pairs too.
80
+ `PreprocessorError` naming file:line. `<Suspense fallback="...">` pairs too;
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).
@@ -41,7 +41,10 @@ Ruact.configure do |config|
41
41
  # Recommended: true in production to prevent accidental attribute exposure.
42
42
  # config.strict_serialization = Rails.env.production?
43
43
 
44
- # Timeout in seconds for deferred Suspense chunks. Default: 5.0.
44
+ # How long a <Suspense>'s content may run, in seconds of real time, in an
45
+ # action declared with `ruact_streaming`; past it, its boundary shows an
46
+ # error and the next one runs. A backstop: it does not stop a query the
47
+ # database is running — the database's statement timeout does. Default: 5.0.
45
48
  # config.suspense_timeout = 5.0
46
49
 
47
50
  # Base URL of the Vite dev server. Default: "http://localhost:5173".
@@ -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
@@ -38,7 +38,12 @@ module Ruact
38
38
  # raise Ruact::SerializationError. Defaults to false in development, true in production.
39
39
  #
40
40
  # @!attribute [r] suspense_timeout
41
- # @return [Float] Seconds before a deferred Suspense chunk times out. Default: 5.0.
41
+ # @return [Float] Seconds of real time a `<Suspense>`'s content may run in an
42
+ # action that streams (`ruact_streaming`) before its boundary gets the
43
+ # timeout error and the next one runs; an explicit `delay` counts inside
44
+ # it. A backstop, not a limit on the database: a query the driver is
45
+ # running keeps running (measured with PostgreSQL in Story 19-1) — the
46
+ # database's statement timeout is what stops it. Default: 5.0.
42
47
  #
43
48
  # @!attribute [r] vite_dev_server
44
49
  # @return [String] Base URL of the Vite dev server. Default: "http://localhost:5173".
@@ -0,0 +1,262 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "socket"
4
+ require_relative "document_rendering"
5
+
6
+ module Ruact
7
+ module Controller
8
+ # How a rendered ruact page reaches the client: as a Flight body, or as an
9
+ # HTML document carrying the payload ({DocumentRendering}) — all at once,
10
+ # or, in an action that streams ({Streaming}), the shell first and each
11
+ # `<Suspense>`'s rows as its content is rendered. Also the Flight body of a
12
+ # redirect the client router follows itself.
13
+ #
14
+ # Everything that decides what a ruact response looks like on the wire, its
15
+ # headers included, is here.
16
+ module Delivery
17
+ extend ActiveSupport::Concern
18
+
19
+ include DocumentRendering
20
+
21
+ # The headers of a Flight body written row by row: not stored, and no
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.
27
+ STREAM_HEADERS = {
28
+ "Content-Type" => "#{RequestKind::FLIGHT_MIME}; charset=utf-8",
29
+ "Cache-Control" => "no-store",
30
+ "X-Accel-Buffering" => "no"
31
+ }.freeze
32
+
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.
36
+ DOCUMENT_STREAM_HEADERS = {
37
+ "Content-Type" => "text/html; charset=utf-8",
38
+ "Cache-Control" => "no-cache",
39
+ "X-Accel-Buffering" => "no"
40
+ }.freeze
41
+
42
+ # What Rails itself writes once the response has started, on the request's
43
+ # thread, while the first block may be running: the session cookie and the
44
+ # session's own keys (`commit_session`, `commit_flash`, and the CSRF token
45
+ # Rails stores at commit since 7.1), and the headers a middleware adds on
46
+ # the way out. Not the block's writes.
47
+ RAILS_WRITES = {
48
+ "header" => %w[set-cookie server-timing content-security-policy
49
+ content-security-policy-report-only x-runtime].freeze,
50
+ "session" => %w[session_id flash _csrf_token].freeze
51
+ }.freeze
52
+
53
+ # Where a streamed document is cut: the rows of each `<Suspense>` are
54
+ # pushed before it, as they are ready.
55
+ BODY_CLOSE = "</body>"
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
+
64
+ private
65
+
66
+ # Build the wire output and write it to the response. Nothing is written
67
+ # until the page (or, when it streams, its shell) has rendered without
68
+ # raising — that way a missing-component error still surfaces as a
69
+ # normal 500 response.
70
+ def emit_ruact_response(pipeline, html, render_context, streaming:)
71
+ # Story 15.5 (FR109) — record which page shape ruact rendered so the
72
+ # `Ruact::Server` dev log (`__ruact_log_response_shape!`) can name it and,
73
+ # crucially, stay SILENT on a plain Rails render (this flag is never set
74
+ # there). `@__`-prefixed → never leaks into `view_assigns`.
75
+ @__ruact_negotiated_page = ruact_request? ? :flight : :html_shell
76
+ if streaming
77
+ return __ruact_stream_page(DeferredRender.new(html, render_context, pipeline,
78
+ guard: __ruact_late_write_guard))
79
+ end
80
+
81
+ input = { html: html, render_context: render_context }
82
+ if ruact_request?
83
+ render plain: pipeline.render(input, mode: :string), content_type: RequestKind::FLIGHT_MIME
84
+ else
85
+ render_ruact_document(pipeline.render(input, mode: :string))
86
+ end
87
+ end
88
+
89
+ # Story 19-1 — the shell, then each boundary as its block is rendered.
90
+ # A page that left nothing for later responds as one that does not stream.
91
+ def __ruact_stream_page(run)
92
+ shell = run.shell
93
+ unless run.pending?
94
+ return render(plain: shell, content_type: RequestKind::FLIGHT_MIME) if ruact_request?
95
+
96
+ return render_ruact_document(shell)
97
+ end
98
+ return __ruact_stream(run, STREAM_HEADERS, shell) if ruact_request?
99
+
100
+ document = ruact_document_html(shell)
101
+ cut = document.rindex(BODY_CLOSE) || document.length
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) }
105
+ end
106
+
107
+ # Writes +first+, then each boundary's rows (through the block, when given),
108
+ # then +last+. No exception leaves here with the stream open: Live would
109
+ # append Rails' `/500.html` script to a page already on screen. What was
110
+ # not sent stays pending, for the browser's transport to label.
111
+ def __ruact_stream(run, headers, first, last = nil)
112
+ headers.each { |name, value| response.headers[name] = value }
113
+ Streaming.keep_unbuffered(response.headers)
114
+ @__ruact_streamed = true
115
+ stream = response.stream
116
+ begin
117
+ stream.write(first)
118
+ while run.pending?
119
+ if __ruact_client_gone?
120
+ __ruact_log_cut_short(run, "the client left")
121
+ break
122
+ end
123
+ rows = run.next_rows
124
+ stream.write(block_given? ? yield(rows) : rows)
125
+ end
126
+ stream.write(last) if last
127
+ rescue ActionController::Live::ClientDisconnected, IOError, SystemCallError => e
128
+ __ruact_log_cut_short(run, "the client left (#{e.class})")
129
+ rescue StandardError => e
130
+ __ruact_report_stream_error(e, run)
131
+ ensure
132
+ @__ruact_streamed_state = __ruact_response_state if Flight::Renderer.development_default
133
+ stream.close
134
+ end
135
+ end
136
+
137
+ # One boundary's rows, as the script a streamed document carries them in:
138
+ # pushed onto the queue the bootstrap reads. Written as
139
+ # {InlineScript.string_literal} writes it — no byte of the payload can
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>"
143
+ end
144
+
145
+ # Story 19-1 (D4) — has the client closed its end? Asked before each
146
+ # block: the server does not notice on its own, and a write to a client
147
+ # that left does not fail until the one after it (measured in 19-0).
148
+ # Puma hands the socket over as `puma.socket`; with another server, or
149
+ # none, nothing is asked.
150
+ def __ruact_client_gone?
151
+ socket = request.env["puma.socket"]
152
+ return false if socket.nil? || !socket.respond_to?(:wait_readable) || !socket.wait_readable(0)
153
+
154
+ # The end of the connection reads as nil or as "", depending on how the
155
+ # peer closed it.
156
+ socket.recv_nonblock(1, Socket::MSG_PEEK).to_s.empty?
157
+ rescue IO::WaitReadable
158
+ false
159
+ rescue SystemCallError, IOError
160
+ true
161
+ end
162
+
163
+ def __ruact_log_cut_short(run, why)
164
+ left = run.pending_count
165
+ logger&.info("[ruact] #{self.class.name}##{action_name}: #{why}; " \
166
+ "#{left} <Suspense> #{left == 1 ? 'boundary was' : 'boundaries were'} not rendered")
167
+ end
168
+
169
+ # An exception once the response has started, outside a block (the end of
170
+ # the layout, the serializer): reported and logged, and the stream closed.
171
+ def __ruact_report_stream_error(error, run)
172
+ logger&.error("[ruact] #{self.class.name}##{action_name} raised while streaming, after the " \
173
+ "response started: #{error.class}: #{error.message}. The response was closed; " \
174
+ "#{run.pending_count} <Suspense> boundaries were not rendered.")
175
+ Rails.error.report(error, handled: true, severity: :error, source: "ruact.stream") if Rails.respond_to?(:error)
176
+ end
177
+
178
+ # Story 19-1 (D5) — in development, a block that writes a cookie, the
179
+ # session, the flash or a header is an error of that block: the response
180
+ # started before the block ran, so the write would be lost.
181
+ def __ruact_late_write_guard
182
+ return nil unless Flight::Renderer.development_default
183
+
184
+ lambda do |&evaluate|
185
+ before = __ruact_response_state
186
+ html = evaluate.call
187
+ changed = __ruact_state_changes(before, __ruact_response_state)
188
+ raise Ruact::Error, __ruact_late_write_message(changed, "a <Suspense>") unless changed.empty?
189
+
190
+ html
191
+ end
192
+ end
193
+
194
+ # Story 19-1 (D5) — the same, for what runs after the page was sent (an
195
+ # `after_action`): it cannot be a block's error any more, so it is logged.
196
+ def __ruact_check_writes_after_stream
197
+ before = @__ruact_streamed_state
198
+ return if before.nil?
199
+
200
+ @__ruact_streamed_state = nil
201
+ changed = __ruact_state_changes(before, __ruact_response_state)
202
+ return if changed.empty?
203
+
204
+ logger&.error("[ruact] #{self.class.name}##{action_name}: " \
205
+ "#{__ruact_late_write_message(changed, 'code after the page was sent')}")
206
+ end
207
+
208
+ def __ruact_late_write_message(changed, where)
209
+ "#{where} wrote #{changed.join(', ')} after the response started, so the browser never gets it. " \
210
+ "Status, headers, cookies, the session and the flash are set in the controller, before the page " \
211
+ "renders."
212
+ end
213
+
214
+ # What a response can no longer carry once it has started. Each part is
215
+ # read on its own: a controller without a session has no session to read.
216
+ # Reading does not load what is not loaded — the session, the flash — so
217
+ # development behaves as production does.
218
+ def __ruact_response_state
219
+ {
220
+ "header" => __ruact_read_state("header") { response.headers.to_h.transform_keys(&:downcase) },
221
+ "cookie" => __ruact_read_state("cookie") { request.cookie_jar.to_hash.except(__ruact_session_cookie) },
222
+ "session" => __ruact_read_state("session") { session.loaded? ? session.to_hash : {} },
223
+ "flash" => __ruact_read_state("flash") { request.flash_hash&.to_hash || {} }
224
+ }
225
+ end
226
+
227
+ def __ruact_read_state(kind)
228
+ yield.except(*RAILS_WRITES.fetch(kind, []))
229
+ rescue StandardError
230
+ nil
231
+ end
232
+
233
+ # The session store's cookie: the key the app configured, or the one the
234
+ # store falls back to without one. Not `request.session_options[:key]`:
235
+ # Rack takes the key out of the options it keeps.
236
+ def __ruact_session_cookie
237
+ key = Rails.application.config.session_options[:key]
238
+ key ||= request.session.instance_variable_get(:@by).try(:key)
239
+ (key || "_session_id").to_s
240
+ rescue StandardError
241
+ "_session_id"
242
+ end
243
+
244
+ # `["the cookie theme", "the header X-Trace"]` — what differs.
245
+ def __ruact_state_changes(before, after)
246
+ before.flat_map do |kind, was|
247
+ now = after[kind]
248
+ next [] if was.nil? || now.nil? || was == now
249
+
250
+ (was.keys | now.keys).reject { |key| was[key] == now[key] }.map { |key| "the #{kind} #{key}" }
251
+ end
252
+ end
253
+
254
+ # The Flight body of a redirect: one row the client router reads and
255
+ # follows (`redirectType: "push"`), instead of a 302 the browser would.
256
+ def __ruact_render_flight_redirect(url)
257
+ render plain: "0:#{JSON.generate({ 'redirectUrl' => url, 'redirectType' => 'push' })}\n",
258
+ content_type: RequestKind::FLIGHT_MIME
259
+ end
260
+ end
261
+ end
262
+ end
@@ -59,6 +59,13 @@ module Ruact
59
59
  # committed: that is a configuration error, raised in development/test and
60
60
  # logged-and-degraded in production rather than served to real traffic.
61
61
  def render_ruact_document(payload)
62
+ render html: ruact_document_html(payload).html_safe, layout: false
63
+ end
64
+
65
+ # The document {#render_ruact_document} sends, as a String — what a page
66
+ # that streams sends up to its last `</body>` ({Delivery}). Every check
67
+ # above runs here, before a byte of the response is written.
68
+ def ruact_document_html(payload)
62
69
  __ruact_warn_turbo_form_submission if __ruact_local_env?
63
70
  # Set for EVERY branch, the shell included: besides carrying the payload
64
71
  # to a layout's zero-argument `ruact_js_assets`, it is how
@@ -67,11 +74,11 @@ module Ruact
67
74
  # through the same layout never inherits it.
68
75
  @ruact_flight_payload = payload
69
76
  layout = Ruact.config.layout
70
- return render html: ruact_html_shell(payload).html_safe, layout: false if layout == false
77
+ return ruact_html_shell(payload) if layout == false
71
78
 
72
79
  unless ruact_layout_resolvable?(layout)
73
80
  __ruact_handle_unready_layout(layout, :missing)
74
- return render html: ruact_html_shell(payload).html_safe, layout: false
81
+ return ruact_html_shell(payload)
75
82
  end
76
83
 
77
84
  # `@ruact_flight_payload` is copied to the view by Rails' `view_assigns`
@@ -79,13 +86,10 @@ module Ruact
79
86
  # that is how the layout's zero-argument `ruact_js_assets` reaches THIS
80
87
  # render's Flight payload.
81
88
  document = render_to_string(html: "".html_safe, layout: layout)
89
+ return document if ruact_document_mountable?(document)
82
90
 
83
- if ruact_document_mountable?(document)
84
- render html: document.html_safe, layout: false
85
- else
86
- __ruact_handle_unready_layout(layout, :unwired)
87
- render html: ruact_html_shell(payload).html_safe, layout: false
88
- end
91
+ __ruact_handle_unready_layout(layout, :unwired)
92
+ ruact_html_shell(payload)
89
93
  ensure
90
94
  remove_instance_variable(:@ruact_flight_payload) if instance_variable_defined?(:@ruact_flight_payload)
91
95
  end
@@ -219,10 +223,9 @@ module Ruact
219
223
  def ruact_html_shell(flight_payload)
220
224
  # Story 14.2 — the JS asset block (entry `<script>` tags + `__FLIGHT_DATA`)
221
225
  # is delegated to the single `Ruact::ViewHelper#ruact_js_assets`
222
- # implementation. The bootstrap entry script is a deferred ES module, so it
223
- # runs after the inline `__FLIGHT_DATA` classic script has populated the
224
- # queue regardless of source order — emitting the whole block in `<body>`
225
- # is correct.
226
+ # implementation, which writes the inline `__FLIGHT_DATA` script before
227
+ # the entry `<script>`: the entry is `async` (Story 19-1) and runs as
228
+ # soon as it loads, so the queue has to be there first.
226
229
  <<~HTML
227
230
  <!DOCTYPE html>
228
231
  <html lang="en">