ruact 0.0.18 → 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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +41 -1
- data/CONTRIBUTING.md +10 -3
- data/README.md +1 -1
- data/lib/generators/ruact/install/templates/AGENTS.md.tt +13 -13
- data/lib/generators/ruact/install/templates/initializer.rb.tt +4 -1
- data/lib/ruact/client_manifest.rb +7 -7
- data/lib/ruact/configuration.rb +6 -1
- data/lib/ruact/controller/delivery.rb +247 -0
- data/lib/ruact/controller/document_rendering.rb +15 -12
- data/lib/ruact/controller/streaming.rb +241 -0
- data/lib/ruact/controller.rb +29 -48
- data/lib/ruact/deferred_render.rb +208 -0
- data/lib/ruact/doctor.rb +18 -1
- data/lib/ruact/erb_preprocessor.rb +203 -22
- data/lib/ruact/flight/react_element.rb +17 -2
- data/lib/ruact/flight/renderer.rb +91 -41
- data/lib/ruact/flight/request.rb +71 -27
- data/lib/ruact/flight/serializer.rb +3 -10
- data/lib/ruact/html_converter.rb +23 -31
- data/lib/ruact/inline_script.rb +24 -0
- data/lib/ruact/load_async_note.rb +51 -0
- data/lib/ruact/markers.rb +95 -0
- data/lib/ruact/navigation_boundary.rb +2 -2
- data/lib/ruact/railtie.rb +12 -0
- data/lib/ruact/render_context.rb +61 -6
- data/lib/ruact/render_pipeline.rb +29 -21
- data/lib/ruact/request_kind.rb +36 -0
- data/lib/ruact/server.rb +3 -7
- data/lib/ruact/testing/flight_extractor.rb +21 -4
- data/lib/ruact/version.rb +1 -1
- data/lib/ruact/view_helper/suspense.rb +62 -0
- data/lib/ruact/view_helper.rb +24 -32
- data/lib/ruact.rb +4 -0
- data/vendor/javascript/vite-plugin-ruact/bootstrap.test.mjs +16 -3
- data/vendor/javascript/vite-plugin-ruact/page-mount.test.mjs +67 -0
- data/vendor/javascript/vite-plugin-ruact/page-stream.test.mjs +107 -0
- data/vendor/javascript/vite-plugin-ruact/runtime/bootstrap.jsx +23 -18
- data/vendor/javascript/vite-plugin-ruact/runtime/dev-overlay.js +39 -0
- data/vendor/javascript/vite-plugin-ruact/runtime/page-stream.js +88 -0
- data/vendor/javascript/vite-plugin-ruact/runtime/ruact-router.js +11 -17
- data/vendor/javascript/vite-plugin-ruact/runtime/suspense-boundary.js +1 -0
- metadata +14 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 69c5f93d5a39e751e7f69b953ffd1101660e464df4397cadee932c12ba81b4fd
|
|
4
|
+
data.tar.gz: 518c3a0c1183cbc82fd13ca3d6d621502e28713b5d9dd21bd46beeca30ecc775
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 115756074639ab4849ae2982cf7d4e13176f20f383302f8961b1ed5f09148e1bec74560f2e1f42ef2aa26262449d6c0536c89ce205748176bad9aa0f5006fbfb
|
|
7
|
+
data.tar.gz: c55a816d7f40810470e42f86f8111ae3fc2e1e4ab150a31153626c26dedb8de1f76166483acb004722239d1696626bbe0bef218237bb34b665da7320e7e200c2
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,44 @@ 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
|
+
|
|
38
|
+
## [0.0.19] - 2026-10-08
|
|
39
|
+
|
|
40
|
+
### Fixed
|
|
41
|
+
|
|
42
|
+
- **A component exported from a file that exports several now renders as itself.** When one file exported more than one client component — as shadcn/ui's `components/ui/dialog.tsx` exports `Dialog`, `DialogTrigger`, `DialogContent` and the rest — every ERB tag naming one of them rendered whichever of the file's components the manifest listed first, with no error: a `<DialogTrigger>` rendered a `Dialog`, and a dialog composed from its parts in ERB never opened. Each tag now imports its own export. Files exporting a single component were not affected.
|
|
43
|
+
|
|
44
|
+
### Changed
|
|
45
|
+
|
|
46
|
+
- **The `AGENTS.md` section the install writes tells agents to key list items.** In an ERB loop whose items hold components, each item needs a `data-react-key`: ERB has no `key`, so React matches the items by position, and when the list changes a component's state shows on the wrong item, with no warning. Re-run `rails generate ruact:install --force` to refresh the section.
|
|
47
|
+
|
|
10
48
|
## [0.0.18] - 2026-10-07
|
|
11
49
|
|
|
12
50
|
### Added
|
|
@@ -480,7 +518,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
480
518
|
|
|
481
519
|
- **E2E test app** — `e2e/` Rails app (no DB, in-memory Post model) with full CRUD system tests validating the complete request cycle.
|
|
482
520
|
|
|
483
|
-
[Unreleased]: https://github.com/luizcg/ruact/compare/v0.0.
|
|
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
|
|
523
|
+
[0.0.19]: https://github.com/luizcg/ruact/releases/tag/v0.0.19
|
|
484
524
|
[0.0.18]: https://github.com/luizcg/ruact/releases/tag/v0.0.18
|
|
485
525
|
[0.0.17]: https://github.com/luizcg/ruact/releases/tag/v0.0.17
|
|
486
526
|
[0.0.16]: https://github.com/luizcg/ruact/releases/tag/v0.0.16
|
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.
|
|
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
|
-
|
|
99
|
-
|
|
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`** —
|
|
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
|
|
4
|
-
|
|
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
|
|
|
@@ -20,8 +19,7 @@ truth" below) instead of guessing.
|
|
|
20
19
|
adds it; `ruact_pages only:` narrows it). The page's renderer owns navigation:
|
|
21
20
|
going between a ruact page and a plain Rails/Turbo page is a full page load.
|
|
22
21
|
- Interactive components live in `app/javascript/components/` as `"use client"`
|
|
23
|
-
files, mounted from ERB with a PascalCase
|
|
24
|
-
`<LikeButton postId={@post.id} />`.
|
|
22
|
+
files, mounted from ERB with a PascalCase tag: `<LikeButton postId={@post.id} />`.
|
|
25
23
|
- Mutations are ordinary non-GET controller actions on a controller that does
|
|
26
24
|
`include Ruact::Server`. Reads are `Ruact::Query` classes in `app/queries/`.
|
|
27
25
|
- ruact generates a typed TypeScript module from the route table; React imports
|
|
@@ -62,10 +60,9 @@ import { categories, useQuery } from "@/.ruact/server-functions";
|
|
|
62
60
|
const { data, loading, error } = useQuery(categories);
|
|
63
61
|
```
|
|
64
62
|
|
|
65
|
-
Query params
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
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.
|
|
69
66
|
|
|
70
67
|
## Ground truth — read the generated file
|
|
71
68
|
|
|
@@ -78,10 +75,13 @@ READ IT instead of simulating the name generation. Regenerate after routes/queri
|
|
|
78
75
|
## Five traps
|
|
79
76
|
|
|
80
77
|
1. **Every component tag closes.** `<Badge label={@x} />` has no children;
|
|
81
|
-
`<Card title={@t}>...</Card>` passes the ERB between its tags
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
`
|
|
78
|
+
`<Card title={@t}>...</Card>` passes the ERB between its tags to the
|
|
79
|
+
component as `children`. An unclosed or stray tag fails LOUDLY with a
|
|
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
|
+
In a loop of items holding components, give each item a `data-react-key`
|
|
84
|
+
(the record's id): React matches them by position otherwise, silently.
|
|
85
85
|
2. **`{}` props are Ruby, not JavaScript.** `<Badge label={@post.title} />`
|
|
86
86
|
evaluates `@post.title` as a Ruby expression in the ERB. No JS ternaries,
|
|
87
87
|
no `{...spread}`. A quoted value (`title="Hi"`) is a
|
|
@@ -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
|
-
#
|
|
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".
|
|
@@ -49,9 +49,13 @@ module Ruact
|
|
|
49
49
|
# every client component eagerly (`virtual:ruact/registry`), so there is
|
|
50
50
|
# nothing to load — and React reads +chunks+ as `[chunkId, filename]` pairs,
|
|
51
51
|
# which the manifest's one-URL list is not.
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
52
|
+
#
|
|
53
|
+
# The entry is looked up by module AND export: one file can export several
|
|
54
|
+
# components (shadcn/ui's dialog.tsx exports Dialog, DialogTrigger, …), and
|
|
55
|
+
# each tag imports its own.
|
|
56
|
+
def resolve(module_id, export_name)
|
|
57
|
+
entry = data.each_value.find { |e| e["id"] == module_id && e["name"] == export_name }
|
|
58
|
+
raise "ClientManifest: no entry for module_id=#{module_id.inspect} export=#{export_name.inspect}" unless entry
|
|
55
59
|
|
|
56
60
|
[entry["id"], NO_CHUNKS, entry["name"]]
|
|
57
61
|
end
|
|
@@ -248,9 +252,5 @@ module Ruact
|
|
|
248
252
|
def entries_by_name
|
|
249
253
|
data
|
|
250
254
|
end
|
|
251
|
-
|
|
252
|
-
def by_module_id(id)
|
|
253
|
-
data.values.find { |entry| entry["id"] == id }
|
|
254
|
-
end
|
|
255
255
|
end
|
|
256
256
|
end
|
data/lib/ruact/configuration.rb
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
-
|
|
84
|
-
|
|
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
|
|
223
|
-
#
|
|
224
|
-
#
|
|
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">
|