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