ruact 0.0.20 → 0.0.22
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +46 -1
- data/CONTRIBUTING.md +2 -2
- data/lib/generators/ruact/install/install_generator.rb +121 -19
- data/lib/generators/ruact/install/legacy/vite.config.pinned.js +28 -0
- data/lib/generators/ruact/install/templates/AGENTS.md.tt +9 -9
- data/lib/generators/ruact/install/templates/vite.config.js.tt +56 -21
- data/lib/ruact/build_skew.rb +164 -0
- data/lib/ruact/cached_blocks.rb +30 -0
- data/lib/ruact/client_manifest.rb +27 -1
- data/lib/ruact/controller/delivery.rb +24 -9
- data/lib/ruact/controller/streaming.rb +64 -0
- data/lib/ruact/controller.rb +21 -5
- data/lib/ruact/declared_content.rb +33 -0
- data/lib/ruact/deferred_render.rb +55 -9
- data/lib/ruact/doctor.rb +40 -6
- data/lib/ruact/erb_preprocessor.rb +8 -2
- data/lib/ruact/flight/react_element.rb +9 -4
- data/lib/ruact/flight/renderer.rb +4 -1
- data/lib/ruact/html_converter.rb +21 -3
- data/lib/ruact/markers.rb +11 -8
- data/lib/ruact/query_watch.rb +106 -0
- data/lib/ruact/railtie.rb +14 -0
- data/lib/ruact/render_context.rb +23 -11
- data/lib/ruact/render_pipeline.rb +3 -1
- data/lib/ruact/version.rb +1 -1
- data/lib/ruact/view_helper/suspense.rb +10 -2
- data/lib/ruact/view_helper.rb +56 -30
- data/lib/ruact.rb +8 -2
- data/vendor/javascript/vite-plugin-ruact/build-version.test.mjs +101 -0
- data/vendor/javascript/vite-plugin-ruact/index.js +39 -4
- data/vendor/javascript/vite-plugin-ruact/page-mount.test.mjs +10 -2
- data/vendor/javascript/vite-plugin-ruact/page-stream.test.mjs +29 -4
- data/vendor/javascript/vite-plugin-ruact/registry.test.mjs +7 -1
- data/vendor/javascript/vite-plugin-ruact/ruact-router.test.mjs +185 -0
- data/vendor/javascript/vite-plugin-ruact/runtime/dev-overlay.js +20 -6
- data/vendor/javascript/vite-plugin-ruact/runtime/page-stream.js +23 -3
- data/vendor/javascript/vite-plugin-ruact/runtime/ruact-router.js +64 -4
- metadata +8 -2
|
@@ -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)
|
data/lib/ruact/render_context.rb
CHANGED
|
@@ -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,
|
|
18
|
-
# (`t(".key")` and error messages read it)
|
|
19
|
-
|
|
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
|
|
51
|
+
# Keeps a `<Suspense>` block for later and returns its key.
|
|
44
52
|
#
|
|
45
53
|
# @param block [Block]
|
|
46
|
-
# @return [
|
|
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 +
|
|
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(
|
|
56
|
-
|
|
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
|
-
#
|
|
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
|
@@ -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
|
data/lib/ruact/view_helper.rb
CHANGED
|
@@ -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
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
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
|
-
#
|
|
62
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
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
|
});
|