ruact 0.0.19 → 0.0.20

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 (42) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +30 -1
  3. data/CONTRIBUTING.md +10 -3
  4. data/README.md +1 -1
  5. data/lib/generators/ruact/install/templates/AGENTS.md.tt +8 -8
  6. data/lib/generators/ruact/install/templates/initializer.rb.tt +4 -1
  7. data/lib/ruact/configuration.rb +6 -1
  8. data/lib/ruact/controller/delivery.rb +247 -0
  9. data/lib/ruact/controller/document_rendering.rb +15 -12
  10. data/lib/ruact/controller/streaming.rb +241 -0
  11. data/lib/ruact/controller.rb +29 -48
  12. data/lib/ruact/deferred_render.rb +208 -0
  13. data/lib/ruact/doctor.rb +18 -1
  14. data/lib/ruact/erb_preprocessor.rb +203 -22
  15. data/lib/ruact/flight/react_element.rb +17 -2
  16. data/lib/ruact/flight/renderer.rb +91 -41
  17. data/lib/ruact/flight/request.rb +71 -27
  18. data/lib/ruact/flight/serializer.rb +3 -10
  19. data/lib/ruact/html_converter.rb +23 -31
  20. data/lib/ruact/inline_script.rb +24 -0
  21. data/lib/ruact/load_async_note.rb +51 -0
  22. data/lib/ruact/markers.rb +95 -0
  23. data/lib/ruact/navigation_boundary.rb +2 -2
  24. data/lib/ruact/railtie.rb +12 -0
  25. data/lib/ruact/render_context.rb +61 -6
  26. data/lib/ruact/render_pipeline.rb +29 -21
  27. data/lib/ruact/request_kind.rb +36 -0
  28. data/lib/ruact/server.rb +3 -7
  29. data/lib/ruact/testing/flight_extractor.rb +21 -4
  30. data/lib/ruact/version.rb +1 -1
  31. data/lib/ruact/view_helper/suspense.rb +62 -0
  32. data/lib/ruact/view_helper.rb +24 -32
  33. data/lib/ruact.rb +4 -0
  34. data/vendor/javascript/vite-plugin-ruact/bootstrap.test.mjs +16 -3
  35. data/vendor/javascript/vite-plugin-ruact/page-mount.test.mjs +67 -0
  36. data/vendor/javascript/vite-plugin-ruact/page-stream.test.mjs +107 -0
  37. data/vendor/javascript/vite-plugin-ruact/runtime/bootstrap.jsx +23 -18
  38. data/vendor/javascript/vite-plugin-ruact/runtime/dev-overlay.js +39 -0
  39. data/vendor/javascript/vite-plugin-ruact/runtime/page-stream.js +88 -0
  40. data/vendor/javascript/vite-plugin-ruact/runtime/ruact-router.js +11 -17
  41. data/vendor/javascript/vite-plugin-ruact/runtime/suspense-boundary.js +1 -0
  42. metadata +14 -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: 69c5f93d5a39e751e7f69b953ffd1101660e464df4397cadee932c12ba81b4fd
4
+ data.tar.gz: 518c3a0c1183cbc82fd13ca3d6d621502e28713b5d9dd21bd46beeca30ecc775
5
5
  SHA512:
6
- metadata.gz: 1b9ed6e86719935a5494f4a847c1938e903c4440f0b026ba3c1780eb0407fe6cf2214b448b3a6e34b8c10e45f42c3e4964dd2bcbaa14c33ec35d5e71114d7bbc
7
- data.tar.gz: c25217ef2564fc948c0aee0191878a4addeefaf4e3edb45986e6d0b1d8b547f17cdc72b15a366d8a9d886db378e522b3c7394fd00ba22f9e05fd11dab8e49a61
6
+ metadata.gz: 115756074639ab4849ae2982cf7d4e13176f20f383302f8961b1ed5f09148e1bec74560f2e1f42ef2aa26262449d6c0536c89ce205748176bad9aa0f5006fbfb
7
+ data.tar.gz: c55a816d7f40810470e42f86f8111ae3fc2e1e4ab150a31153626c26dedb8de1f76166483acb004722239d1696626bbe0bef218237bb34b665da7320e7e200c2
data/CHANGELOG.md CHANGED
@@ -7,6 +7,34 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.0.20] - 2026-10-14
11
+
12
+ ### Added
13
+
14
+ - **`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`.
15
+ - **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`).
16
+ - **`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.
17
+
18
+ ### Fixed
19
+
20
+ - **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.
21
+ - **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.
22
+
23
+ ### Changed
24
+
25
+ - **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.
26
+ - **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.
27
+ - **`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 `">`.
28
+ - **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).
29
+ - **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`.
30
+ - **`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.
31
+ - **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.
32
+ - **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.
33
+ - **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.
34
+ - **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.
35
+ - **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.
36
+ - **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.
37
+
10
38
  ## [0.0.19] - 2026-10-08
