ruact 0.0.20 → 0.0.22

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +46 -1
  3. data/CONTRIBUTING.md +2 -2
  4. data/lib/generators/ruact/install/install_generator.rb +121 -19
  5. data/lib/generators/ruact/install/legacy/vite.config.pinned.js +28 -0
  6. data/lib/generators/ruact/install/templates/AGENTS.md.tt +9 -9
  7. data/lib/generators/ruact/install/templates/vite.config.js.tt +56 -21
  8. data/lib/ruact/build_skew.rb +164 -0
  9. data/lib/ruact/cached_blocks.rb +30 -0
  10. data/lib/ruact/client_manifest.rb +27 -1
  11. data/lib/ruact/controller/delivery.rb +24 -9
  12. data/lib/ruact/controller/streaming.rb +64 -0
  13. data/lib/ruact/controller.rb +21 -5
  14. data/lib/ruact/declared_content.rb +33 -0
  15. data/lib/ruact/deferred_render.rb +55 -9
  16. data/lib/ruact/doctor.rb +40 -6
  17. data/lib/ruact/erb_preprocessor.rb +8 -2
  18. data/lib/ruact/flight/react_element.rb +9 -4
  19. data/lib/ruact/flight/renderer.rb +4 -1
  20. data/lib/ruact/html_converter.rb +21 -3
  21. data/lib/ruact/markers.rb +11 -8
  22. data/lib/ruact/query_watch.rb +106 -0
  23. data/lib/ruact/railtie.rb +14 -0
  24. data/lib/ruact/render_context.rb +23 -11
  25. data/lib/ruact/render_pipeline.rb +3 -1
  26. data/lib/ruact/version.rb +1 -1
  27. data/lib/ruact/view_helper/suspense.rb +10 -2
  28. data/lib/ruact/view_helper.rb +56 -30
  29. data/lib/ruact.rb +8 -2
  30. data/vendor/javascript/vite-plugin-ruact/build-version.test.mjs +101 -0
  31. data/vendor/javascript/vite-plugin-ruact/index.js +39 -4
  32. data/vendor/javascript/vite-plugin-ruact/page-mount.test.mjs +10 -2
  33. data/vendor/javascript/vite-plugin-ruact/page-stream.test.mjs +29 -4
  34. data/vendor/javascript/vite-plugin-ruact/registry.test.mjs +7 -1
  35. data/vendor/javascript/vite-plugin-ruact/ruact-router.test.mjs +185 -0
  36. data/vendor/javascript/vite-plugin-ruact/runtime/dev-overlay.js +20 -6
  37. data/vendor/javascript/vite-plugin-ruact/runtime/page-stream.js +23 -3
  38. data/vendor/javascript/vite-plugin-ruact/runtime/ruact-router.js +64 -4
  39. metadata +8 -2
