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.
Files changed (39) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +46 -1
  3. data/CONTRIBUTING.md +2 -2
  4. data/lib/generators/ruact/install/install_generator.rb +121 -19
  5. data/lib/generators/ruact/install/legacy/vite.config.pinned.js +28 -0
  6. data/lib/generators/ruact/install/templates/AGENTS.md.tt +9 -9
  7. data/lib/generators/ruact/install/templates/vite.config.js.tt +56 -21
  8. data/lib/ruact/build_skew.rb +164 -0
  9. data/lib/ruact/cached_blocks.rb +30 -0
  10. data/lib/ruact/client_manifest.rb +27 -1
  11. data/lib/ruact/controller/delivery.rb +24 -9
  12. data/lib/ruact/controller/streaming.rb +64 -0
  13. data/lib/ruact/controller.rb +21 -5
  14. data/lib/ruact/declared_content.rb +33 -0
  15. data/lib/ruact/deferred_render.rb +55 -9
  16. data/lib/ruact/doctor.rb +40 -6
  17. data/lib/ruact/erb_preprocessor.rb +8 -2
  18. data/lib/ruact/flight/react_element.rb +9 -4
  19. data/lib/ruact/flight/renderer.rb +4 -1
  20. data/lib/ruact/html_converter.rb +21 -3
  21. data/lib/ruact/markers.rb +11 -8
  22. data/lib/ruact/query_watch.rb +106 -0
  23. data/lib/ruact/railtie.rb +14 -0
  24. data/lib/ruact/render_context.rb +23 -11
  25. data/lib/ruact/render_pipeline.rb +3 -1
  26. data/lib/ruact/version.rb +1 -1
  27. data/lib/ruact/view_helper/suspense.rb +10 -2
  28. data/lib/ruact/view_helper.rb +56 -30
  29. data/lib/ruact.rb +8 -2
  30. data/vendor/javascript/vite-plugin-ruact/build-version.test.mjs +101 -0
  31. data/vendor/javascript/vite-plugin-ruact/index.js +39 -4
  32. data/vendor/javascript/vite-plugin-ruact/page-mount.test.mjs +10 -2
  33. data/vendor/javascript/vite-plugin-ruact/page-stream.test.mjs +29 -4
  34. data/vendor/javascript/vite-plugin-ruact/registry.test.mjs +7 -1
  35. data/vendor/javascript/vite-plugin-ruact/ruact-router.test.mjs +185 -0
  36. data/vendor/javascript/vite-plugin-ruact/runtime/dev-overlay.js +20 -6
  37. data/vendor/javascript/vite-plugin-ruact/runtime/page-stream.js +23 -3
  38. data/vendor/javascript/vite-plugin-ruact/runtime/ruact-router.js +64 -4
  39. metadata +8 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 69c5f93d5a39e751e7f69b953ffd1101660e464df4397cadee932c12ba81b4fd
4
- data.tar.gz: 518c3a0c1183cbc82fd13ca3d6d621502e28713b5d9dd21bd46beeca30ecc775
3
+ metadata.gz: 769b242b14222272ed72cf6fedeaf732de780ce3d57d4f6b916ecf8a4cd17faa
4
+ data.tar.gz: d639e44c10c4524ece71cebbb8d3e135484ec6715792492b17a986347937a6fd
5
5
  SHA512:
6
- metadata.gz: 115756074639ab4849ae2982cf7d4e13176f20f383302f8961b1ed5f09148e1bec74560f2e1f42ef2aa26262449d6c0536c89ce205748176bad9aa0f5006fbfb
7
- data.tar.gz: c55a816d7f40810470e42f86f8111ae3fc2e1e4ab150a31153626c26dedb8de1f76166483acb004722239d1696626bbe0bef218237bb34b665da7320e7e200c2
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.20...HEAD
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 imports the plugin by absolute path off the
18
- installed gem. That has one consequence worth stating up front, because it is what makes the rest of this
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 (or shows manual instructions if one exists)
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
- vite_config_file = Pathname(destination_root).join("vite.config.js")
269
+ return revoke_vite_config if behavior == :revoke
252
270
 
253
- # `--force` must actually regenerate (the message below promises it). The
254
- # guard skips the template ONLY when the file exists AND force was not
255
- # passed; with --force we fall through to `template`, which overwrites.
256
- if vite_config_file.exist? && !options[:force]
257
- say_status "notice", "vite.config.js already exists — add the plugin manually:", :yellow
258
- say " 1. At the top of vite.config.js, add:"
259
- say " import ruact from '#{Ruact.vite_plugin_path}';"
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
- # imports it by the absolute `Ruact.vite_plugin_path` and it uses only
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, so status,
82
- redirect, cookies, session and flash belong in the controller, not inside it.
81
+ under `ruact_streaming` its content runs AFTER the page is sent: status,
82
+ redirect, cookies, session, flash, `content_for` and locale/tenant/DB-role
83
+ scopes belong in the controller (`around_action`), not inside it or in a
84
+ middleware; a `cache` block goes inside a `<Suspense>`, never around one.
83
85
  In a loop of items holding components, give each item a `data-react-key`
84
86
  (the record's id): React matches them by position otherwise, silently.
85
87
  2. **`{}` props are Ruby, not JavaScript.** `<Badge label={@post.title} />`
86
- evaluates `@post.title` as a Ruby expression in the ERB. No JS ternaries,
87
- no `{...spread}`. A quoted value (`title="Hi"`) is a
88
- plain string and a bare attribute is `true`, as in JSX.
88
+ evaluates `@post.title` as a Ruby expression: no JS ternaries, no `{...spread}`.
89
+ A quoted value (`title="Hi"`) is a plain string; a bare attribute is `true`.
89
90
  3. **One action, two response shapes.** A `Ruact::Server` non-GET action
90
91
  answers JSON — its instance variables, or `204`, or `{"$redirect": path}` —
91
92
  when the request's `Accept` header is exactly `application/json`, which is
@@ -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` — checks install/config health; exits 1 on failure.
143
- Append `-- --json` for the same checks as a machine-readable report.
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 — the shape may change; gate on their
148
- `schema_version` field (currently `0`), do not treat it as a stable contract.
148
+ - Both `--json` outputs are EXPERIMENTAL: gate on `schema_version` (now `0`).
149
149
  - `bin/rails ruact:server_functions:generate` — regenerates the TS module;
150
150
  exits 1 on a naming collision or an invalid name.
151
151
  - `bin/dev` — boots Rails AND Vite (both required: Vite serves the client components).
@@ -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
- export default defineConfig({
8
- plugins: [
9
- react(),
10
- ruact(), // scans app/javascript/components/ for "use client", emits react-client-manifest.json
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
- 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: '<%= Ruact.bootstrap_virtual_id %>',
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
- server: {
24
- port: 5173,
25
- strictPort: true,
26
- origin: 'http://localhost:5173',
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