ruact 0.0.20 → 0.0.22
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 +46 -1
- data/CONTRIBUTING.md +2 -2
- data/lib/generators/ruact/install/install_generator.rb +121 -19
- data/lib/generators/ruact/install/legacy/vite.config.pinned.js +28 -0
- data/lib/generators/ruact/install/templates/AGENTS.md.tt +9 -9
- data/lib/generators/ruact/install/templates/vite.config.js.tt +56 -21
- data/lib/ruact/build_skew.rb +164 -0
- data/lib/ruact/cached_blocks.rb +30 -0
- data/lib/ruact/client_manifest.rb +27 -1
- data/lib/ruact/controller/delivery.rb +24 -9
- data/lib/ruact/controller/streaming.rb +64 -0
- data/lib/ruact/controller.rb +21 -5
- data/lib/ruact/declared_content.rb +33 -0
- data/lib/ruact/deferred_render.rb +55 -9
- data/lib/ruact/doctor.rb +40 -6
- data/lib/ruact/erb_preprocessor.rb +8 -2
- data/lib/ruact/flight/react_element.rb +9 -4
- data/lib/ruact/flight/renderer.rb +4 -1
- data/lib/ruact/html_converter.rb +21 -3
- data/lib/ruact/markers.rb +11 -8
- data/lib/ruact/query_watch.rb +106 -0
- data/lib/ruact/railtie.rb +14 -0
- data/lib/ruact/render_context.rb +23 -11
- data/lib/ruact/render_pipeline.rb +3 -1
- data/lib/ruact/version.rb +1 -1
- data/lib/ruact/view_helper/suspense.rb +10 -2
- data/lib/ruact/view_helper.rb +56 -30
- data/lib/ruact.rb +8 -2
- data/vendor/javascript/vite-plugin-ruact/build-version.test.mjs +101 -0
- data/vendor/javascript/vite-plugin-ruact/index.js +39 -4
- data/vendor/javascript/vite-plugin-ruact/page-mount.test.mjs +10 -2
- data/vendor/javascript/vite-plugin-ruact/page-stream.test.mjs +29 -4
- data/vendor/javascript/vite-plugin-ruact/registry.test.mjs +7 -1
- data/vendor/javascript/vite-plugin-ruact/ruact-router.test.mjs +185 -0
- data/vendor/javascript/vite-plugin-ruact/runtime/dev-overlay.js +20 -6
- data/vendor/javascript/vite-plugin-ruact/runtime/page-stream.js +23 -3
- data/vendor/javascript/vite-plugin-ruact/runtime/ruact-router.js +64 -4
- metadata +8 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 769b242b14222272ed72cf6fedeaf732de780ce3d57d4f6b916ecf8a4cd17faa
|
|
4
|
+
data.tar.gz: d639e44c10c4524ece71cebbb8d3e135484ec6715792492b17a986347937a6fd
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 03f1a1c07dd0ee8594765e72f6e2d54762c6ac336a74929b2ce9fe531b6fff2c4ca15a2c87c81e7e4fada9dd645ab9e46c12e7f09b9b556c5055db76d3086f6f
|
|
7
|
+
data.tar.gz: '03931869edf24e89d2cf1f74eae2b434973859319434a12ec88840620359a1edfa5d473374f071b6a03620d5280e01f7936fc270f82fa11e2d5027e52de7e131'
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,49 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.0.22] - 2026-10-15
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- **The app says when the page's JavaScript was built by another ruact.** A server updated without its JavaScript breaks the pages that stream: 0.0.19's runtime, for one, reads only the shell's rows of a streamed document and its `<Suspense>` fallbacks stay, with "the response ended before the whole page arrived". The Vite plugin now records which ruact built the JavaScript, in `react-client-manifest.json` (`"__ruact__": { "version": … }` — never a component; a server of 0.0.21 or before reads the file as before). When that is not the server's version, or the build records none — every build by 0.0.21 and before: the first build after upgrading to this version is needed for the warning to go —
|
|
15
|
+
- in development, a banner across the top of the page says so, with what to do, and the log says it once;
|
|
16
|
+
- at boot, in every environment, the log says it once, as an error (the app still boots);
|
|
17
|
+
- `rails ruact:doctor` fails its manifest check (and `-- --json` reports it, the fix as the remediation); a manifest that is not valid JSON fails it too.
|
|
18
|
+
|
|
19
|
+
**Rebuilding was not always enough.** Up to 0.0.21 the `vite.config.js` that `rails generate ruact:install` wrote imported the plugin by the absolute path of the gem installed then, so after `bundle update ruact` the build kept the old plugin; when `vite.config.*` imports the plugin from a path that is not this gem's, the message names it and says to run `rails generate ruact:install` (see Changed, below). The comparison is by version only: two builds of the same version are not told apart. The version read is the manifest's, which the Vite dev server writes too: in development without it, after `bin/dev` ran with the new plugin over an older `public/assets` build, nothing is said while the old build is served — `npm run build` again.
|
|
20
|
+
|
|
21
|
+
### Changed
|
|
22
|
+
|
|
23
|
+
- **The `vite.config.js` that `rails generate ruact:install` writes asks Bundler where the ruact gem is, each time Vite starts** (`bundle exec ruby -e "require 'ruact'; print Ruact.vite_plugin_path"`), and loads ruact's Vite plugin from there. After `bundle update ruact`, the next build or `bin/dev` uses the new gem's plugin, with nothing to edit; and the file names no path, so it works in a checkout elsewhere or on CI. It costs about 0.6 s each time Vite starts (measured on macOS) — not per page, nor per module reloaded. Vite has to run where `bundle exec` works — the app's root, as `bin/dev` and `npm run build` do; otherwise it stops and says so. Where Bundler cannot run (a build stage without Ruby), `RUACT_VITE_PLUGIN_PATH` names the plugin's `index.js` instead — a fixed path, to update with the gem. The config is now an `async` function (`export default defineConfig(async () => …)`), so it works whether or not `package.json` says `"type": "module"`. It also sets `publicDir: false`: ruact puts nothing in Vite's public dir, and without it every build copied `public/` into `public/assets/`, with a warning.
|
|
24
|
+
|
|
25
|
+
**An app installed before:** run `rails generate ruact:install` again. A `vite.config.js` it generated and you never edited is replaced (it says which gem's path it held). One you edited is left as it is, and the install prints the lines to change: the `import ruact from '…'` line becomes the `ruactPluginPath()` function, and the config an `async` function that imports the plugin first. `--force` overwrites the file, as before. An app whose config is another `vite.config.*` (`vite.config.ts`, say) gets no `vite.config.js` beside it — Vite would read that one instead — only the lines to change in its own; with `--force` it gets one, and the install says Vite now reads it instead. `--skip` leaves a generated one as it is, and `rails destroy ruact:install` removes `vite.config.js` only when it is exactly what the install writes.
|
|
26
|
+
|
|
27
|
+
## [0.0.21] - 2026-10-15
|
|
28
|
+
|
|
29
|
+
### Changed
|
|
30
|
+
|
|
31
|
+
- **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.
|
|
32
|
+
- **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.
|
|
33
|
+
- **`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.
|
|
34
|
+
- **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.
|
|
35
|
+
- **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.
|
|
36
|
+
|
|
37
|
+
- **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`.
|
|
38
|
+
|
|
39
|
+
- **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.
|
|
40
|
+
|
|
41
|
+
- **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.
|
|
42
|
+
|
|
43
|
+
### Fixed
|
|
44
|
+
|
|
45
|
+
- **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.
|
|
46
|
+
- **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.
|
|
47
|
+
- **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.
|
|
48
|
+
- **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.
|
|
49
|
+
- **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.
|
|
50
|
+
- **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.
|
|
51
|
+
- **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.
|
|
52
|
+
|
|
10
53
|
## [0.0.20] - 2026-10-14
|
|
11
54
|
|
|
12
55
|
### Added
|
|
@@ -518,7 +561,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
518
561
|
|
|
519
562
|
- **E2E test app** — `e2e/` Rails app (no DB, in-memory Post model) with full CRUD system tests validating the complete request cycle.
|
|
520
563
|
|
|
521
|
-
[Unreleased]: https://github.com/luizcg/ruact/compare/v0.0.
|
|
564
|
+
[Unreleased]: https://github.com/luizcg/ruact/compare/v0.0.22...HEAD
|
|
565
|
+
[0.0.22]: https://github.com/luizcg/ruact/releases/tag/v0.0.22
|
|
566
|
+
[0.0.21]: https://github.com/luizcg/ruact/releases/tag/v0.0.21
|
|
522
567
|
[0.0.20]: https://github.com/luizcg/ruact/releases/tag/v0.0.20
|
|
523
568
|
[0.0.19]: https://github.com/luizcg/ruact/releases/tag/v0.0.19
|
|
524
569
|
[0.0.18]: https://github.com/luizcg/ruact/releases/tag/v0.0.18
|
data/CONTRIBUTING.md
CHANGED
|
@@ -14,8 +14,8 @@ request, and everything it asks you to run, you can run — with this repository
|
|
|
14
14
|
| `vendor/javascript/ruact-server-functions-runtime/` | the browser-side runtime those generated accessors call |
|
|
15
15
|
|
|
16
16
|
The two JavaScript packages are **bundled, not published**. A host app never installs them from npm:
|
|
17
|
-
`rails generate ruact:install` writes a `vite.config.js` that
|
|
18
|
-
|
|
17
|
+
`rails generate ruact:install` writes a `vite.config.js` that asks Bundler where the ruact gem is, each time
|
|
18
|
+
Vite starts, and loads the plugin from there. That has one consequence worth stating up front, because it is what makes the rest of this
|
|
19
19
|
document short — **pointing a Rails app at your checkout of the gem points it at your checkout of the Vite
|
|
20
20
|
plugin too.** There is nothing to link separately, and nothing else to clone. This repository has no
|
|
21
21
|
submodules.
|
|
@@ -20,7 +20,9 @@ module Ruact
|
|
|
20
20
|
# (`config.layout = true` or another name) gets the missing lines PRINTED,
|
|
21
21
|
# never written. See Ruact::Configuration#layout.
|
|
22
22
|
# 4. Creates app/javascript/components/.keep
|
|
23
|
-
# 5. Creates vite.config.js
|
|
23
|
+
# 5. Creates vite.config.js, which asks Bundler for the plugin (Story 19.4);
|
|
24
|
+
# replaces one it generated before 19.4 and the app never edited, and
|
|
25
|
+
# shows manual instructions for any other existing one
|
|
24
26
|
# 6. Creates package.json (react/react-dom/vite/@vitejs/plugin-react) so a
|
|
25
27
|
# fresh app has JS deps to install (Story 14.6, FR101).
|
|
26
28
|
# 7. Creates Procfile.dev + bin/dev (foreman) so the single `bin/dev`
|
|
@@ -247,23 +249,37 @@ module Ruact
|
|
|
247
249
|
rake "ruact:server_functions:generate"
|
|
248
250
|
end
|
|
249
251
|
|
|
252
|
+
# Story 19.4 — what the install wrote from Story 14.2 to 0.0.21, with
|
|
253
|
+
# the plugin's import as a placeholder: an existing vite.config.js equal
|
|
254
|
+
# to it, but for that import, was generated and never edited.
|
|
255
|
+
PINNED_VITE_CONFIG = File.expand_path("legacy/vite.config.pinned.js", __dir__)
|
|
256
|
+
PINNED_VITE_CONFIG_IMPORT = "import ruact from '__RUACT_VITE_PLUGIN_PATH__';"
|
|
257
|
+
PINNED_IMPORT_LINE = %r{^import ruact from '([^'\n]*vite-plugin-ruact/index\.js)';$}
|
|
258
|
+
|
|
259
|
+
# Story 19.4 — the vite.config.js the install writes asks Bundler where
|
|
260
|
+
# the ruact gem is each time Vite starts, so it names no path. An existing
|
|
261
|
+
# file is replaced only when it is one this generator wrote before 19.4,
|
|
262
|
+
# never edited (it imported the plugin by the path of the gem installed
|
|
263
|
+
# then, so after `bundle update ruact` the build kept the old plugin);
|
|
264
|
+
# any other file is left as it is and the change is printed. `--force`
|
|
265
|
+
# overwrites, as before (and says so when it sets another `vite.config.*`
|
|
266
|
+
# of the app's aside); `--skip` leaves a generated one; `rails destroy`
|
|
267
|
+
# removes only the file exactly as the install writes it.
|
|
250
268
|
def create_vite_config
|
|
251
|
-
|
|
269
|
+
return revoke_vite_config if behavior == :revoke
|
|
252
270
|
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
say_status "notice", "vite.config.js
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
say " 2. In the plugins array, add: ruact()"
|
|
261
|
-
say " 3. Set build.rollupOptions.input to '#{Ruact.bootstrap_virtual_id}'"
|
|
262
|
-
say ""
|
|
263
|
-
say " Re-run `rails generate ruact:install --force` to overwrite vite.config.js."
|
|
264
|
-
else
|
|
265
|
-
template "vite.config.js.tt", "vite.config.js"
|
|
271
|
+
vite_config_file = Pathname(destination_root).join("vite.config.js")
|
|
272
|
+
other = other_vite_config unless vite_config_file.exist?
|
|
273
|
+
if other && !options[:force]
|
|
274
|
+
# Vite reads vite.config.js first: writing one would set the app's aside.
|
|
275
|
+
say_status "notice", "#{other} exists — vite.config.js is not written, Vite would read it " \
|
|
276
|
+
"instead of yours.", :yellow
|
|
277
|
+
return show_vite_config_instructions(other)
|
|
266
278
|
end
|
|
279
|
+
return refresh_vite_config(vite_config_file) if vite_config_file.exist? && !options[:force]
|
|
280
|
+
|
|
281
|
+
template "vite.config.js.tt", "vite.config.js"
|
|
282
|
+
say_status "warn", "vite.config.js — Vite reads it first: #{other} is no longer used", :yellow if other
|
|
267
283
|
end
|
|
268
284
|
|
|
269
285
|
# Story 14.6 (FR101, Epic 14 DoD) — a fresh ruact app needs a package.json
|
|
@@ -271,7 +287,7 @@ module Ruact
|
|
|
271
287
|
# plugin) so the `npm install` that Story 14.1 runs has something to
|
|
272
288
|
# resolve and `bin/dev`'s `npm run dev` (Vite) has a `dev` script. The
|
|
273
289
|
# bundled ruact Vite plugin is NOT a package.json dependency — vite.config
|
|
274
|
-
#
|
|
290
|
+
# loads it from the gem Bundler has (`Ruact.vite_plugin_path`) and it uses only
|
|
275
291
|
# `node:` builtins. Guarded like vite.config.js: an existing package.json
|
|
276
292
|
# is left untouched (the app may already have one) unless --force.
|
|
277
293
|
#
|
|
@@ -429,13 +445,99 @@ module Ruact
|
|
|
429
445
|
say "\nThen add <MyComponent /> to any ERB view." if whole_app?
|
|
430
446
|
say layout_summary
|
|
431
447
|
say ""
|
|
432
|
-
say "Note: re-run this generator after updating the ruact gem to refresh"
|
|
433
|
-
say "the bundled Vite plugin path in vite.config.js."
|
|
434
|
-
say ""
|
|
435
448
|
end
|
|
436
449
|
|
|
437
450
|
private
|
|
438
451
|
|
|
452
|
+
# The path a vite.config.js generated before Story 19.4 imports the
|
|
453
|
+
# plugin from, when the file is otherwise as generated; nil for any
|
|
454
|
+
# other file. Only whitespace at the end of the file may differ.
|
|
455
|
+
def pinned_vite_config_path(content)
|
|
456
|
+
return nil unless content.valid_encoding?
|
|
457
|
+
|
|
458
|
+
match = content.match(PINNED_IMPORT_LINE)
|
|
459
|
+
return nil unless match
|
|
460
|
+
|
|
461
|
+
generated = File.read(PINNED_VITE_CONFIG, encoding: Encoding::UTF_8)
|
|
462
|
+
normalized = content.sub(PINNED_IMPORT_LINE) { PINNED_VITE_CONFIG_IMPORT }
|
|
463
|
+
normalized.rstrip == generated.rstrip ? match[1] : nil
|
|
464
|
+
end
|
|
465
|
+
|
|
466
|
+
# vite.config.js as this version of the template renders it.
|
|
467
|
+
def rendered_vite_config
|
|
468
|
+
source = File.read(File.join(self.class.source_root, "vite.config.js.tt"), encoding: Encoding::UTF_8)
|
|
469
|
+
ERB.new(source, trim_mode: "-").result(binding)
|
|
470
|
+
end
|
|
471
|
+
|
|
472
|
+
# An existing vite.config.js the install does not touch: how to make it
|
|
473
|
+
# ask Bundler for the plugin, with the template's own lines.
|
|
474
|
+
def show_vite_config_instructions(name = "vite.config.js")
|
|
475
|
+
rendered = rendered_vite_config
|
|
476
|
+
head = rendered[0...rendered.index("export default")]
|
|
477
|
+
say " In #{name}, to load ruact's Vite plugin from the gem Bundler has (so a `bundle update ruact`"
|
|
478
|
+
say " needs no edit there):"
|
|
479
|
+
say " 1. Replace the `import ruact from '…'` line, if there is one, with (beside your other imports):"
|
|
480
|
+
say ""
|
|
481
|
+
head.each_line do |line|
|
|
482
|
+
next if line.match?(/^import (\{ defineConfig \}|react) from /)
|
|
483
|
+
|
|
484
|
+
say " #{line}".rstrip
|
|
485
|
+
end
|
|
486
|
+
say " 2. Wrap the config in an async function that imports the plugin first:"
|
|
487
|
+
say ""
|
|
488
|
+
say " export default defineConfig(async () => {"
|
|
489
|
+
say " const { default: ruact } = await import(pathToFileURL(ruactPluginPath()).href);"
|
|
490
|
+
say " return { /* your config, with ruact() in plugins */ };"
|
|
491
|
+
say " });"
|
|
492
|
+
say ""
|
|
493
|
+
say " 3. In it: ruact() in plugins, build.rollupOptions.input '#{Ruact.bootstrap_virtual_id}',"
|
|
494
|
+
say " and (optional) publicDir: false, so a build does not copy public/ into public/assets/."
|
|
495
|
+
say ""
|
|
496
|
+
return unless name == "vite.config.js"
|
|
497
|
+
|
|
498
|
+
say " Re-run `rails generate ruact:install --force` to write vite.config.js instead."
|
|
499
|
+
end
|
|
500
|
+
|
|
501
|
+
# A `vite.config.*` of the app's other than vite.config.js, which Vite
|
|
502
|
+
# would no longer read once a vite.config.js exists.
|
|
503
|
+
def other_vite_config
|
|
504
|
+
(Ruact::BuildSkew::VITE_CONFIGS - ["vite.config.js"])
|
|
505
|
+
.find { |name| Pathname(destination_root).join(name).exist? }
|
|
506
|
+
end
|
|
507
|
+
|
|
508
|
+
# An existing vite.config.js: replaced when the install generated it
|
|
509
|
+
# before Story 19.4 and the app never edited it (not under --skip),
|
|
510
|
+
# left as it is otherwise.
|
|
511
|
+
def refresh_vite_config(vite_config_file)
|
|
512
|
+
existing = vite_config_file.binread.force_encoding(Encoding::UTF_8)
|
|
513
|
+
pinned = pinned_vite_config_path(existing)
|
|
514
|
+
if pinned && options[:skip]
|
|
515
|
+
say_status "skip", "vite.config.js — it imports ruact's Vite plugin from #{pinned}, the gem " \
|
|
516
|
+
"installed then (--skip leaves it)", :yellow
|
|
517
|
+
elsif pinned
|
|
518
|
+
say_status "update", "vite.config.js — it imported ruact's Vite plugin from #{pinned}, " \
|
|
519
|
+
"the gem installed then; it now asks Bundler", :green
|
|
520
|
+
template "vite.config.js.tt", "vite.config.js", force: true, verbose: false
|
|
521
|
+
elsif existing == rendered_vite_config
|
|
522
|
+
say_status "identical", "vite.config.js", :blue
|
|
523
|
+
else
|
|
524
|
+
say_status "notice", "vite.config.js already exists and has edits of yours — it is left as it is.", :yellow
|
|
525
|
+
show_vite_config_instructions
|
|
526
|
+
end
|
|
527
|
+
end
|
|
528
|
+
|
|
529
|
+
# `rails destroy ruact:install`: removes vite.config.js only when it is
|
|
530
|
+
# exactly what the install writes; any other one is the app's.
|
|
531
|
+
def revoke_vite_config
|
|
532
|
+
vite_config_file = Pathname(destination_root).join("vite.config.js")
|
|
533
|
+
return unless vite_config_file.exist?
|
|
534
|
+
unless vite_config_file.binread.force_encoding(Encoding::UTF_8) == rendered_vite_config
|
|
535
|
+
return say_status("skip", "vite.config.js — not the one the install writes, left as it is", :yellow)
|
|
536
|
+
end
|
|
537
|
+
|
|
538
|
+
template "vite.config.js.tt", "vite.config.js"
|
|
539
|
+
end
|
|
540
|
+
|
|
439
541
|
# What the post-install message says about the layout — true for the
|
|
440
542
|
# setting actually in effect, not just for a fresh install.
|
|
441
543
|
def layout_summary
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { defineConfig } from 'vite';
|
|
2
|
+
import react from '@vitejs/plugin-react';
|
|
3
|
+
// Bundled Vite plugin — path resolved from the installed ruact gem.
|
|
4
|
+
// Re-run `rails generate ruact:install` after gem upgrades to refresh this path.
|
|
5
|
+
import ruact from '__RUACT_VITE_PLUGIN_PATH__';
|
|
6
|
+
|
|
7
|
+
export default defineConfig({
|
|
8
|
+
plugins: [
|
|
9
|
+
react(),
|
|
10
|
+
ruact(), // scans app/javascript/components/ for "use client", emits react-client-manifest.json
|
|
11
|
+
],
|
|
12
|
+
|
|
13
|
+
build: {
|
|
14
|
+
outDir: 'public/assets',
|
|
15
|
+
manifest: true,
|
|
16
|
+
rollupOptions: {
|
|
17
|
+
// ruact's bootstrap entry is a virtual module served by the plugin above
|
|
18
|
+
// (Story 14.2) — there is no app/javascript/application.jsx to point at.
|
|
19
|
+
input: 'virtual:ruact/bootstrap',
|
|
20
|
+
},
|
|
21
|
+
},
|
|
22
|
+
|
|
23
|
+
server: {
|
|
24
|
+
port: 5173,
|
|
25
|
+
strictPort: true,
|
|
26
|
+
origin: 'http://localhost:5173',
|
|
27
|
+
},
|
|
28
|
+
});
|
|
@@ -78,14 +78,15 @@ READ IT instead of simulating the name generation. Regenerate after routes/queri
|
|
|
78
78
|
`<Card title={@t}>...</Card>` passes the ERB between its tags to the
|
|
79
79
|
component as `children`. An unclosed or stray tag fails LOUDLY with a
|
|
80
80
|
`PreprocessorError` naming file:line. `<Suspense fallback="...">` pairs too;
|
|
81
|
-
under `ruact_streaming` its content runs AFTER the page is sent
|
|
82
|
-
redirect, cookies, session
|
|
81
|
+
under `ruact_streaming` its content runs AFTER the page is sent: status,
|
|
82
|
+
redirect, cookies, session, flash, `content_for` and locale/tenant/DB-role
|
|
83
|
+
scopes belong in the controller (`around_action`), not inside it or in a
|
|
84
|
+
middleware; a `cache` block goes inside a `<Suspense>`, never around one.
|
|
83
85
|
In a loop of items holding components, give each item a `data-react-key`
|
|
84
86
|
(the record's id): React matches them by position otherwise, silently.
|
|
85
87
|
2. **`{}` props are Ruby, not JavaScript.** `<Badge label={@post.title} />`
|
|
86
|
-
evaluates `@post.title` as a Ruby expression
|
|
87
|
-
|
|
88
|
-
plain string and a bare attribute is `true`, as in JSX.
|
|
88
|
+
evaluates `@post.title` as a Ruby expression: no JS ternaries, no `{...spread}`.
|
|
89
|
+
A quoted value (`title="Hi"`) is a plain string; a bare attribute is `true`.
|
|
89
90
|
3. **One action, two response shapes.** A `Ruact::Server` non-GET action
|
|
90
91
|
answers JSON — its instance variables, or `204`, or `{"$redirect": path}` —
|
|
91
92
|
when the request's `Accept` header is exactly `application/json`, which is
|
|
@@ -139,13 +140,12 @@ duration, or a deliberate `expires_in: nil` for a non-expiring token.
|
|
|
139
140
|
|
|
140
141
|
## Verify your work
|
|
141
142
|
|
|
142
|
-
- `bin/rails ruact:doctor` —
|
|
143
|
-
|
|
143
|
+
- `bin/rails ruact:doctor` — install/config health, and that the JS was built by
|
|
144
|
+
this ruact (run it after a gem upgrade); exits 1 on failure. Append `-- --json` for JSON.
|
|
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).
|
|
@@ -1,28 +1,63 @@
|
|
|
1
|
+
import { execSync } from 'node:child_process';
|
|
2
|
+
import { pathToFileURL } from 'node:url';
|
|
1
3
|
import { defineConfig } from 'vite';
|
|
2
4
|
import react from '@vitejs/plugin-react';
|
|
3
|
-
// Bundled Vite plugin — path resolved from the installed ruact gem.
|
|
4
|
-
// Re-run `rails generate ruact:install` after gem upgrades to refresh this path.
|
|
5
|
-
import ruact from '<%= Ruact.vite_plugin_path %>';
|
|
6
5
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
6
|
+
// ruact's Vite plugin ships inside the ruact gem. Bundler is asked where that
|
|
7
|
+
// gem is each time Vite starts, so after `bundle update ruact` the next build
|
|
8
|
+
// (or bin/dev) uses the new plugin: no path to refresh in this file.
|
|
9
|
+
// Where Bundler cannot run (a build stage without Ruby), RUACT_VITE_PLUGIN_PATH
|
|
10
|
+
// names the plugin's index.js instead — a fixed path, to update with the gem.
|
|
11
|
+
function ruactPluginPath() {
|
|
12
|
+
const pinned = process.env.RUACT_VITE_PLUGIN_PATH;
|
|
13
|
+
if (pinned) return pinned;
|
|
12
14
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
15
|
+
let out;
|
|
16
|
+
try {
|
|
17
|
+
out = execSync(`bundle exec ruby -e "require 'ruact'; print Ruact.vite_plugin_path"`, {
|
|
18
|
+
encoding: 'utf8',
|
|
19
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
20
|
+
});
|
|
21
|
+
} catch (error) {
|
|
22
|
+
throw new Error(
|
|
23
|
+
"[ruact] could not ask Bundler where the ruact gem is. Run Vite from the app's root, " +
|
|
24
|
+
'where `bundle exec` works (bin/dev, npm run build), or set RUACT_VITE_PLUGIN_PATH.\n' +
|
|
25
|
+
(error.stderr || error.message),
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
// The last line only: anything a gem prints while loading comes before it.
|
|
29
|
+
const path = out.trim().split(/\r?\n/).pop();
|
|
30
|
+
if (!path) throw new Error('[ruact] Bundler gave no path for the ruact gem.');
|
|
31
|
+
return path;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export default defineConfig(async () => {
|
|
35
|
+
const { default: ruact } = await import(pathToFileURL(ruactPluginPath()).href);
|
|
36
|
+
|
|
37
|
+
return {
|
|
38
|
+
// ruact puts nothing in Vite's public dir, and Rails serves public/: without
|
|
39
|
+
// this, every build copies public/ into public/assets/.
|
|
40
|
+
publicDir: false,
|
|
41
|
+
|
|
42
|
+
plugins: [
|
|
43
|
+
react(),
|
|
44
|
+
ruact(), // scans app/javascript/components/ for "use client", emits react-client-manifest.json
|
|
45
|
+
],
|
|
46
|
+
|
|
47
|
+
build: {
|
|
48
|
+
outDir: 'public/assets',
|
|
49
|
+
manifest: true,
|
|
50
|
+
rollupOptions: {
|
|
51
|
+
// ruact's bootstrap entry is a virtual module served by the plugin above
|
|
52
|
+
// (Story 14.2) — there is no app/javascript/application.jsx to point at.
|
|
53
|
+
input: '<%= Ruact.bootstrap_virtual_id %>',
|
|
54
|
+
},
|
|
20
55
|
},
|
|
21
|
-
},
|
|
22
56
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
57
|
+
server: {
|
|
58
|
+
port: 5173,
|
|
59
|
+
strictPort: true,
|
|
60
|
+
origin: 'http://localhost:5173',
|
|
61
|
+
},
|
|
62
|
+
};
|
|
28
63
|
});
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "erb"
|
|
4
|
+
|
|
5
|
+
module Ruact
|
|
6
|
+
# Story 19-3 — the page's JavaScript built by another ruact than the server's.
|
|
7
|
+
#
|
|
8
|
+
# The Vite plugin records which ruact built the JavaScript in
|
|
9
|
+
# `react-client-manifest.json` ({ClientManifest#build_version}). When that is
|
|
10
|
+
# not this gem's version — or the build records none, as every build by 0.0.21
|
|
11
|
+
# and before — the browser runs another release's runtime against this
|
|
12
|
+
# server, and a page that streams keeps its `<Suspense>` fallbacks forever
|
|
13
|
+
# (measured with 0.0.19's: `spec/browser/deferred_suspense`, `./run skew`).
|
|
14
|
+
#
|
|
15
|
+
# Said loudly in development (a banner on the page, {ViewHelper}), once in
|
|
16
|
+
# the log at boot in any environment ({Railtie}), and as a failure of
|
|
17
|
+
# `rails ruact:doctor`'s manifest check ({Doctor}) — all with {.message}.
|
|
18
|
+
#
|
|
19
|
+
# The usual cause after `bundle update ruact` is not a forgotten build: the
|
|
20
|
+
# `vite.config.js` that `ruact:install` wrote up to 0.0.21 imports the plugin
|
|
21
|
+
# by the absolute path of the gem installed then, so the build and the dev
|
|
22
|
+
# server keep using the old plugin while that version stays installed.
|
|
23
|
+
# {.message} names it when it reads such an import, and the fix: the install
|
|
24
|
+
# replaces that file with one that asks Bundler for the plugin (Story 19.4).
|
|
25
|
+
module BuildSkew
|
|
26
|
+
# The `vite.config` files Vite looks for, in its order.
|
|
27
|
+
VITE_CONFIGS = %w[vite.config.js vite.config.mjs vite.config.ts vite.config.cjs vite.config.mts
|
|
28
|
+
vite.config.cts].freeze
|
|
29
|
+
# A string literal naming ruact's plugin entry, as `ruact:install` writes it
|
|
30
|
+
# (no template interpolation: a path built in JavaScript is not read).
|
|
31
|
+
PLUGIN_IMPORT = %r{(["'`])([^"'`$\n]*vite-plugin-ruact/index\.js)\1}
|
|
32
|
+
LOCK = Mutex.new
|
|
33
|
+
private_constant :LOCK
|
|
34
|
+
|
|
35
|
+
# Whether a build that recorded +build_version+ is another ruact's.
|
|
36
|
+
#
|
|
37
|
+
# @param build_version [String, nil] {ClientManifest#build_version}
|
|
38
|
+
# @return [Boolean]
|
|
39
|
+
def self.skewed?(build_version)
|
|
40
|
+
build_version != Ruact::VERSION
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# What happened and what to do, in one paragraph without the `[ruact]`
|
|
44
|
+
# prefix.
|
|
45
|
+
#
|
|
46
|
+
# @param build_version [String, nil] {ClientManifest#build_version}
|
|
47
|
+
# @param root [Pathname, String, nil] the app's root, where `vite.config.*` is read
|
|
48
|
+
# @return [String]
|
|
49
|
+
def self.message(build_version, root: default_root)
|
|
50
|
+
built = build_version ? "ruact #{build_version}" : "an older ruact, one that did not record its version"
|
|
51
|
+
"The page's JavaScript was built by #{built}, and this server is ruact #{Ruact::VERSION}: " \
|
|
52
|
+
"pages that stream keep their <Suspense> fallbacks, and other pages may break. " \
|
|
53
|
+
"#{remedy(root)}"
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# {.message}'s fix only: the `vite.config` import when it names another
|
|
57
|
+
# ruact's plugin, then the rebuild.
|
|
58
|
+
#
|
|
59
|
+
# @param root [Pathname, String, nil]
|
|
60
|
+
# @return [String]
|
|
61
|
+
def self.remedy(root = default_root)
|
|
62
|
+
rebuild = "Rebuild the JavaScript with this ruact (npm run build, or restart bin/dev in development) " \
|
|
63
|
+
"and deploy it together with the server."
|
|
64
|
+
imported = foreign_plugin_import(root)
|
|
65
|
+
return rebuild unless imported
|
|
66
|
+
|
|
67
|
+
config = imported[:config]
|
|
68
|
+
fix = if config == "vite.config.js"
|
|
69
|
+
"run `rails generate ruact:install`, which replaces a vite.config.js it generated with one that " \
|
|
70
|
+
"asks Bundler for the plugin (for one you edited, it prints the change to make)"
|
|
71
|
+
else
|
|
72
|
+
"load it from the gem Bundler has — `rails generate ruact:install` prints the change to make in #{config}"
|
|
73
|
+
end
|
|
74
|
+
"#{config} imports ruact's Vite plugin from #{imported[:path]}, another installation of ruact: " \
|
|
75
|
+
"#{fix}, then: #{rebuild}"
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# The message as a banner across the top of the page, in development.
|
|
79
|
+
# Plain HTML with inline style, as the warning that the Vite dev server is
|
|
80
|
+
# down: it cannot depend on the JavaScript that is the problem.
|
|
81
|
+
#
|
|
82
|
+
# @param message [String] {.message}
|
|
83
|
+
# @return [String]
|
|
84
|
+
def self.banner(message)
|
|
85
|
+
<<~HTML
|
|
86
|
+
<div role="alert" data-ruact-build-skew style="position:fixed;inset:0 0 auto 0;z-index:2147483647;padding:12px 16px;background:#7f1d1d;color:#fff;font:14px/1.4 system-ui,sans-serif">
|
|
87
|
+
<strong>[ruact]</strong> #{ERB::Util.html_escape(message)}
|
|
88
|
+
</div>
|
|
89
|
+
HTML
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Logs {.message} once per process for each version a build recorded, so a
|
|
93
|
+
# development server reloading its code does not repeat it, and keeps the
|
|
94
|
+
# message: `vite.config.*` is read once per version, not on every page.
|
|
95
|
+
#
|
|
96
|
+
# @param build_version [String, nil]
|
|
97
|
+
# @param logger [#warn, #error, nil]
|
|
98
|
+
# @param level [Symbol] the logger method
|
|
99
|
+
# @return [String, nil] the message, or nil when the build is this ruact's
|
|
100
|
+
def self.note(build_version, logger:, level: :warn)
|
|
101
|
+
return unless skewed?(build_version)
|
|
102
|
+
|
|
103
|
+
key = build_version.to_s
|
|
104
|
+
text, first = LOCK.synchronize do
|
|
105
|
+
@noted ||= {}
|
|
106
|
+
@noted.key?(key) ? [@noted[key], false] : [@noted[key] = message(build_version), true]
|
|
107
|
+
end
|
|
108
|
+
logger&.public_send(level, "[ruact] #{text}") if first
|
|
109
|
+
text
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Forgets what it logged (specs).
|
|
113
|
+
#
|
|
114
|
+
# @api private
|
|
115
|
+
def self.reset!
|
|
116
|
+
LOCK.synchronize { @noted = nil }
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# The first `vite.config.*` under +root+ whose ruact plugin import names a
|
|
120
|
+
# file other than this gem's, or nil — none found, no such import, or the
|
|
121
|
+
# import is this gem's. Read only for the message, never to decide.
|
|
122
|
+
#
|
|
123
|
+
# @api private
|
|
124
|
+
# @return [Hash{Symbol => String}, nil] `{ config:, path: }`
|
|
125
|
+
def self.foreign_plugin_import(root)
|
|
126
|
+
return nil unless root
|
|
127
|
+
|
|
128
|
+
VITE_CONFIGS.each do |name|
|
|
129
|
+
config = File.join(root.to_s, name)
|
|
130
|
+
next unless File.file?(config)
|
|
131
|
+
|
|
132
|
+
match = File.binread(config).force_encoding(Encoding::UTF_8).scrub.match(PLUGIN_IMPORT)
|
|
133
|
+
return nil unless match
|
|
134
|
+
|
|
135
|
+
imported = match[2]
|
|
136
|
+
resolved = imported.start_with?(".") ? File.expand_path(imported, root.to_s) : imported
|
|
137
|
+
return nil if same_file?(resolved, Ruact.vite_plugin_path)
|
|
138
|
+
|
|
139
|
+
return { config: name, path: imported }
|
|
140
|
+
end
|
|
141
|
+
nil
|
|
142
|
+
rescue StandardError
|
|
143
|
+
# Read only for the message: whatever goes wrong, the message goes
|
|
144
|
+
# without the sentence.
|
|
145
|
+
nil
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def self.same_file?(one, other)
|
|
149
|
+
real(one) == real(other)
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def self.real(path)
|
|
153
|
+
File.realpath(path)
|
|
154
|
+
rescue SystemCallError
|
|
155
|
+
File.expand_path(path)
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def self.default_root
|
|
159
|
+
Rails.root if defined?(Rails) && Rails.respond_to?(:root)
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
private_class_method :same_file?, :real, :default_root
|
|
163
|
+
end
|
|
164
|
+
end
|
|
@@ -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
|