@@ -0,0 +1,106 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ruact
4
+ # Story 19-2 (AC14) — when a `<Suspense>` runs past `suspense_timeout` with a
5
+ # query in flight, that query is cancelled on the database server.
6
+ #
7
+ # `Timeout` gives the block back at the limit, but a query the driver is
8
+ # waiting on keeps running on the server, and the connection stays busy with
9
+ # it: the next query on it — this response's, or the next request's once the
10
+ # connection is back in the pool — waits for it to end (measured with
11
+ # PostgreSQL: seconds). Inside a transaction even the unwinding waits, at its
12
+ # ROLLBACK. So at the limit the queries in flight on the connections the
13
+ # block's thread holds — or that it already gave back — are cancelled, from
14
+ # another thread if the block is stuck; the connection is free again within
15
+ # milliseconds.
16
+ #
17
+ # PostgreSQL only: `PG::Connection#cancel` is libpq's PQcancel, made to be
18
+ # called from another thread. Other adapters are left as they are; the
19
+ # database's own statement timeout is the limit there.
20
+ #
21
+ # Not cancelled: a query `load_async` runs. The async executor's thread holds
22
+ # its connection, not the block's, and nothing ties that query to the block
23
+ # that waits for it; it runs to its end. The statement timeout is the limit
24
+ # for those.
25
+ #
26
+ # Internal API: not part of the public compatibility contract.
27
+ class QueryWatch
28
+ # Who holds a connection the way Active Record records it: the thread, or
29
+ # the fiber under `isolation_level = :fiber`.
30
+ def self.current_owner
31
+ ::ActiveSupport::IsolatedExecutionState.context
32
+ end
33
+
34
+ # Watches +owner+'s connections for +limit+ seconds; nil when there is
35
+ # nothing to watch (no Active Record).
36
+ def self.start(owner, limit)
37
+ return nil unless defined?(::ActiveRecord::Base) && defined?(::PG::Connection)
38
+
39
+ new(owner, limit)
40
+ end
41
+
42
+ def initialize(owner, limit)
43
+ @lock = Mutex.new
44
+ @done = false
45
+ @cancelled = false
46
+ @watcher = Thread.new do
47
+ sleep(limit)
48
+ @lock.synchronize { cancel_queries_of(owner) unless @done }
49
+ end
50
+ end
51
+
52
+ # The block ran past the limit and was given back: cancel what its owner
53
+ # still has in flight now, whether or not the watcher got there first.
54
+ def cancel_now(owner)
55
+ @lock.synchronize { cancel_queries_of(owner) }
56
+ end
57
+
58
+ # Whether a query was cancelled: the error the block then raises
59
+ # (`ActiveRecord::QueryCanceled`) is the timeout's, not the content's. The
60
+ # watcher can get there before `Timeout` does.
61
+ def cancelled?
62
+ @cancelled
63
+ end
64
+
65
+ # The block ended (or was ended): nothing to cancel any more. Does not wait
66
+ # for a cancel under way, which opens a connection to the database.
67
+ def stop
68
+ @done = true
69
+ @watcher.kill
70
+ end
71
+
72
+ private
73
+
74
+ # The queries are found under each pool's lock and cancelled outside it: a
75
+ # cancel opens a connection to the database, and the pool's lock is every
76
+ # thread's checkout. A connection nobody holds could be handed to another
77
+ # request in between and have that request's query cancelled; the window
78
+ # is the time a cancel takes to reach the server, on the timeout path only.
79
+ def cancel_queries_of(owner)
80
+ in_flight(owner).each do |raw|
81
+ raw.cancel
82
+ @cancelled = true
83
+ rescue StandardError
84
+ nil
85
+ end
86
+ end
87
+
88
+ def in_flight(owner)
89
+ ::ActiveRecord::Base.connection_handler.connection_pool_list(:all).flat_map do |pool|
90
+ pool.synchronize { pool.connections.filter_map { |connection| query_of(connection, owner) } }
91
+ rescue StandardError
92
+ []
93
+ end
94
+ end
95
+
96
+ # The owner's own connection, or one already back in the pool: unwinding a
97
+ # `with_connection` gives the connection back with its query still running.
98
+ # A query in flight on a connection nobody holds is one nobody waits for.
99
+ def query_of(connection, owner)
100
+ return unless connection.owner.nil? || connection.owner.equal?(owner)
101
+
102
+ raw = connection.instance_variable_get(:@raw_connection)
103
+ raw if raw.is_a?(::PG::Connection) && raw.transaction_status == ::PG::PQTRANS_ACTIVE
104
+ end
105
+ end
106
+ end
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)
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "securerandom"
4
+
3
5
  module Ruact
4
6
  # Per-render mutable state holding the components encountered during ERB
5
7
  # evaluation, and — in an action that streams — the `<Suspense>` blocks whose
@@ -14,13 +16,19 @@ module Ruact
14
16
  # Internal API: not part of the public compatibility contract.
15
17
  class RenderContext
16
18
  # A `<Suspense>` block waiting to be rendered: the view it was written in,
17
- # the block, and the template Rails was rendering when it met the tag
18
- # (`t(".key")` and error messages read it).
19
- Block = Struct.new(:view, :block, :virtual_path, :template, :location)
19
+ # the block, the template Rails was rendering when it met the tag
20
+ # (`t(".key")` and error messages read it), and the local variables the
21
+ # block can see, as they were then (Story 19-2: a `for` loop reuses one
22
+ # variable, a local can be reassigned below the tag).
23
+ Block = Struct.new(:view, :block, :virtual_path, :template, :location, :locals)
20
24
 
21
25
  # @param deferring [Boolean] whether a `<Suspense>`'s content is rendered
22
26
  # after the shell (an action that streams) instead of in place
23
27
  def initialize(deferring: false)
28
+ # Story 19-2 — every marker this render writes carries it, so a marker a
29
+ # `cache` block stored in another render never passes for one of this
30
+ # render's: it would name another component, or another boundary's block.
31
+ @id = SecureRandom.hex(4)
24
32
  @components = []
25
33
  @deferring = deferring
26
34
  @blocks = []
@@ -35,25 +43,29 @@ module Ruact
35
43
 
36
44
  def register(name, props)
37
45
  # Frozen: the converter keys a Hash by it, which would copy an unfrozen one.
38
- token = "__RUACT_#{@components.length}__".freeze
46
+ token = "__RUACT_#{@id}_#{@components.length}__".freeze
39
47
  @components << { token: token, name: name, props: props }
40
48
  token
41
49
  end
42
50
 
43
- # Keeps a `<Suspense>` block for later and returns its index.
51
+ # Keeps a `<Suspense>` block for later and returns its key.
44
52
  #
45
53
  # @param block [Block]
