ruact 0.0.21 → 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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e96181a35f53a1a25da4cd4db1ca655aeff15cdd5190f56aaafb97f1a2a19361
4
- data.tar.gz: e9034d5a9f16dee7a5a319483364dbbf049a6b0aecf63073c3efcfe66ce6baa4
3
+ metadata.gz: 769b242b14222272ed72cf6fedeaf732de780ce3d57d4f6b916ecf8a4cd17faa
4
+ data.tar.gz: d639e44c10c4524ece71cebbb8d3e135484ec6715792492b17a986347937a6fd
5
5
  SHA512:
6
- metadata.gz: c90a2671ac0774bd51f726af1dc22ab58ba048ee6354b1078888ad2704289214caa6b6189b8909c72ab074546a37dc80676fb49576e630d09d1629eb09ff22ba
7
- data.tar.gz: e71ea90b4a5ee1450fd5c772fe7a99c9641b5febb8465ea0e7649fc1ab00db670fcdd3333b34bffbcc870bb0d719deb9b236f40769e6277097f0a3cdaa0bc115
6
+ metadata.gz: 03f1a1c07dd0ee8594765e72f6e2d54762c6ac336a74929b2ce9fe531b6fff2c4ca15a2c87c81e7e4fada9dd645ab9e46c12e7f09b9b556c5055db76d3086f6f
7
+ data.tar.gz: '03931869edf24e89d2cf1f74eae2b434973859319434a12ec88840620359a1edfa5d473374f071b6a03620d5280e01f7936fc270f82fa11e2d5027e52de7e131'
data/CHANGELOG.md CHANGED
@@ -7,6 +7,23 @@ 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
+
10
27
  ## [0.0.21] - 2026-10-15
11
28
 
12
29
  ### Changed
@@ -544,7 +561,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
544
561
 
545
562
  - **E2E test app** — `e2e/` Rails app (no DB, in-memory Post model) with full CRUD system tests validating the complete request cycle.
546
563
 
547
- [Unreleased]: https://github.com/luizcg/ruact/compare/v0.0.21...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
548
566
  [0.0.21]: https://github.com/luizcg/ruact/releases/tag/v0.0.21
549
567
  [0.0.20]: https://github.com/luizcg/ruact/releases/tag/v0.0.20
550
568
  [0.0.19]: https://github.com/luizcg/ruact/releases/tag/v0.0.19
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
+ });
@@ -140,8 +140,8 @@ duration, or a deliberate `expires_in: nil` for a non-expiring token.
140
140
 
141
141
  ## Verify your work
142
142
 
143
- - `bin/rails ruact:doctor` — checks install/config health; exits 1 on failure.
144
- 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.
145
145
  - `bin/rails ruact:routes -- --json` — the accessor/route table (each accessor's
146
146
  name, kind, verb, path, params) as JSON, from the same route table codegen
147
147
  reads; bare `ruact:routes` prints a compact human table.
@@ -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
@@ -37,7 +37,19 @@ module Ruact
37
37
  # existing manifest + reader is unaffected). {#contract_for} reads it for the
38
38
  # Story 13.5 preprocess-time call-site validator; +nil+ means "no contract →
39
39
  # no validation" (fail open).
40
+ #
41
+ # Story 19-3 — the Vite plugin also records which ruact built the page's
42
+ # JavaScript, under a key no component can have (a component's is PascalCase
43
+ # or +folder/_snake+):
44
+ #
45
+ # "__ruact__": { "version": "0.0.22" }
46
+ #
47
+ # {#build_version} reads it; it is not a component, for any lookup. A manifest
48
+ # written by 0.0.21 or before has no such key.
40
49
  class ClientManifest
50
+ # The manifest key of the build's record (Story 19-3).
51
+ BUILD_KEY = "__ruact__"
52
+
41
53
  # The +chunks+ of every import row (see {#resolve}). One shared frozen
42
54
  # array: a render resolves each component once, and the allocation guard
43
55
  # counts every array.
@@ -129,6 +141,13 @@ module Ruact
129
141
  entry && entry["contract"]
130
142
  end
131
143
 
144
+ # Story 19-3 — the ruact version whose Vite plugin wrote this manifest, or
145
+ # +nil+ when it records none (a build by 0.0.21 or before, or a plugin that
146
+ # could not read its gem's version).
147
+ #
148
+ # @return [String, nil]
149
+ attr_reader :build_version
150
+
132
151
  # Load from a file path (JSON).
