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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +57 -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/cached_blocks.rb +30 -0
- data/lib/ruact/configuration.rb +6 -1
- data/lib/ruact/controller/delivery.rb +262 -0
- data/lib/ruact/controller/document_rendering.rb +15 -12
- data/lib/ruact/controller/streaming.rb +305 -0
- data/lib/ruact/controller.rb +34 -51
- data/lib/ruact/declared_content.rb +33 -0
- data/lib/ruact/deferred_render.rb +254 -0
- data/lib/ruact/doctor.rb +42 -2
- data/lib/ruact/erb_preprocessor.rb +211 -24
- data/lib/ruact/flight/react_element.rb +22 -2
- data/lib/ruact/flight/renderer.rb +94 -41
- data/lib/ruact/flight/request.rb +71 -27
- data/lib/ruact/flight/serializer.rb +3 -10
- data/lib/ruact/html_converter.rb +44 -34
- data/lib/ruact/inline_script.rb +24 -0
- data/lib/ruact/load_async_note.rb +51 -0
- data/lib/ruact/markers.rb +98 -0
- data/lib/ruact/navigation_boundary.rb +2 -2
- data/lib/ruact/query_watch.rb +106 -0
- data/lib/ruact/railtie.rb +12 -0
- data/lib/ruact/render_context.rb +73 -6
- data/lib/ruact/render_pipeline.rb +32 -22
- 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 +70 -0
- data/lib/ruact/view_helper.rb +35 -33
- data/lib/ruact.rb +7 -0
- data/vendor/javascript/vite-plugin-ruact/bootstrap.test.mjs +16 -3
- data/vendor/javascript/vite-plugin-ruact/page-mount.test.mjs +75 -0
- data/vendor/javascript/vite-plugin-ruact/page-stream.test.mjs +132 -0
- data/vendor/javascript/vite-plugin-ruact/ruact-router.test.mjs +185 -0
- data/vendor/javascript/vite-plugin-ruact/runtime/bootstrap.jsx +23 -18
- data/vendor/javascript/vite-plugin-ruact/runtime/dev-overlay.js +53 -0
- data/vendor/javascript/vite-plugin-ruact/runtime/page-stream.js +108 -0
- data/vendor/javascript/vite-plugin-ruact/runtime/ruact-router.js +74 -20
- data/vendor/javascript/vite-plugin-ruact/runtime/suspense-boundary.js +1 -0
- metadata +17 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: e96181a35f53a1a25da4cd4db1ca655aeff15cdd5190f56aaafb97f1a2a19361
|
|
4
|
+
data.tar.gz: e9034d5a9f16dee7a5a319483364dbbf049a6b0aecf63073c3efcfe66ce6baa4
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: c90a2671ac0774bd51f726af1dc22ab58ba048ee6354b1078888ad2704289214caa6b6189b8909c72ab074546a37dc80676fb49576e630d09d1629eb09ff22ba
|
|
7
|
+
data.tar.gz: e71ea90b4a5ee1450fd5c772fe7a99c9641b5febb8465ea0e7649fc1ab00db670fcdd3333b34bffbcc870bb0d719deb9b236f40769e6277097f0a3cdaa0bc115
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,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.
|
|
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.
|
|
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
|
|
|
@@ -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
|
|
65
|
-
|
|
66
|
-
|
|
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
|
|
87
|
-
|
|
88
|
-
plain string and a bare attribute is `true`, as in JSX.
|
|
88
|
+
evaluates `@post.title` as a Ruby expression: no JS ternaries, no `{...spread}`.
|
|
89
|
+
A quoted value (`title="Hi"`) is a plain string; a bare attribute is `true`.
|
|
89
90
|
3. **One action, two response shapes.** A `Ruact::Server` non-GET action
|
|
90
91
|
answers JSON — its instance variables, or `204`, or `{"$redirect": path}` —
|
|
91
92
|
when the request's `Accept` header is exactly `application/json`, which is
|
|
@@ -144,8 +145,7 @@ duration, or a deliberate `expires_in: nil` for a non-expiring token.
|
|
|
144
145
|
- `bin/rails ruact:routes -- --json` — the accessor/route table (each accessor's
|
|
145
146
|
name, kind, verb, path, params) as JSON, from the same route table codegen
|
|
146
147
|
reads; bare `ruact:routes` prints a compact human table.
|
|
147
|
-
- Both `--json` outputs are EXPERIMENTAL
|
|
148
|
-
`schema_version` field (currently `0`), do not treat it as a stable contract.
|
|
148
|
+
- Both `--json` outputs are EXPERIMENTAL: gate on `schema_version` (now `0`).
|
|
149
149
|
- `bin/rails ruact:server_functions:generate` — regenerates the TS module;
|
|
150
150
|
exits 1 on a naming collision or an invalid name.
|
|
151
151
|
- `bin/dev` — boots Rails AND Vite (both required: Vite serves the client components).
|
|
@@ -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".
|
|
@@ -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
|
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,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
|
|
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">
|