46
- # @return [Integer]
54
+ # @return [String]
47
55
  def defer_block(block)
48
56
  @blocks << block
49
- @blocks.length - 1
57
+ "#{@id}-#{@blocks.length - 1}".freeze
50
58
  end
51
59
 
52
- # The block registered under +index+.
60
+ # The block registered under +key+, or nil when the key is not this
61
+ # render's — a marker that came out of a `cache` block another render wrote.
53
62
  #
54
- # @return [Block]
55
- def block(index)
56
- @blocks.fetch(index)
63
+ # @return [Block, nil]
64
+ def block(key)
65
+ id, index = key.to_s.split("-", 2)
66
+ return nil unless id == @id && index.to_s.match?(/\A\d+\z/)
67
+
68
+ @blocks[Integer(index, 10)]
57
69
  end
58
70
 
59
71
  # Whether the development message for a `<Suspense>` in an action that
@@ -66,7 +66,8 @@ module Ruact
66
66
  #
67
67
  # @example Pre-rendered HTML, streaming output
68
68
  # ctx = Ruact::RenderContext.new
69
- # pipeline.render({ html: "<!-- __RUACT_0__ -->", render_context: ctx }, mode: :stream)
69
+ # html = Ruact::Markers.component(ctx.register("NavBar", {}))
70
+ # pipeline.render({ html: html, render_context: ctx }, mode: :stream)
70
71
  # # => #<Enumerator: ...> (yields Flight rows lazily)
71
72
  def render(input, mode: :string)
72
73
  validate_mode!(mode)
@@ -232,6 +233,7 @@ module Ruact
232
233
  # the caller may discard the RenderContext immediately after `render` returns — the
233
234
  # returned Enumerator does not reference the RenderContext.
234
235
  def render_html_enum(html, render_context, streaming:)
236
+ CachedBlocks.report(html, @logger) unless render_context.deferring?
235
237
  registry = render_context.resolve(@manifest, controller_path: @controller_path)
236
238
  strict = Ruact.config.strict_serialization
237
239
  warning_cb = as_json_warning_callback
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.20"
4
+ VERSION = "0.0.22"
5
5
  end
@@ -21,7 +21,7 @@ module Ruact
21
21
  ctx = @ruact_render_context
22
22
  if ctx&.deferring?
23
23
  index = ctx.defer_block(RenderContext::Block.new(self, block, @virtual_path, @current_template,
24
- caller_locations(1, 1).first))
24
+ caller_locations(1, 1).first, __ruact_locals(block)))
25
25
  return "#{Markers.suspense_open(fallback, delay, block: index)}#{Markers::SUSPENSE_CLOSE}".html_safe
26
26
  end
27
27
 
@@ -33,6 +33,14 @@ module Ruact
33
33
  "#{Markers.suspense_open(fallback, delay)}#{content}#{Markers::SUSPENSE_CLOSE}".html_safe
34
34
  end
35
35
 
36
+ # Story 19-2 (AC8.3) — the local variables +block+ sees, as they are when
37
+ # the tag is met. The block runs later; without these it would see a local
38
+ # as it is then — the last item of a `for` loop, a value reassigned below.
39
+ def __ruact_locals(block)
40
+ scope = block.binding
41
+ scope.local_variables.to_h { |name| [name, scope.local_variable_get(name)] }
42
+ end
43
+
36
44
  # Story 19-1 — a `<Suspense>` in an action that does not stream renders its
37
45
  # content before the page is sent, as it always did. Development says so,
38
46
  # once per response, with the line that makes the action stream.
@@ -56,7 +64,7 @@ module Ruact
56
64
  )
57
65
  end
58
66
 
59
- private :__ruact_note_unstreamed_suspense
67
+ private :__ruact_note_unstreamed_suspense, :__ruact_locals
60
68
  end
61
69
  end
62
70
  end
@@ -235,7 +235,7 @@ module Ruact
235
235
  # `</script>`, and its `\e`-style escapes mean something else in JS).
236
236
  def ruact_flight_data_script(flight_payload)
237
237
  <<~HTML.strip