133
152
  # Pre-warms the reference cache and freezes the manifest so it cannot be
134
153
  # mutated at runtime (AC#5). Pre-warming is required because Ruby's freeze
@@ -138,7 +157,7 @@ module Ruact
138
157
  raw = File.read(path)
139
158
  data = JSON.parse(raw)
140
159
  manifest = from_hash(data)
141
- data.each_key { |name| manifest.reference_for(name) }
160
+ data.each_key { |name| manifest.reference_for(name) unless name == BUILD_KEY }
142
161
  manifest.freeze
143
162
  end
144
163
 
@@ -147,9 +166,16 @@ module Ruact
147
166
  # first-lookup path works even when +data+ is empty (otherwise
148
167
  # +reference_for+ would raise +FrozenError+ trying to memoize on a
149
168
  # frozen instance).
169
+ #
170
+ # Story 19-3 — the build's record ({BUILD_KEY}) is taken out of the entries
171
+ # (without changing the Hash given) and kept as {#build_version}.
150
172
  def self.from_hash(data)
151
173
  manifest = new
174
+ recorded = data.is_a?(Hash) && data.key?(BUILD_KEY)
175
+ build = data[BUILD_KEY] if recorded
176
+ data = data.except(BUILD_KEY) if recorded
152
177
  manifest.instance_variable_set(:@data, data)
178
+ manifest.instance_variable_set(:@build_version, build.is_a?(Hash) ? build["version"]&.to_s : nil)
153
179
  manifest.instance_variable_set(:@reference_cache, {})
154
180
  manifest
155
181
  end
@@ -106,6 +106,15 @@ module Ruact
106
106
  ManifestResolver.resolve
107
107
  end
108
108
 
109
+ # Story 19-3 — {BuildSkew.note} for the manifest this render resolved: the
110
+ # message when its build is another ruact's (logged once per version),
111
+ # else nil.
112
+ def __ruact_build_skew(manifest)
113
+ return unless manifest.respond_to?(:build_version)
114
+
115
+ BuildSkew.note(manifest.build_version, logger: logger)
116
+ end
117
+
109
118
  # Only activate RSC rendering when the matching .html.erb template exists AND
110
119
  # the client will accept an HTML response (AC FR26; Story 10.0).
111
120
  # `request.format.html?` is false for a `*/*` wildcard (curl/bots/
@@ -177,7 +186,12 @@ module Ruact
177
186
  # its headers on the first row.
178
187
  self.status = status if status
179
188
 
