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
|
@@ -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
|
|
@@ -18,15 +18,21 @@ module Ruact
|
|
|
18
18
|
|
|
19
19
|
include DocumentRendering
|
|
20
20
|
|
|
21
|
-
# The headers of a Flight body written row by row:
|
|
21
|
+
# The headers of a Flight body written row by row: not stored, and no
|
|
22
22
|
# buffering by a proxy that honours `X-Accel-Buffering` (nginx).
|
|
23
|
+
# `no-store`, not `no-cache` (Story 19-2): a response the browser may
|
|
24
|
+
# store holds its cache entry while it streams, and Chrome then holds a
|
|
25
|
+
# second request for the same URL — a `revalidate()`, the same link
|
|
26
|
+
# clicked again — until the first one ends.
|
|
23
27
|
STREAM_HEADERS = {
|
|
24
28
|
"Content-Type" => "#{RequestKind::FLIGHT_MIME}; charset=utf-8",
|
|
25
|
-
"Cache-Control" => "no-
|
|
29
|
+
"Cache-Control" => "no-store",
|
|
26
30
|
"X-Accel-Buffering" => "no"
|
|
27
31
|
}.freeze
|
|
28
32
|
|
|
29
|
-
# The same, for a document written in parts
|
|
33
|
+
# The same, for a document written in parts — `no-cache`: `no-store`
|
|
34
|
+
# would keep the page out of the browser's back/forward cache, and the
|
|
35
|
+
# router stops a document it leaves while it still loads.
|
|
30
36
|
DOCUMENT_STREAM_HEADERS = {
|
|
31
37
|
"Content-Type" => "text/html; charset=utf-8",
|
|
32
38
|
"Cache-Control" => "no-cache",
|
|
@@ -48,6 +54,13 @@ module Ruact
|
|
|
48
54
|
# pushed before it, as they are ready.
|
|
49
55
|
BODY_CLOSE = "</body>"
|
|
50
56
|
|
|
57
|
+
# Story 19-2 — written around the pushed rows of a streamed document, so
|
|
58
|
+
# the client router knows a document still loading has rows to come: it
|
|
59
|
+
# stops such a document when it leaves the page, and only such a one (a
|
|
60
|
+
# document that is still being parsed, its rows all in, is left alone).
|
|
61
|
+
STREAMING_OPEN = "(self.__FLIGHT_DATA=self.__FLIGHT_DATA||[]).streaming=true"
|
|
62
|
+
STREAMING_DONE = "self.__FLIGHT_DATA.streaming=false"
|
|
63
|
+
|
|
51
64
|
private
|
|
52
65
|
|
|
53
66
|
# Build the wire output and write it to the response. Nothing is written
|
|
@@ -86,9 +99,9 @@ module Ruact
|
|
|
86
99
|
|
|
87
100
|
document = ruact_document_html(shell)
|
|
88
101
|
cut = document.rindex(BODY_CLOSE) || document.length
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
102
|
+
open = "<script#{__ruact_nonce_attr}>"
|
|
103
|
+
__ruact_stream(run, DOCUMENT_STREAM_HEADERS, "#{document[0...cut]}#{open}#{STREAMING_OPEN}</script>",
|
|
104
|
+
"#{open}#{STREAMING_DONE}</script>#{document[cut..]}") { |rows| __ruact_flight_push(rows, open) }
|
|
92
105
|
end
|
|
93
106
|
|
|
94
107
|
# Writes +first+, then each boundary's rows (through the block, when given),
|
|
@@ -97,6 +110,8 @@ module Ruact
|
|
|
97
110
|
# not sent stays pending, for the browser's transport to label.
|
|
98
111
|
def __ruact_stream(run, headers, first, last = nil)
|
|
99
112
|
headers.each { |name, value| response.headers[name] = value }
|
|
113
|
+
Streaming.keep_unbuffered(response.headers)
|
|
114
|
+
@__ruact_streamed = true
|
|
100
115
|
stream = response.stream
|
|
101
116
|
begin
|
|
102
117
|
stream.write(first)
|
|
@@ -122,9 +137,9 @@ module Ruact
|
|
|
122
137
|
# One boundary's rows, as the script a streamed document carries them in:
|
|
123
138
|
# pushed onto the queue the bootstrap reads. Written as
|
|
124
139
|
# {InlineScript.string_literal} writes it — no byte of the payload can
|
|
125
|
-
# close the `<script>`.
|
|
126
|
-
def __ruact_flight_push(rows)
|
|
127
|
-
"
|
|
140
|
+
# close the `<script>`. +open+ carries the request's CSP nonce, if any.
|
|
141
|
+
def __ruact_flight_push(rows, open = "<script>")
|
|
142
|
+
"#{open}(self.__FLIGHT_DATA=self.__FLIGHT_DATA||[]).push(#{InlineScript.string_literal(rows)})</script>"
|
|
128
143
|
end
|
|
129
144
|
|
|
130
145
|
# Story 19-1 (D4) — has the client closed its end? Asked before each
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "securerandom"
|
|
4
|
+
|
|
3
5
|
module Ruact
|
|
4
6
|
module Controller
|
|
5
7
|
# Story 19-1 (ADR D6, option A) — which actions send their page as it
|
|
@@ -110,6 +112,60 @@ module Ruact
|
|
|
110
112
|
end
|
|
111
113
|
end
|
|
112
114
|
|
|
115
|
+
# Story 19-2 (AC20) — said once per process, in every environment: an
|
|
116
|
+
# action declared with `ruact_streaming` got a request over HTTP/1.0, so
|
|
117
|
+
# Live gave it an ordinary response and the page was sent whole. nginx
|
|
118
|
+
# talks HTTP/1.0 to its upstream unless told otherwise, so in production
|
|
119
|
+
# this is a proxy's configuration, found here or not at all.
|
|
120
|
+
UNSTREAMED = "%<action>s streams (ruact_streaming or ActionController::Live), but Live gave it an " \
|
|
121
|
+
"ordinary response — a request over HTTP/1.0 — so the page was sent whole, not " \
|
|
122
|
+
"streamed. Behind nginx, set `proxy_http_version 1.1;` where it proxies to the app. " \
|
|
123
|
+
"(Rack::Test and controller tests get an ordinary response too.) (Said once.)"
|
|
124
|
+
|
|
125
|
+
# Logs {UNSTREAMED} the first time an action that streams could not.
|
|
126
|
+
#
|
|
127
|
+
# @api private
|
|
128
|
+
# @param controller [ActionController::Metal]
|
|
129
|
+
# @return [void]
|
|
130
|
+
def self.note_unstreamed(controller)
|
|
131
|
+
return if @unstreamed_noted
|
|
132
|
+
|
|
133
|
+
@unstreamed_noted = true
|
|
134
|
+
action = "#{controller.class.name}##{controller.action_name}"
|
|
135
|
+
controller.logger&.warn("[ruact] #{format(UNSTREAMED, action: action)}")
|
|
136
|
+
nil
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# Forgets that it logged (specs).
|
|
140
|
+
#
|
|
141
|
+
# @api private
|
|
142
|
+
def self.reset_unstreamed_note!
|
|
143
|
+
@unstreamed_noted = false
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# Story 19-2 (AC19.3) — Rack 2's `Rack::ETag` digests a 200 body unless
|
|
147
|
+
# the response has an `ETag` or a `Last-Modified`, reading it whole first
|
|
148
|
+
# (`Cache-Control: no-cache` does not stop it): a page that streams was
|
|
149
|
+
# held until its last `<Suspense>`. Measured with Rack 2.2.24. A weak ETag
|
|
150
|
+
# of its own, new for every response, so that no `If-None-Match` ever
|
|
151
|
+
# matches it. Rack 3 leaves a body it cannot hold in memory alone.
|
|
152
|
+
RACK_ETAG_BUFFERS = Gem::Version.new(::Rack.release) < Gem::Version.new("3")
|
|
153
|
+
|
|
154
|
+
# Sets that ETag on +headers+ when the app runs on Rack 2 — unless the
|
|
155
|
+
# action set its own validator (`fresh_when`), which already keeps
|
|
156
|
+
# `Rack::ETag` away and which conditional GET needs.
|
|
157
|
+
#
|
|
158
|
+
# @api private
|
|
159
|
+
# @param headers [Hash, ActionDispatch::Response::Headers]
|
|
160
|
+
# @return [void]
|
|
161
|
+
def self.keep_unbuffered(headers)
|
|
162
|
+
return unless RACK_ETAG_BUFFERS
|
|
163
|
+
return if headers.key?("ETag") || headers.key?("Last-Modified")
|
|
164
|
+
|
|
165
|
+
headers["ETag"] = %(W/"ruact-stream-#{SecureRandom.hex(8)}")
|
|
166
|
+
nil
|
|
167
|
+
end
|
|
168
|
+
|
|
113
169
|
# Includes `ActionController::Live` in +controller+ for `ruact_streaming`,
|
|
114
170
|
# marked as ruact's doing so {LiveInclusion} does not take it for the app's.
|
|
115
171
|
#
|
|
@@ -221,6 +277,14 @@ module Ruact
|
|
|
221
277
|
# support runs Live inline).
|
|
222
278
|
def process_action(*)
|
|
223
279
|
super
|
|
280
|
+
rescue StandardError => e
|
|
281
|
+
# Story 19-2 (AC6) — raised once the page was sent (an `after_action`):
|
|
282
|
+
# Live only logs what is raised after the response started, so the
|
|
283
|
+
# app's error reporting would never hear of it.
|
|
284
|
+
if @__ruact_streamed && defined?(Rails) && Rails.respond_to?(:error)
|
|
285
|
+
Rails.error.report(e, handled: false, severity: :error, source: "ruact.stream")
|
|
286
|
+
end
|
|
287
|
+
raise
|
|
224
288
|
ensure
|
|
225
289
|
__ruact_check_writes_after_stream
|
|
226
290
|
__ruact_release_connections if __ruact_on_live_thread?
|
data/lib/ruact/controller.rb
CHANGED
|
@@ -92,7 +92,7 @@ module Ruact
|
|
|
92
92
|
# are only ever invoked internally by `ruact_html_shell`; the others only
|
|
93
93
|
# by templates).
|
|
94
94
|
private :ruact_js_assets, :ruact_head_assets, :__ruact_component__, :__ruact_component_open__,
|
|
95
|
-
:__ruact_suspense__
|
|
95
|
+
:__ruact_suspense__, :__ruact_nonce_attr
|
|
96
96
|
|
|
97
97
|
# Resolves the manifest for this render. In PRODUCTION this is the boot-time
|
|
98
98
|
# cached +Ruact.manifest+ (set by Railtie#config.to_prepare) — no per-request
|
|
@@ -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,16 +186,23 @@ module Ruact
|
|
|
177
186
|
# its headers on the first row.
|
|
178
187
|
self.status = status if status
|
|
179
188
|
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
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,
|
|
195
|
+
controller_path: controller_path, logger: logger,
|
|
196
|
+
development: Flight::Renderer.development_default)
|
|
183
197
|
# Story 19-1 — in an action that streams, the first load and a
|
|
184
198
|
# navigation alike, each `<Suspense>`'s content renders after the shell
|
|
185
199
|
# is sent: the view keeps its blocks instead of running them. Only when
|
|
186
200
|
# the response really is Live's: on HTTP/1.0 (nginx's default to its
|
|
187
201
|
# upstream) Live gives an ordinary response, which the server sends as
|
|
188
202
|
# soon as anything is written — the page then renders whole, in place.
|
|
189
|
-
|
|
203
|
+
declared = self.class.ruact_streaming?(action_name)
|
|
204
|
+
streaming = declared && response.is_a?(ActionController::Live::Response)
|
|
205
|
+
Streaming.note_unstreamed(self) if declared && !streaming
|
|
190
206
|
|
|
191
207
|
# Allocate a per-render context and expose it to the view via a normal
|
|
192
208
|
# (non-`@_`-prefixed) instance variable on the controller. Rails 8's
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ruact
|
|
4
|
+
# Story 19-2 (AC10) — in development, `content_for`/`provide` inside a
|
|
5
|
+
# `<Suspense>` that runs after the shell is an error of that block: the layout
|
|
6
|
+
# was sent before the block ran, so nothing it declares shows. What the view
|
|
7
|
+
# had declared is taken before the block and compared after.
|
|
8
|
+
#
|
|
9
|
+
# Internal API: not part of the public compatibility contract.
|
|
10
|
+
module DeclaredContent
|
|
11
|
+
# What +view+ has declared so far, or nil when it keeps no record.
|
|
12
|
+
def self.take(view)
|
|
13
|
+
return nil unless view.respond_to?(:view_flow) && view.view_flow.respond_to?(:content)
|
|
14
|
+
|
|
15
|
+
# Copies: the record keeps a SafeBuffer per name, which `content_for`
|
|
16
|
+
# appends to in place (and whose `to_s` is itself).
|
|
17
|
+
view.view_flow.content.transform_values { |content| String.new(content.to_s) }
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Raises when +view+ declared more than +before+. A name only read
|
|
21
|
+
# (`content_for?`, `yield :name`) appears in the record empty: Rails'
|
|
22
|
+
# record defaults every name it is asked for to "".
|
|
23
|
+
def self.check!(view, before)
|
|
24
|
+
after = view.view_flow.content
|
|
25
|
+
changed = after.keys.reject { |name| name == :layout || after[name].to_s == before.fetch(name, "") }
|
|
26
|
+
return if changed.empty?
|
|
27
|
+
|
|
28
|
+
raise Ruact::Error,
|
|
29
|
+
"#{changed.map { |name| "content_for :#{name}" }.join(', ')} inside a <Suspense> runs after the " \
|
|
30
|
+
"layout was sent, so the layout never shows it. Move it outside the <Suspense>."
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -99,14 +99,24 @@ module Ruact
|
|
|
99
99
|
def content(element)
|
|
100
100
|
return element unless element.is_a?(Flight::DeferredContent)
|
|
101
101
|
|
|
102
|
-
block = @render_context.block(element.
|
|
102
|
+
block = @render_context.block(element.key)
|
|
103
|
+
raise Ruact::Error, Flight::DeferredContent::STALE if block.nil?
|
|
104
|
+
|
|
103
105
|
html = @guard ? @guard.call { evaluate(block) } : evaluate(block)
|
|
104
106
|
convert(html)
|
|
105
107
|
end
|
|
106
108
|
|
|
107
|
-
# Runs the block in its view, with the template path
|
|
108
|
-
#
|
|
109
|
+
# Runs the block in its view, with the template path and its local
|
|
110
|
+
# variables as they were when the tag was met, and returns what it wrote.
|
|
111
|
+
# Only what it writes: a value it ends on is not content.
|
|
109
112
|
def evaluate(pending)
|
|
113
|
+
declared = DeclaredContent.take(pending.view) if @development
|
|
114
|
+
html = with_locals(pending.block, pending.locals) { run_in_view(pending) }
|
|
115
|
+
DeclaredContent.check!(pending.view, declared) if declared
|
|
116
|
+
html
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def run_in_view(pending)
|
|
110
120
|
pending.view.instance_exec(pending) do |entry|
|
|
111
121
|
saved = [@virtual_path, @current_template]
|
|
112
122
|
@virtual_path = entry.virtual_path
|
|
@@ -122,6 +132,22 @@ module Ruact
|
|
|
122
132
|
end
|
|
123
133
|
end
|
|
124
134
|
|
|
135
|
+
# Sets the block's local variables to +locals+ while it runs, and back
|
|
136
|
+
# after. A block shares them with the template and with the blocks met
|
|
137
|
+
# after it.
|
|
138
|
+
def with_locals(block, locals)
|
|
139
|
+
return yield if locals.nil? || locals.empty?
|
|
140
|
+
|
|
141
|
+
scope = block.binding
|
|
142
|
+
now = locals.keys.to_h { |name| [name, scope.local_variable_get(name)] }
|
|
143
|
+
locals.each { |name, value| scope.local_variable_set(name, value) }
|
|
144
|
+
begin
|
|
145
|
+
yield
|
|
146
|
+
ensure
|
|
147
|
+
now.each { |name, value| scope.local_variable_set(name, value) }
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
|
|
125
151
|
# The components registered since the last conversion are resolved and
|
|
126
152
|
# added: a block registers its own as it runs. One the manifest cannot
|
|
127
153
|
# resolve fails this conversion and keeps its place (nil), so the next
|
|
@@ -153,16 +179,31 @@ module Ruact
|
|
|
153
179
|
raise Expired, "the delay (#{delay}s) is longer than suspense_timeout" if delay && delay > limit
|
|
154
180
|
|
|
155
181
|
started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
182
|
+
# A query still running at the limit is cancelled on the server, so its
|
|
183
|
+
# connection is free again (Story 19-2, QueryWatch).
|
|
184
|
+
owner = QueryWatch.current_owner
|
|
185
|
+
watch = QueryWatch.start(owner, limit)
|
|
156
186
|
begin
|
|
157
187
|
Timeout.timeout(limit) { wait_then(delay, &) }
|
|
158
|
-
rescue
|
|
159
|
-
|
|
160
|
-
raise if Process.clock_gettime(Process::CLOCK_MONOTONIC) - started < limit
|
|
188
|
+
rescue StandardError => e
|
|
189
|
+
raise unless expired?(e, started, limit, watch)
|
|
161
190
|
|
|
191
|
+
watch&.cancel_now(owner)
|
|
162
192
|
raise Expired, "ran past suspense_timeout (#{limit}s)", cause: e
|
|
193
|
+
ensure
|
|
194
|
+
watch&.stop
|
|
163
195
|
end
|
|
164
196
|
end
|
|
165
197
|
|
|
198
|
+
# A `Timeout::Error` is ours only when the limit has passed; earlier, it is
|
|
199
|
+
# the content's. Any other error is ours when the watch cancelled a query
|
|
200
|
+
# at the limit: what the cancelled query raised.
|
|
201
|
+
def expired?(error, started, limit, watch)
|
|
202
|
+
return watch&.cancelled? || false unless error.is_a?(Timeout::Error)
|
|
203
|
+
|
|
204
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC) - started >= limit
|
|
205
|
+
end
|
|
206
|
+
|
|
166
207
|
def wait_then(delay)
|
|
167
208
|
sleep(delay) if delay&.positive?
|
|
168
209
|
yield
|
|
@@ -181,11 +222,16 @@ module Ruact
|
|
|
181
222
|
payload
|
|
182
223
|
end
|
|
183
224
|
|
|
184
|
-
# React's development client reads `stack` as
|
|
225
|
+
# React's development client reads each frame of `stack` as
|
|
226
|
+
# `[function, file, line, column, enclosing line, enclosing column]` and
|
|
227
|
+
# rebuilds the server's call stack from it. A four-element frame (what this
|
|
228
|
+
# sent before Story 19-2) left the last two undefined, and every frame came
|
|
229
|
+
# out at line 2. Ruby has no column and no enclosing function: column 1,
|
|
230
|
+
# enclosing 0 (none).
|
|
185
231
|
def development_stack(error)
|
|
186
232
|
Array(error.backtrace).first(STACK_FRAMES).filter_map do |frame|
|
|
187
233
|
match = frame.match(/\A(.+?):(\d+)(?::in [`'](.+)')?\z/)
|
|
188
|
-
match && [match[3] || "(ruby)", match[1], Integer(match[2], 10), 0]
|
|
234
|
+
match && [match[3] || "(ruby)", match[1], Integer(match[2], 10), 1, 0, 0]
|
|
189
235
|
end
|
|
190
236
|
end
|
|
191
237
|
|
|
@@ -201,7 +247,7 @@ module Ruact
|
|
|
201
247
|
element = boundary[:element]
|
|
202
248
|
return "" unless element.is_a?(Flight::DeferredContent)
|
|
203
249
|
|
|
204
|
-
location = @render_context.block(element.
|
|
250
|
+
location = @render_context.block(element.key)&.location
|
|
205
251
|
location ? " at #{location.path}:#{location.lineno}" : ""
|
|
206
252
|
end
|
|
207
253
|
end
|
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
|
|
@@ -484,7 +495,30 @@ module Ruact
|
|
|
484
495
|
def check_streaming
|
|
485
496
|
mode = Ruact.streaming_mode || :buffered
|
|
486
497
|
label = mode == :enabled ? "enabled" : "buffered"
|
|
487
|
-
|
|
498
|
+
message = "streaming: #{label} (#{streaming_server_hint})"
|
|
499
|
+
declaring = streaming_controllers.length
|
|
500
|
+
return [:pass, message] if declaring.zero?
|
|
501
|
+
|
|
502
|
+
# Story 19-2 (AC20) — the doctor sees no request: what it can say is
|
|
503
|
+
# where a page that streams silently does not.
|
|
504
|
+
[:pass, "#{message}; #{pluralize_count(declaring, 'controller')} #{declaring == 1 ? 'streams' : 'stream'} " \
|
|
505
|
+
"(ruact_streaming or ActionController::Live) — behind a proxy, the app must be reached over " \
|
|
506
|
+
"HTTP/1.1 (nginx: `proxy_http_version 1.1;`), or those pages are sent whole",
|
|
507
|
+
PROXY_HTTP11]
|
|
508
|
+
end
|
|
509
|
+
|
|
510
|
+
# Story 19-2 (AC20) — what makes a proxy talk HTTP/1.1 to the app.
|
|
511
|
+
PROXY_HTTP11 = "Behind nginx, set `proxy_http_version 1.1;` where it proxies to the app: by default it " \
|
|
512
|
+
"talks HTTP/1.0 upstream, and a page declared with ruact_streaming is then sent whole " \
|
|
513
|
+
"(the app logs it once)."
|
|
514
|
+
private_constant :PROXY_HTTP11
|
|
515
|
+
|
|
516
|
+
# Controller files that declare `ruact_streaming` or include
|
|
517
|
+
# `ActionController::Live`. Reads source, as {#island_controllers} does.
|
|
518
|
+
def streaming_controllers
|
|
519
|
+
Dir.glob(Rails.root.join("app", "controllers", "**", "*.rb").to_s).select do |file|
|
|
520
|
+
File.read(file).match?(/^\s*(ruact_streaming\b|include\s+ActionController::Live\b)/)
|
|
521
|
+
end
|
|
488
522
|
end
|
|
489
523
|
|
|
490
524
|
# Story 19-1 (ADR D1) — whether `load_async` runs its query in the
|
|
@@ -14,7 +14,7 @@ module Ruact
|
|
|
14
14
|
# <%= __ruact_component__("LikeButton", { "postId" => @post.id, "initialCount" => 5 }) %>
|
|
15
15
|
#
|
|
16
16
|
# The placeholder is replaced by an HTML comment with a unique token:
|
|
17
|
-
# <!--
|
|
17
|
+
# <!-- __RUACT_a1b2c3d4_0__ -->
|
|
18
18
|
#
|
|
19
19
|
# A component with children (Story 18-2) keeps its content as ERB, inside a
|
|
20
20
|
# wrapper element HtmlConverter turns into the component's `children`:
|
|
@@ -399,7 +399,13 @@ module Ruact
|
|
|
399
399
|
props_ruby = pairs.map { |prop, expr| "#{prop.inspect} => #{expr}" }.join(", ")
|
|
400
400
|
props_hash = props_ruby.empty? ? "{}" : "{ #{props_ruby} }"
|
|
401
401
|
helper = kind == :self_closing ? "__ruact_component__" : "__ruact_component_open__"
|
|
402
|
-
|
|
402
|
+
# Story 19-2 — a tag written over several lines keeps them, so every
|
|
403
|
+
# line below it is still the template's own in an error and a backtrace
|
|
404
|
+
# (the props' own code carries the line breaks it has). Nothing is
|
|
405
|
+
# allocated for a tag on one line: the guard on allocations counts
|
|
406
|
+
# every component.
|
|
407
|
+
missing = match.count("\n") - props_hash.count("\n")
|
|
408
|
+
%(<%= #{helper}(#{name.inspect}, #{props_hash}) #{"\n" * missing if missing.positive?}%>)
|
|
403
409
|
rescue ComponentContractError, ComponentTagError, UnknownComponentError
|
|
404
410
|
# Already carry file:line and the fix — re-raise AS-IS (do NOT append the
|
|
405
411
|
# generic "at line N: snippet" tail).
|
|
@@ -31,14 +31,19 @@ module Ruact
|
|
|
31
31
|
end
|
|
32
32
|
|
|
33
33
|
# The content of a Suspense boundary that has not been rendered yet: the
|
|
34
|
-
#
|
|
34
|
+
# key of its block in the render context. The serializer never sees one —
|
|
35
35
|
# it defers a boundary's children without looking at them, and the caller
|
|
36
36
|
# of {Renderer#take_boundary} renders it.
|
|
37
37
|
class DeferredContent
|
|
38
|
-
|
|
38
|
+
# Story 19-2 — what a boundary says when its marker came out of a `cache`
|
|
39
|
+
# block another render wrote: its content was never in the fragment.
|
|
40
|
+
STALE = "A <Suspense> inside `cache` was read from the cache, and its content is not in the " \
|
|
41
|
+
"cached fragment: put the <Suspense> outside the `cache` block (cache what is inside it)."
|
|
39
42
|
|
|
40
|
-
|
|
41
|
-
|
|
43
|
+
attr_reader :key
|
|
44
|
+
|
|
45
|
+
def initialize(key)
|
|
46
|
+
@key = key
|
|
42
47
|
end
|
|
43
48
|
end
|
|
44
49
|
|
|
@@ -85,8 +85,11 @@ module Ruact
|
|
|
85
85
|
# a {DeferredContent} needs {#take_boundary}.
|
|
86
86
|
def next_boundary(streaming:)
|
|
87
87
|
boundary = take_boundary
|
|
88
|
+
# A block this render cannot run: its marker came out of a `cache`
|
|
89
|
+
# block a render that streamed wrote.
|
|
88
90
|
if boundary[:element].is_a?(DeferredContent)
|
|
89
|
-
|
|
91
|
+
return error_rows(boundary[:id],
|
|
92
|
+
RowEmitter.error_payload(DeferredContent::STALE, digest: "ruact:suspense-error"))
|
|
90
93
|
end
|
|
91
94
|
|
|
92
95
|
if streaming && boundary[:delay]&.positive?
|
data/lib/ruact/html_converter.rb
CHANGED
|
@@ -8,7 +8,7 @@ module Ruact
|
|
|
8
8
|
# Rules:
|
|
9
9
|
# - HTML attributes → React equivalents (class→className, for→htmlFor, etc.)
|
|
10
10
|
# - data-react-key="x" → becomes the React key on the element
|
|
11
|
-
# - HTML comments matching
|
|
11
|
+
# - HTML comments matching __RUACT_<render>_<n>__ tokens → replaced by client component refs
|
|
12
12
|
# - Text nodes → plain Ruby strings
|
|
13
13
|
# - Multiple root nodes → wrapped in a Fragment (array)
|
|
14
14
|
# rubocop:disable Metrics/ClassLength
|
|
@@ -35,6 +35,11 @@ module Ruact
|
|
|
35
35
|
"spellcheck" => "spellCheck"
|
|
36
36
|
}.freeze
|
|
37
37
|
|
|
38
|
+
# A client component's marker (`__RUACT_<render>_<n>__`, the render's id
|
|
39
|
+
# being eight hex digits, {RenderContext}), or one 0.0.20 and before wrote
|
|
40
|
+
# (`__RUACT_<n>__`), which a `cache` block may still hold.
|
|
41
|
+
TOKEN = /\A__RUACT_(?:\h{8}_)?\d+__\z/
|
|
42
|
+
|
|
38
43
|
# Tags whose `value` attribute maps to React's `defaultValue` (uncontrolled).
|
|
39
44
|
DEFAULT_VALUE_TAGS = %w[textarea select].freeze
|
|
40
45
|
|
|
@@ -167,7 +172,11 @@ module Ruact
|
|
|
167
172
|
# Check if this is a Ruact component placeholder
|
|
168
173
|
token = Markers.comment_token(node)
|
|
169
174
|
entry = @registry[token]
|
|
170
|
-
|
|
175
|
+
unless entry
|
|
176
|
+
raise Ruact::HtmlConverterError, stale_component_message(token) if token.match?(TOKEN)
|
|
177
|
+
|
|
178
|
+
return nil
|
|
179
|
+
end
|
|
171
180
|
|
|
172
181
|
Flight::ReactElement.new(
|
|
173
182
|
type: entry[:ref],
|
|
@@ -200,7 +209,7 @@ module Ruact
|
|
|
200
209
|
def convert_component_with_children(node)
|
|
201
210
|
token = Markers.wrapper_token(node)
|
|
202
211
|
entry = @registry[token]
|
|
203
|
-
raise Ruact::HtmlConverterError,
|
|
212
|
+
raise Ruact::HtmlConverterError, stale_component_message(token) unless entry
|
|
204
213
|
|
|
205
214
|
props = entry[:props]
|
|
206
215
|
children = convert_children(node)
|
|
@@ -210,6 +219,15 @@ module Ruact
|
|
|
210
219
|
Flight::ReactElement.new(type: entry[:ref], key: nil, props: props)
|
|
211
220
|
end
|
|
212
221
|
|
|
222
|
+
# Story 19-2 — a marker this render did not write: it came out of a
|
|
223
|
+
# `cache` block, which stores a component's marker and not the component.
|
|
224
|
+
def stale_component_message(token)
|
|
225
|
+
"ruact: the page holds the marker of a client component (#{token}) that this render did not " \
|
|
226
|
+
"register — it came out of a `cache` block, which stores the component's marker but not the " \
|
|
227
|
+
"component. Move the client component outside the `cache` block (cache the HTML around it, or " \
|
|
228
|
+
"the data it is given)."
|
|
229
|
+
end
|
|
230
|
+
|
|
213
231
|
def convert_suspense_element(node)
|
|
214
232
|
fallback_text = Markers.suspense_fallback(node)
|
|
215
233
|
fallback = if fallback_text.empty?
|
data/lib/ruact/markers.rb
CHANGED
|
@@ -8,14 +8,18 @@ module Ruact
|
|
|
8
8
|
# Three markers:
|
|
9
9
|
#
|
|
10
10
|
# - a client component: an HTML comment holding the token its
|
|
11
|
-
# {RenderContext} entry was registered under — `<!--
|
|
11
|
+
# {RenderContext} entry was registered under — `<!-- __RUACT_a1b2c3d4_0__ -->`;
|
|
12
12
|
# - a client component with children: an element wrapped around the
|
|
13
|
-
# children's HTML — `<ruact-component data-ruact-token="
|
|
13
|
+
# children's HTML — `<ruact-component data-ruact-token="__RUACT_a1b2c3d4_0__">…`;
|
|
14
14
|
# - a Suspense boundary: an element wrapped around its content, carrying the
|
|
15
15
|
# fallback text and the optional delay —
|
|
16
16
|
# `<ruact-suspense data-ruact-fallback="Loading…">…</ruact-suspense>`. A
|
|
17
|
-
# boundary whose content is rendered later is empty and carries the
|
|
18
|
-
# of its block in the {RenderContext} instead (`data-ruact-block="0"`).
|
|
17
|
+
# boundary whose content is rendered later is empty and carries the key
|
|
18
|
+
# of its block in the {RenderContext} instead (`data-ruact-block="a1b2c3d4-0"`).
|
|
19
|
+
#
|
|
20
|
+
# Every token and key carries the id of the render that wrote it, so a
|
|
21
|
+
# marker a `cache` block stored in another render is never taken for one of
|
|
22
|
+
# this render's.
|
|
19
23
|
#
|
|
20
24
|
# {ErbPreprocessor} writes the ERB that produces them, the view helpers
|
|
21
25
|
# ({ViewHelper}, and {RenderPipeline} for a plain-ERB render) write the
|
|
@@ -49,7 +53,7 @@ module Ruact
|
|
|
49
53
|
# attribute values as written in the template. Only `"` is escaped: the
|
|
50
54
|
# rest of the text stays as the template wrote it (entities included, which
|
|
51
55
|
# the HTML parser then decodes). The preprocessor refuses ERB in either.
|
|
52
|
-
# +block+ is the
|
|
56
|
+
# +block+ is the key of the boundary's content in the render context,
|
|
53
57
|
# when that content is rendered later.
|
|
54
58
|
def self.suspense_open(fallback, delay, block: nil)
|
|
55
59
|
delay_attr = delay ? %( #{DELAY_ATTR}="#{delay.gsub('"', '"')}") : ""
|
|
@@ -72,11 +76,10 @@ module Ruact
|
|
|
72
76
|
node[FALLBACK_ATTR] || ""
|
|
73
77
|
end
|
|
74
78
|
|
|
75
|
-
# The
|
|
79
|
+
# The key of the block a Suspense wrapper's content is rendered from
|
|
76
80
|
# later, or nil when the content is in the wrapper.
|
|
77
81
|
def self.suspense_block(node)
|
|
78
|
-
|
|
79
|
-
value && Integer(value, 10)
|
|
82
|
+
node[BLOCK_ATTR]
|
|
80
83
|
end
|
|
81
84
|
|
|
82
85
|
# The delay of a Suspense wrapper, in seconds, or nil when it is absent,
|