238
- <script>
238
+ <script#{__ruact_nonce_attr}>
239
239
  (function() {
240
240
  var d = (self.__FLIGHT_DATA = self.__FLIGHT_DATA || []);
241
241
  d.push(#{InlineScript.string_literal(flight_payload)});
@@ -244,6 +244,16 @@ module Ruact
244
244
  HTML
245
245
  end
246
246
 
247
+ # Story 19-2 (AC21) — the request's CSP nonce as a `<script>` attribute, so
248
+ # an app whose policy allows scripts by nonce (no `'unsafe-inline'`) runs
249
+ # the ones ruact writes. Empty without a nonce generator: the bytes are
250
+ # those of before.
251
+ def __ruact_nonce_attr
252
+ source = respond_to?(:request, true) ? request : nil
253
+ nonce = source.content_security_policy_nonce if source.respond_to?(:content_security_policy_nonce)
254
+ nonce ? %( nonce="#{ERB::Util.html_escape(nonce)}") : ""
255
+ end
256
+
247
257
  # The bootstrap entry `<script>` tags — dev serves the virtual module from
248
258
  # the Vite dev server (with the react-refresh preamble + `@vite/client`);
249
259
  # prod reads the hashed URL from the Vite manifest. Both target the SAME
@@ -255,35 +265,51 @@ module Ruact
255
265
  # never show. The bootstrap then reads the rows the document pushes while
256
266
  # it is still open.
257
267
  def ruact_vite_tags
258
- if Rails.env.development? && vite_dev_running?
259
- # @vitejs/plugin-react normally injects this preamble by processing index.html.
260
- # Since our HTML is generated by Rails (not Vite), we inject it manually.
261
- # Without it, every JSX file throws "can't detect preamble" at runtime.
262
- # One async module, so the preamble still runs before any component
263
- # module: it installs the preamble, then imports the client and the
264
- # bootstrap, in that order.
265
- dev_server = Ruact.config.vite_dev_server.chomp("/")
266
- <<~HTML
267
- <script type="module" async>
268
- import RefreshRuntime from '#{dev_server}/@react-refresh';
269
- RefreshRuntime.injectIntoGlobalHook(window);
270
- window.$RefreshReg$ = () => {};
271
- window.$RefreshSig$ = () => (type) => type;
272
- window.__vite_plugin_react_preamble_installed__ = true;
273
- await import('#{dev_server}/@vite/client');
274
- await import('#{ruact_bootstrap_dev_url}');
275
- </script>
276
- HTML
277
- else
278
- # Production: read the hashed URL from the Vite manifest, keyed on the
279
- # virtual entry id (the manifest key Vite emits for the virtual input).
280
- entry = vite_manifest_entry(Ruact.bootstrap_virtual_id)
281
- src = entry ? "/assets/#{entry['file']}" : "/assets/application.js"
282
- tag = %(<script type="module" async src="#{src}"></script>)
283
- # Story 17-0k — in development with neither the Vite dev server nor a
284
- # build, that script 404s and the page stays empty with no error.
285
- Rails.env.development? && entry.nil? ? "#{ruact_vite_down_warning}\n#{tag}" : tag
286
- 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) : ""
287
313
  end
288
314
 
289
315
  # Story 17-0k — `bin/rails server` on its own, once Vite has run before:
data/lib/ruact.rb CHANGED
@@ -18,8 +18,12 @@ 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"
24
+ require_relative "ruact/cached_blocks"
25
+ require_relative "ruact/query_watch"
26
+ require_relative "ruact/declared_content"
23
27
  require_relative "ruact/view_helper"
24
28
  require_relative "ruact/erb_preprocessor_hook"
25
29
  require_relative "ruact/server_functions"
@@ -58,8 +62,10 @@ module Ruact
58
62
  end
59
63
 
60
64
  # Returns the absolute path to the Vite plugin bundled inside this gem.
61
- # Use this in vite.config.js: import ruact from '<%= Ruact.vite_plugin_path %>'
62
- # 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.
63
69
  #
64
70
  # @return [String] absolute path to vendor/javascript/vite-plugin-ruact/index.js
65
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);
@@ -56,12 +56,20 @@ describe("showDevOverlay", () => {
56
56
  expect(document.getElementById("ruact-dev-overlay")).toBeNull();
57
57
  });
58
58
 
59
- it("shows the latest failure in the one overlay", () => {
59
+ // Story 19-2 (AC7) — every failure, in the one overlay.
60
+ it("shows each failure in the one overlay, and opens again after it was closed", () => {
60
61
  showDevOverlay(new Error("first"));
61
62
  showDevOverlay(new Error("second"));
62
63
  const overlays = document.querySelectorAll("#ruact-dev-overlay");
63
64
  expect(overlays).toHaveLength(1);
64
- expect(overlays[0].querySelector("[data-overlay-message]").textContent).toBe("second");
65
+ const messages = [...overlays[0].querySelectorAll("[data-overlay-message]")].map((el) => el.textContent);
66
+ expect(messages).toEqual(["first", "second"]);
67
+ expect(overlays[0].querySelector("[data-overlay-title]").textContent).toBe("[ruact] 2 <Suspense> failed on the server");
65
68
  overlays[0].click();
69
+
70
+ showDevOverlay(new Error("third"));
71
+ const reopened = document.getElementById("ruact-dev-overlay");
72
+ expect([...reopened.querySelectorAll("[data-overlay-message]")].map((el) => el.textContent)).toEqual(["third"]);
73
+ reopened.click();
66
74
  });
67
75
  });