180
- pipeline = RenderPipeline.new(ruact_manifest,
189
+ manifest = ruact_manifest
190
+ # Story 19-3 — in development, the page says loudly when its JavaScript
191
+ # was built by another ruact (`ruact_js_assets` reads this). The manifest
192
+ # this render resolved anyway: no other request to the Vite dev server.
193
+ @ruact_build_skew = __ruact_build_skew(manifest) if Rails.env.development?
194
+ pipeline = RenderPipeline.new(manifest,
181
195
  controller_path: controller_path, logger: logger,
182
196
  development: Flight::Renderer.development_default)
183
197
  # Story 19-1 — in an action that streams, the first load and a
data/lib/ruact/doctor.rb CHANGED
@@ -145,14 +145,25 @@ module Ruact
145
145
 
146
146
  private
147
147
 
148
+ # Story 19-3 — the manifest also says which ruact built the JavaScript; a
149
+ # build by another one (or by one that recorded none, 0.0.21 and before)
150
+ # fails: the page then runs another release's runtime against this server.
148
151
  def check_manifest
149
152
  path = manifest_path
150
- if Pathname(path).exist?
151
- [:pass, "Manifest found at #{path}"]
152
- else
153
- [:fail, "Manifest not found — run vite build",
154
- "Run vite build (or bin/dev) to generate the client manifest."]
153
+ unless Pathname(path).exist?
154
+ return [:fail, "Manifest not found — run vite build",
155
+ "Run vite build (or bin/dev) to generate the client manifest."]
155
156
  end
157
+
158
+ built = ClientManifest.from_hash(JSON.parse(File.read(path))).build_version
159
+ return [:pass, "Manifest found at #{path} (built by ruact #{built})"] unless BuildSkew.skewed?(built)
160
+
161
+ by = built ? "ruact #{built}" : "a ruact that did not record its version (0.0.21 or before)"
162
+ [:fail, "Manifest at #{path} was built by #{by}; this app runs ruact #{Ruact::VERSION}",
163
+ BuildSkew.remedy(Rails.root)]
164
+ rescue JSON::ParserError => e
165
+ [:fail, "Manifest at #{path} is not valid JSON (#{e.message.lines.first.strip})",
166
+ "Rebuild it: run vite build (or bin/dev)."]
156
167
  end
157
168
 
158
169
  def check_vite
data/lib/ruact/railtie.rb CHANGED
@@ -80,6 +80,7 @@ module Ruact
80
80
 
81
81
  if manifest_path.exist?
82
82
  Ruact.manifest = Ruact::ClientManifest.load(manifest_path)
83
+ Ruact::Railtie.note_build_skew!(Ruact.manifest)
83
84
  else
84
85
  Ruact::Railtie.check_manifest!(manifest_path)
85
86
  end
@@ -209,6 +210,19 @@ module Ruact
209
210
  false
210
211
  end
211
212
 
213
+ # Story 19-3 — the built JavaScript is another ruact's: one error line in
214
+ # the log, in every environment, once per process for each version the
215
+ # build recorded (`to_prepare` runs again on every reload in development).
216
+ # It does not stop the boot: pages without a `<Suspense>` that streams may
217
+ # well work, and stopping was not decided.
218
+ #
219
+ # @param manifest [Ruact::ClientManifest]
220
+ # @return [void]
221
+ def self.note_build_skew!(manifest)
222
+ Ruact::BuildSkew.note(manifest.build_version, logger: Rails.logger, level: :error)
223
+ nil
224
+ end
225
+
212
226
  # Checks whether the manifest exists and either warns (dev) or raises (prod).
213
227
  # Extracted as a class method for direct testability without a full Rails app.
214
228
  def self.check_manifest!(manifest_path)
data/lib/ruact/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Ruact
4
- VERSION = "0.0.21"
4
+ VERSION = "0.0.22"
5
5
  end
@@ -265,35 +265,51 @@ module Ruact
265
265
  # never show. The bootstrap then reads the rows the document pushes while
266
266
  # it is still open.
267
267
  def ruact_vite_tags
268
- if Rails.env.development? && vite_dev_running?
269
- # @vitejs/plugin-react normally injects this preamble by processing index.html.
270
- # Since our HTML is generated by Rails (not Vite), we inject it manually.
271
- # Without it, every JSX file throws "can't detect preamble" at runtime.
272
- # One async module, so the preamble still runs before any component
273
- # module: it installs the preamble, then imports the client and the
274
- # bootstrap, in that order.
275
- dev_server = Ruact.config.vite_dev_server.chomp("/")
276
- <<~HTML
277
- <script type="module" async#{__ruact_nonce_attr}>
278
- import RefreshRuntime from '#{dev_server}/@react-refresh';
279
- RefreshRuntime.injectIntoGlobalHook(window);
280
- window.$RefreshReg$ = () => {};
281
- window.$RefreshSig$ = () => (type) => type;
282
- window.__vite_plugin_react_preamble_installed__ = true;
283
- await import('#{dev_server}/@vite/client');
284
- await import('#{ruact_bootstrap_dev_url}');
285
- </script>
286
- HTML
287
- else
288
- # Production: read the hashed URL from the Vite manifest, keyed on the
289
- # virtual entry id (the manifest key Vite emits for the virtual input).
290
- entry = vite_manifest_entry(Ruact.bootstrap_virtual_id)
291
- src = entry ? "/assets/#{entry['file']}" : "/assets/application.js"
292
- tag = %(<script type="module" async src="#{src}"#{__ruact_nonce_attr}></script>)
293
- # Story 17-0k — in development with neither the Vite dev server nor a
294
- # build, that script 404s and the page stays empty with no error.
295
- Rails.env.development? && entry.nil? ? "#{ruact_vite_down_warning}\n#{tag}" : tag
296
- end
268
+ return ruact_build_skew_warning + ruact_vite_dev_tags if Rails.env.development? && vite_dev_running?
269
+
270
+ # Production: read the hashed URL from the Vite manifest, keyed on the
271
+ # virtual entry id (the manifest key Vite emits for the virtual input).
272
+ entry = vite_manifest_entry(Ruact.bootstrap_virtual_id)
273
+ src = entry ? "/assets/#{entry['file']}" : "/assets/application.js"
274
+ tag = %(<script type="module" async src="#{src}"#{__ruact_nonce_attr}></script>)
275
+ return tag unless Rails.env.development?
276
+
277
+ # Story 17-0k — in development with neither the Vite dev server nor a
278
+ # build, that script 404s and the page stays empty with no error. The
279
+ # component manifest on disk then describes no JavaScript the page runs:
280
+ # no word of its version (Story 19-3).
281
+ entry.nil? ? "#{ruact_vite_down_warning}\n#{tag}" : ruact_build_skew_warning + tag
282
+ end
283
+
284
+ # The development entry: the react-refresh preamble, `@vite/client`, then
285
+ # the bootstrap, from the Vite dev server.
286
+ def ruact_vite_dev_tags
287
+ # @vitejs/plugin-react normally injects this preamble by processing index.html.
288
+ # Since our HTML is generated by Rails (not Vite), we inject it manually.
289
+ # Without it, every JSX file throws "can't detect preamble" at runtime.
290
+ # One async module, so the preamble still runs before any component
291
+ # module: it installs the preamble, then imports the client and the
292
+ # bootstrap, in that order.
293
+ dev_server = Ruact.config.vite_dev_server.chomp("/")
294
+ <<~HTML
295
+ <script type="module" async#{__ruact_nonce_attr}>
296
+ import RefreshRuntime from '#{dev_server}/@react-refresh';
297
+ RefreshRuntime.injectIntoGlobalHook(window);
298
+ window.$RefreshReg$ = () => {};
299
+ window.$RefreshSig$ = () => (type) => type;
300
+ window.__vite_plugin_react_preamble_installed__ = true;
301
+ await import('#{dev_server}/@vite/client');
302
+ await import('#{ruact_bootstrap_dev_url}');
303
+ </script>
304
+ HTML
305
+ end
306
+
307
+ # Story 19-3 — the page's JavaScript was built by another ruact: the
308
+ # message the controller kept for this render, on the page
309
+ # ({Ruact::BuildSkew.banner}). Empty on a page no ruact render produced.
310
+ def ruact_build_skew_warning
311
+ message = instance_variable_defined?(:@ruact_build_skew) ? @ruact_build_skew : nil
312
+ message ? BuildSkew.banner(message) : ""
297
313
  end
298
314
 
299
315
  # Story 17-0k — `bin/rails server` on its own, once Vite has run before:
data/lib/ruact.rb CHANGED
@@ -18,6 +18,7 @@ require_relative "ruact/render_context"
18
18
  require_relative "ruact/html_converter"
19
19
  require_relative "ruact/client_manifest"
20
20
  require_relative "ruact/manifest_resolver"
21
+ require_relative "ruact/build_skew"
21
22
  require_relative "ruact/render_pipeline"
22
23
  require_relative "ruact/deferred_render"
23
24
  require_relative "ruact/cached_blocks"
@@ -61,8 +62,10 @@ module Ruact
61
62
  end
62
63
 
63
64
  # Returns the absolute path to the Vite plugin bundled inside this gem.
64
- # Use this in vite.config.js: import ruact from '<%= Ruact.vite_plugin_path %>'
65
- # Re-run `rails generate ruact:install` after gem upgrades to refresh the path.
65
+ # The vite.config.js `rails generate ruact:install` writes asks for it
66
+ # through Bundler each time Vite starts (Story 19.4) —
67
+ # `bundle exec ruby -e "require 'ruact'; print Ruact.vite_plugin_path"` —
68
+ # so after `bundle update ruact` the next build uses the new gem's plugin.
66
69
  #
67
70
  # @return [String] absolute path to vendor/javascript/vite-plugin-ruact/index.js
68
71
  def vite_plugin_path
@@ -0,0 +1,101 @@
1
+ // Story 19-3 — the manifest says which ruact built it. Every manifest the
2
+ // plugin writes or serves (the build, the dev server's file, its
3
+ // GET /__ruact/manifest) carries `__ruact__: { version }`, the gem's version read
4
+ // from `lib/ruact/version.rb` beside the plugin; the in-memory manifest the
5
+ // registry is generated from never does.
6
+
7
+ import { describe, it, expect, beforeEach, afterEach } from "vitest";
8
+ import fs from "node:fs";
9
+ import os from "node:os";
10
+ import path from "node:path";
11
+ import { fileURLToPath } from "node:url";
12
+ import ruact, { BUILD_KEY, readRuactVersion, withBuild, buildManifest, generateRegistrySource } from "./index.js";
13
+
14
+ const here = path.dirname(fileURLToPath(import.meta.url));
15
+ const GEM_VERSION = fs
16
+ .readFileSync(path.resolve(here, "../../../lib/ruact/version.rb"), "utf8")
17
+ .match(/VERSION = "([^"]+)"/)[1];
18
+
19
+ let dir;
20
+ beforeEach(() => {
21
+ dir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "ruact-build-version-")));
22
+ const file = path.join(dir, "app/javascript/components/LikeButton.jsx");
23
+ fs.mkdirSync(path.dirname(file), { recursive: true });
24
+ fs.writeFileSync(file, `"use client";\nexport function LikeButton() { return null; }\n`);
25
+ });
26
+ afterEach(() => fs.rmSync(dir, { recursive: true, force: true }));
27
+
28
+ function server() {
29
+ const middlewares = new Map();
30
+ const handlers = {};
31
+ return {
32
+ middlewares: { use: (route, fn) => middlewares.set(route, fn) },
33
+ moduleGraph: { getModuleById: () => null, invalidateModule: () => {} },
34
+ ws: { send: () => {} },
35
+ watcher: { add: () => {}, on: (event, fn) => (handlers[event] ||= []).push(fn) },
36
+ get(route) {
37
+ let body = "";
38
+ middlewares.get(route)({ url: route, method: "GET" }, { setHeader: () => {}, end: (b) => (body = b) }, () => {});
39
+ return JSON.parse(body);
40
+ },
41
+ change(file) {
42
+ for (const fn of handlers.change) fn(file);
43
+ },
44
+ };
45
+ }
46
+
47
+ async function boot() {
48
+ const warnings = [];
49
+ const plugin = ruact();
50
+ await plugin.configResolved({ root: dir, logger: { info: () => {}, warn: (m) => warnings.push(m) } });
51
+ await plugin.buildStart({});
52
+ const dev = server();
53
+ await plugin.configureServer(dev);
54
+ return { dev, warnings };
55
+ }
56
+
57
+ const written = () => JSON.parse(fs.readFileSync(path.join(dir, "public/react-client-manifest.json"), "utf8"));
58
+
59
+ describe("the build's version in the manifest (Story 19-3)", () => {
60
+ it("reads the gem's version from lib/ruact/version.rb", () => {
61
+ expect(readRuactVersion()).toBe(GEM_VERSION);
62
+ });
63
+
64
+ it("is null for a plugin that is not inside a gem", () => {
65
+ expect(readRuactVersion(path.join(dir, "lib/ruact/version.rb"))).toBeNull();
66
+ fs.mkdirSync(path.join(dir, "lib/ruact"), { recursive: true });
67
+ fs.writeFileSync(path.join(dir, "lib/ruact/version.rb"), "module Ruact; end\n");
68
+ expect(readRuactVersion(path.join(dir, "lib/ruact/version.rb"))).toBeNull();
69
+ });
70
+
71
+ it("the file buildStart writes carries it first", async () => {
72
+ const { warnings } = await boot();
73
+ const manifest = written();
74
+ expect(Object.keys(manifest)).toEqual([BUILD_KEY, "LikeButton"]);
75
+ expect(manifest[BUILD_KEY]).toEqual({ version: GEM_VERSION });
76
+ expect(warnings).toEqual([]);
77
+ });
78
+
79
+ it("GET /__ruact/manifest carries it, and the file the watcher rewrites does too", async () => {
80
+ const { dev } = await boot();
81
+ expect(dev.get("/__ruact/manifest")[BUILD_KEY]).toEqual({ version: GEM_VERSION });
82
+
83
+ const added = path.join(dir, "app/javascript/components/CommentBox.jsx");
84
+ fs.writeFileSync(added, `"use client";\nexport function CommentBox() { return null; }\n`);
85
+ dev.change(added);
86
+ expect(Object.keys(written())).toEqual([BUILD_KEY, "CommentBox", "LikeButton"]);
87
+ expect(Object.keys(dev.get("/__ruact/manifest"))).toEqual([BUILD_KEY, "CommentBox", "LikeButton"]);
88
+ });
89
+
90
+ it("never reaches the registry", () => {
91
+ const manifest = buildManifest(path.join(dir, "app/javascript/components"));
92
+ expect(manifest).not.toHaveProperty(BUILD_KEY);
93
+ expect(generateRegistrySource(manifest)).not.toContain(BUILD_KEY);
94
+ });
95
+
96
+ it("without a version, the manifest is the components only", () => {
97
+ const manifest = { LikeButton: { id: "/LikeButton.jsx", name: "LikeButton", chunks: [] } };
98
+ expect(withBuild(manifest, null)).toBe(manifest);
99
+ expect(Object.keys(withBuild(manifest, "1.2.3"))).toEqual([BUILD_KEY, "LikeButton"]);
100
+ });
101
+ });
@@ -69,6 +69,33 @@ const RESOLVED_BOOTSTRAP_ID = "\0" + BOOTSTRAP_VIRTUAL_ID;
69
69
  // point at the gem copy, regardless of the app's cwd.