11
39
 
12
40
  ### Fixed
@@ -490,7 +518,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
490
518
 
491
519
  - **E2E test app** — `e2e/` Rails app (no DB, in-memory Post model) with full CRUD system tests validating the complete request cycle.
492
520
 
493
- [Unreleased]: https://github.com/luizcg/ruact/compare/v0.0.19...HEAD
521
+ [Unreleased]: https://github.com/luizcg/ruact/compare/v0.0.20...HEAD
522
+ [0.0.20]: https://github.com/luizcg/ruact/releases/tag/v0.0.20
494
523
  [0.0.19]: https://github.com/luizcg/ruact/releases/tag/v0.0.19
495
524
  [0.0.18]: https://github.com/luizcg/ruact/releases/tag/v0.0.18
496
525
  [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,7 +77,9 @@ 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, so status,
82
+ redirect, cookies, session and flash belong in the controller, not inside it.
83
83
  In a loop of items holding components, give each item a `data-react-key`
84
84
  (the record's id): React matches them by position otherwise, silently.
85
85
  2. **`{}` props are Ruby, not JavaScript.** `<Badge label={@post.title} />`
@@ -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".
@@ -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,247 @@
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: no caching, and no
22
+ # buffering by a proxy that honours `X-Accel-Buffering` (nginx).
23
+ STREAM_HEADERS = {
24
+ "Content-Type" => "#{RequestKind::FLIGHT_MIME}; charset=utf-8",
25
+ "Cache-Control" => "no-cache",
26
+ "X-Accel-Buffering" => "no"
27
+ }.freeze
28
+
29
+ # The same, for a document written in parts.
30
+ DOCUMENT_STREAM_HEADERS = {
31
+ "Content-Type" => "text/html; charset=utf-8",
32
+ "Cache-Control" => "no-cache",
33
+ "X-Accel-Buffering" => "no"
34
+ }.freeze
35
+
36
+ # What Rails itself writes once the response has started, on the request's
37
+ # thread, while the first block may be running: the session cookie and the
38
+ # session's own keys (`commit_session`, `commit_flash`, and the CSRF token
39
+ # Rails stores at commit since 7.1), and the headers a middleware adds on
40
+ # the way out. Not the block's writes.
41
+ RAILS_WRITES = {
42
+ "header" => %w[set-cookie server-timing content-security-policy
43
+ content-security-policy-report-only x-runtime].freeze,
44
+ "session" => %w[session_id flash _csrf_token].freeze
45
+ }.freeze
46
+
47
+ # Where a streamed document is cut: the rows of each `<Suspense>` are
48
+ # pushed before it, as they are ready.
49
+ BODY_CLOSE = "</body>"
50
+
51
+ private
52
+
53
+ # Build the wire output and write it to the response. Nothing is written
54
+ # until the page (or, when it streams, its shell) has rendered without
55
+ # raising — that way a missing-component error still surfaces as a
56
+ # normal 500 response.
57
+ def emit_ruact_response(pipeline, html, render_context, streaming:)
58
+ # Story 15.5 (FR109) — record which page shape ruact rendered so the
59
+ # `Ruact::Server` dev log (`__ruact_log_response_shape!`) can name it and,
60
+ # crucially, stay SILENT on a plain Rails render (this flag is never set
61
+ # there). `@__`-prefixed → never leaks into `view_assigns`.
62
+ @__ruact_negotiated_page = ruact_request? ? :flight : :html_shell
63
+ if streaming
64
+ return __ruact_stream_page(DeferredRender.new(html, render_context, pipeline,
65
+ guard: __ruact_late_write_guard))
66
+ end
67
+
68
+ input = { html: html, render_context: render_context }
69
+ if ruact_request?
70
+ render plain: pipeline.render(input, mode: :string), content_type: RequestKind::FLIGHT_MIME
71
+ else
72
+ render_ruact_document(pipeline.render(input, mode: :string))
73
+ end
74
+ end
75
+
76
+ # Story 19-1 — the shell, then each boundary as its block is rendered.
77
+ # A page that left nothing for later responds as one that does not stream.
78
+ def __ruact_stream_page(run)
79
+ shell = run.shell
80
+ unless run.pending?
81
+ return render(plain: shell, content_type: RequestKind::FLIGHT_MIME) if ruact_request?
82
+
83
+ return render_ruact_document(shell)
84
+ end
85
+ return __ruact_stream(run, STREAM_HEADERS, shell) if ruact_request?
86
+
87
+ document = ruact_document_html(shell)
88
+ 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
92
+ end
93
+
94
+ # Writes +first+, then each boundary's rows (through the block, when given),
95
+ # then +last+. No exception leaves here with the stream open: Live would
96
+ # append Rails' `/500.html` script to a page already on screen. What was
97
+ # not sent stays pending, for the browser's transport to label.
98
+ def __ruact_stream(run, headers, first, last = nil)
99
+ headers.each { |name, value| response.headers[name] = value }
100
+ stream = response.stream
101
+ begin
102
+ stream.write(first)
103
+ while run.pending?
104
+ if __ruact_client_gone?
105
+ __ruact_log_cut_short(run, "the client left")
106
+ break
107
+ end
108
+ rows = run.next_rows
109
+ stream.write(block_given? ? yield(rows) : rows)
110
+ end
111
+ stream.write(last) if last
112
+ rescue ActionController::Live::ClientDisconnected, IOError, SystemCallError => e
113
+ __ruact_log_cut_short(run, "the client left (#{e.class})")
114
+ rescue StandardError => e
115
+ __ruact_report_stream_error(e, run)
116
+ ensure
117
+ @__ruact_streamed_state = __ruact_response_state if Flight::Renderer.development_default
118
+ stream.close
119
+ end
120
+ end
121
+
122
+ # One boundary's rows, as the script a streamed document carries them in:
123
+ # pushed onto the queue the bootstrap reads. Written as
124
+ # {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>"
128
+ end
129
+
130
+ # Story 19-1 (D4) — has the client closed its end? Asked before each
131
+ # block: the server does not notice on its own, and a write to a client
132
+ # that left does not fail until the one after it (measured in 19-0).
133
+ # Puma hands the socket over as `puma.socket`; with another server, or
134
+ # none, nothing is asked.
135
+ def __ruact_client_gone?
136
+ socket = request.env["puma.socket"]
137
+ return false if socket.nil? || !socket.respond_to?(:wait_readable) || !socket.wait_readable(0)
138
+
139
+ # The end of the connection reads as nil or as "", depending on how the
140
+ # peer closed it.
141
+ socket.recv_nonblock(1, Socket::MSG_PEEK).to_s.empty?
142
+ rescue IO::WaitReadable
143
+ false
144
+ rescue SystemCallError, IOError
145
+ true
146
+ end
147
+
148
+ def __ruact_log_cut_short(run, why)
149
+ left = run.pending_count
150
+ logger&.info("[ruact] #{self.class.name}##{action_name}: #{why}; " \
151
+ "#{left} <Suspense> #{left == 1 ? 'boundary was' : 'boundaries were'} not rendered")
152
+ end
153
+
154
+ # An exception once the response has started, outside a block (the end of
155
+ # the layout, the serializer): reported and logged, and the stream closed.
156
+ def __ruact_report_stream_error(error, run)
157
+ logger&.error("[ruact] #{self.class.name}##{action_name} raised while streaming, after the " \
158
+ "response started: #{error.class}: #{error.message}. The response was closed; " \
159
+ "#{run.pending_count} <Suspense> boundaries were not rendered.")
160
+ Rails.error.report(error, handled: true, severity: :error, source: "ruact.stream") if Rails.respond_to?(:error)
161
+ end
162
+
163
+ # Story 19-1 (D5) — in development, a block that writes a cookie, the
164
+ # session, the flash or a header is an error of that block: the response
165
+ # started before the block ran, so the write would be lost.
166
+ def __ruact_late_write_guard
167
+ return nil unless Flight::Renderer.development_default
168
+
169
+ lambda do |&evaluate|
170
+ before = __ruact_response_state
171
+ html = evaluate.call
172
+ changed = __ruact_state_changes(before, __ruact_response_state)
173
+ raise Ruact::Error, __ruact_late_write_message(changed, "a <Suspense>") unless changed.empty?
174
+
175
+ html
176
+ end
177
+ end
178
+
179
+ # Story 19-1 (D5) — the same, for what runs after the page was sent (an
180
+ # `after_action`): it cannot be a block's error any more, so it is logged.
181
+ def __ruact_check_writes_after_stream
182
+ before = @__ruact_streamed_state
183
+ return if before.nil?
184
+
185
+ @__ruact_streamed_state = nil
186
+ changed = __ruact_state_changes(before, __ruact_response_state)
187
+ return if changed.empty?
188
+
189
+ logger&.error("[ruact] #{self.class.name}##{action_name}: " \
190
+ "#{__ruact_late_write_message(changed, 'code after the page was sent')}")
191
+ end
192
+
193
+ def __ruact_late_write_message(changed, where)
194
+ "#{where} wrote #{changed.join(', ')} after the response started, so the browser never gets it. " \
195
+ "Status, headers, cookies, the session and the flash are set in the controller, before the page " \
196
+ "renders."
197
+ end
198
+
199
+ # What a response can no longer carry once it has started. Each part is
200
+ # read on its own: a controller without a session has no session to read.
201
+ # Reading does not load what is not loaded — the session, the flash — so
202
+ # development behaves as production does.
203
+ def __ruact_response_state
204
+ {
205
+ "header" => __ruact_read_state("header") { response.headers.to_h.transform_keys(&:downcase) },
206
+ "cookie" => __ruact_read_state("cookie") { request.cookie_jar.to_hash.except(__ruact_session_cookie) },
207
+ "session" => __ruact_read_state("session") { session.loaded? ? session.to_hash : {} },
208
+ "flash" => __ruact_read_state("flash") { request.flash_hash&.to_hash || {} }
209
+ }
210
+ end
211
+
212
+ def __ruact_read_state(kind)
213
+ yield.except(*RAILS_WRITES.fetch(kind, []))
214
+ rescue StandardError
215
+ nil
216
+ end
217
+
218
+ # The session store's cookie: the key the app configured, or the one the
219
+ # store falls back to without one. Not `request.session_options[:key]`:
220
+ # Rack takes the key out of the options it keeps.
221
+ def __ruact_session_cookie
222
+ key = Rails.application.config.session_options[:key]
223
+ key ||= request.session.instance_variable_get(:@by).try(:key)
224
+ (key || "_session_id").to_s
225
+ rescue StandardError
226
+ "_session_id"
227
+ end
228
+
229
+ # `["the cookie theme", "the header X-Trace"]` — what differs.
230
+ def __ruact_state_changes(before, after)
231
+ before.flat_map do |kind, was|
232
+ now = after[kind]
233
+ next [] if was.nil? || now.nil? || was == now
234
+
235
+ (was.keys | now.keys).reject { |key| was[key] == now[key] }.map { |key| "the #{kind} #{key}" }
236
+ end
237
+ end
238
+
239
+ # The Flight body of a redirect: one row the client router reads and
240
+ # follows (`redirectType: "push"`), instead of a 302 the browser would.
241
+ def __ruact_render_flight_redirect(url)
242
+ render plain: "0:#{JSON.generate({ 'redirectUrl' => url, 'redirectType' => 'push' })}\n",
243
+ content_type: RequestKind::FLIGHT_MIME
244
+ end
245
+ end
246
+ end
247
+ 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">