70
70
  const RUNTIME_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), "runtime");
71
71
 
72
+ // Story 19-3 — the build says which ruact built it. The plugin ships inside the
73
+ // gem (`<gem>/vendor/javascript/vite-plugin-ruact/`), so the gem's version is
74
+ // `<gem>/lib/ruact/version.rb`, the one file `bin/release` writes. The manifest
75
+ // carries it under a key no component can have (a component's is PascalCase or
76
+ // `folder/_snake`); the server compares it with its own and says so loudly when
77
+ // the page's JavaScript is another release's.
78
+ export const BUILD_KEY = "__ruact__";
79
+ const GEM_VERSION_FILE = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../../lib/ruact/version.rb");
80
+
81
+ // The gem version beside this plugin, or null when the plugin is not inside a
82
+ // gem (a copy elsewhere): the manifest then records none, and the server takes
83
+ // the build for an older release's, which is what it is told to warn about.
84
+ export function readRuactVersion(file = GEM_VERSION_FILE) {
85
+ try {
86
+ return fs.readFileSync(file, "utf8").match(/VERSION = "([^"]+)"/)?.[1] ?? null;
87
+ } catch {
88
+ return null;
89
+ }
90
+ }
91
+
92
+ // The manifest as written and served: the build's record first, then the
93
+ // components. The in-memory manifest never holds it — the registry and the
94
+ // build's chunk matching iterate the components only.
95
+ export function withBuild(manifest, version) {
96
+ return version ? { [BUILD_KEY]: { version }, ...manifest } : manifest;
97
+ }
98
+
72
99
  export default function ruact(options = {}) {
73
100
  const {
74
101
  componentsDir = "app/javascript/components",
@@ -78,6 +105,7 @@ export default function ruact(options = {}) {
78
105
  let root;
79
106
  let manifest = {};
80
107
  let flightClient; // { source: "app" | "vendored", version, mode }
108
+ let ruactVersion = null;
81
109
 
82
110
  return installServerFunctionsHooks({
83
111
  name: "vite-plugin-ruact",
@@ -94,6 +122,13 @@ export default function ruact(options = {}) {
94
122
  flightClient = resolveFlightClient(root, config.isProduction ? "production" : "development");
95
123
  // Dev server and build alike, so a build log shows it too.
96
124
  config.logger?.info?.(`[ruact] Flight client: ${describeFlightClient(flightClient)}`);
125
+ ruactVersion = readRuactVersion();
126
+ if (!ruactVersion) {
127
+ config.logger?.warn?.(
128
+ `[ruact] could not read the ruact version from ${GEM_VERSION_FILE}: the manifest will not say which ` +
129
+ "ruact built it, and the server will warn that this build is another release's.",
130
+ );
131
+ }
97
132
  },
98
133
 
99
134
  // Story 10.1b / 14.2 — resolve ruact's virtual module ids.
@@ -121,7 +156,7 @@ export default function ruact(options = {}) {
121
156
  // During dev: build the manifest from source files
122
157
  buildStart() {
123
158
  manifest = buildManifest(path.resolve(root, componentsDir));
124
- writeManifest(path.resolve(root, manifestOutput), manifest);
159
+ writeManifest(path.resolve(root, manifestOutput), withBuild(manifest, ruactVersion));
125
160
  },
126
161
 
127
162
  // During build: update with hashed chunk URLs from the bundle
@@ -180,7 +215,7 @@ export default function ruact(options = {}) {
180
215
  delete entry._sourceFile;
181
216
  }
182
217
 
183
- writeManifest(path.resolve(root, manifestOutput), final);
218
+ writeManifest(path.resolve(root, manifestOutput), withBuild(final, ruactVersion));
184
219
  },
185
220
 
186
221
  // Dev server: watch components dir and rebuild manifest on change. Story
@@ -207,13 +242,13 @@ export default function ruact(options = {}) {
207
242
  out[name] = rest;
208
243
  }
209
244
  res.setHeader("Content-Type", "application/json");
210
- res.end(JSON.stringify(out));
245
+ res.end(JSON.stringify(withBuild(out, ruactVersion)));
211
246
  });
212
247
 
213
248
  const rebuild = (file) => {
214
249
  if (!file.startsWith(dir)) return;
215
250
  manifest = buildManifest(dir);
216
- writeManifest(path.resolve(root, manifestOutput), manifest);
251
+ writeManifest(path.resolve(root, manifestOutput), withBuild(manifest, ruactVersion));
217
252
  const mod = server.moduleGraph.getModuleById(RESOLVED_REGISTRY_ID);
218
253
  if (mod) {
219
254
  server.moduleGraph.invalidateModule(mod);
@@ -20,6 +20,8 @@ import path from "node:path";
20
20
  import { pathToFileURL } from "node:url";
21
21
  import { build } from "vite";
22
22
  import {
23
+ BUILD_KEY,
24
+ readRuactVersion,
23
25
  buildManifest,
24
26
  generateRegistrySource,
25
27
  REGISTRY_VIRTUAL_ID,
@@ -143,9 +145,13 @@ describe("prod id-match invariant — real vite build (Story 10.1b AC2, LOAD-BEA
143
145
  });
144
146
 
145
147
  // The manifest the gem will read (ids === Flight moduleIds).
146
- const manifest = JSON.parse(
148
+ const written = JSON.parse(
147
149
  fs.readFileSync(path.join(dir, "public/react-client-manifest.json"), "utf8")
148
150
  );
151
+ // Story 19-3 — the build's record comes first, and is not a component.
152
+ const { [BUILD_KEY]: build19_3, ...manifest } = written;
153
+ expect(Object.keys(written)[0]).toBe(BUILD_KEY);
154
+ expect(build19_3).toEqual({ version: readRuactVersion() });
149
155
  const manifestIds = new Set(Object.values(manifest).map((e) => e.id));
150
156
 
151
157
  // Prod ids stay source-relative (eager-inlined components → no facade chunk
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ruact
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.0.21
4
+ version: 0.0.22
5
5
  platform: ruby
6
6
  authors:
7
7
  - Luiz Garcia
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-10-15 00:00:00.000000000 Z
11
+ date: 2026-10-16 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: nokogiri
@@ -43,6 +43,7 @@ files:
43
43
  - RELEASING.md
44
44
  - SECURITY.md
45
45
  - lib/generators/ruact/install/install_generator.rb
46
+ - lib/generators/ruact/install/legacy/vite.config.pinned.js
46
47
  - lib/generators/ruact/install/templates/AGENTS.md.tt
47
48
  - lib/generators/ruact/install/templates/Procfile.dev.tt
48
49
  - lib/generators/ruact/install/templates/dev.tt
@@ -71,6 +72,7 @@ files:
71
72
  - lib/generators/ruact/scaffold/templates/views/new.html.erb.tt
72
73
  - lib/generators/ruact/scaffold/templates/views/show.html.erb.tt
73
74
  - lib/ruact.rb
75
+ - lib/ruact/build_skew.rb
74
76
  - lib/ruact/cached_blocks.rb
75
77
  - lib/ruact/client_manifest.rb
76
78
  - lib/ruact/component_attributes.rb
@@ -159,6 +161,7 @@ files:
159
161
  - vendor/javascript/ruact-server-functions-runtime/package.json
160
162
  - vendor/javascript/ruact-server-functions-runtime/usequery.test.mjs
161
163
  - vendor/javascript/vite-plugin-ruact/bootstrap.test.mjs
164
+ - vendor/javascript/vite-plugin-ruact/build-version.test.mjs
162
165
  - vendor/javascript/vite-plugin-ruact/flight-conformance.test.mjs
163
166
  - vendor/javascript/vite-plugin-ruact/index.js
164
167
  - vendor/javascript/vite-plugin-ruact/manifest-contract.test.mjs