ruact 0.0.18 → 0.0.20
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 +41 -1
- data/CONTRIBUTING.md +10 -3
- data/README.md +1 -1
- data/lib/generators/ruact/install/templates/AGENTS.md.tt +13 -13
- data/lib/generators/ruact/install/templates/initializer.rb.tt +4 -1
- data/lib/ruact/client_manifest.rb +7 -7
- data/lib/ruact/configuration.rb +6 -1
- data/lib/ruact/controller/delivery.rb +247 -0
- data/lib/ruact/controller/document_rendering.rb +15 -12
- data/lib/ruact/controller/streaming.rb +241 -0
- data/lib/ruact/controller.rb +29 -48
- data/lib/ruact/deferred_render.rb +208 -0
- data/lib/ruact/doctor.rb +18 -1
- data/lib/ruact/erb_preprocessor.rb +203 -22
- data/lib/ruact/flight/react_element.rb +17 -2
- data/lib/ruact/flight/renderer.rb +91 -41
- data/lib/ruact/flight/request.rb +71 -27
- data/lib/ruact/flight/serializer.rb +3 -10
- data/lib/ruact/html_converter.rb +23 -31
- data/lib/ruact/inline_script.rb +24 -0
- data/lib/ruact/load_async_note.rb +51 -0
- data/lib/ruact/markers.rb +95 -0
- data/lib/ruact/navigation_boundary.rb +2 -2
- data/lib/ruact/railtie.rb +12 -0
- data/lib/ruact/render_context.rb +61 -6
- data/lib/ruact/render_pipeline.rb +29 -21
- data/lib/ruact/request_kind.rb +36 -0
- data/lib/ruact/server.rb +3 -7
- data/lib/ruact/testing/flight_extractor.rb +21 -4
- data/lib/ruact/version.rb +1 -1
- data/lib/ruact/view_helper/suspense.rb +62 -0
- data/lib/ruact/view_helper.rb +24 -32
- data/lib/ruact.rb +4 -0
- data/vendor/javascript/vite-plugin-ruact/bootstrap.test.mjs +16 -3
- data/vendor/javascript/vite-plugin-ruact/page-mount.test.mjs +67 -0
- data/vendor/javascript/vite-plugin-ruact/page-stream.test.mjs +107 -0
- data/vendor/javascript/vite-plugin-ruact/runtime/bootstrap.jsx +23 -18
- data/vendor/javascript/vite-plugin-ruact/runtime/dev-overlay.js +39 -0
- data/vendor/javascript/vite-plugin-ruact/runtime/page-stream.js +88 -0
- data/vendor/javascript/vite-plugin-ruact/runtime/ruact-router.js +11 -17
- data/vendor/javascript/vite-plugin-ruact/runtime/suspense-boundary.js +1 -0
- metadata +14 -2
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ruact
|
|
4
|
+
module Controller
|
|
5
|
+
# Story 19-1 (ADR D6, option A) — which actions send their page as it
|
|
6
|
+
# renders: the shell first, then each `<Suspense>`'s content when it is
|
|
7
|
+
# ready.
|
|
8
|
+
#
|
|
9
|
+
# Streaming needs `ActionController::Live`, and Live is decided before the
|
|
10
|
+
# action runs: it gives the action a response whose body is a queue, and
|
|
11
|
+
# runs the action in a thread of its own. So the app says which actions
|
|
12
|
+
# stream (`ruact_streaming`), and ruact applies Live to those and only
|
|
13
|
+
# those. The other actions of the controller respond as they did — a
|
|
14
|
+
# buffered response with its `ETag`, on the request's thread.
|
|
15
|
+
#
|
|
16
|
+
# A controller that includes `ActionController::Live` itself, and declares
|
|
17
|
+
# nothing, streams every action.
|
|
18
|
+
module Streaming
|
|
19
|
+
extend ActiveSupport::Concern
|
|
20
|
+
|
|
21
|
+
# Where `Controller.action(:name)` leaves the action's name: a route that
|
|
22
|
+
# names no action (`get "/x", to: PagesController.action(:show)`) gives
|
|
23
|
+
# `make_response!` nothing else to go on.
|
|
24
|
+
ACTION_ENV = "ruact.action"
|
|
25
|
+
|
|
26
|
+
# The thread that received the request. Under Live the action runs in
|
|
27
|
+
# another one, and what that thread leased from the connection pool is
|
|
28
|
+
# its own to give back.
|
|
29
|
+
REQUEST_THREAD_ENV = "ruact.request_thread"
|
|
30
|
+
|
|
31
|
+
included do
|
|
32
|
+
# Story 19-1 — the actions this controller declared with
|
|
33
|
+
# `ruact_streaming`, or nil. Inherited, and redeclarable by a subclass.
|
|
34
|
+
class_attribute :__ruact_streaming, instance_accessor: false, instance_predicate: false, default: nil
|
|
35
|
+
singleton_class.prepend(ResponseChoice)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
class_methods do
|
|
39
|
+
# Story 19-1 — the actions whose page is sent as it renders: the shell
|
|
40
|
+
# and the `<Suspense>` fallbacks first, then each `<Suspense>`'s
|
|
41
|
+
# content, rendered after the shell is sent, as it is ready. On the
|
|
42
|
+
# first load and on a navigation.
|
|
43
|
+
#
|
|
44
|
+
# Without arguments, every ruact page of the controller streams (an
|
|
45
|
+
# action with no template of its own, a JSON one, does not); `only:`
|
|
46
|
+
# names the actions, `except:` leaves some out. One line in
|
|
47
|
+
# `ApplicationController` makes every page of the app stream.
|
|
48
|
+
#
|
|
49
|
+
# The actions that stream run under `ActionController::Live`, in a
|
|
50
|
+
# thread of their own: an `ETag` is not computed for them, and what a
|
|
51
|
+
# middleware set up for the request's thread (a thread variable, the
|
|
52
|
+
# locale) is not theirs — set it in the controller (`around_action`).
|
|
53
|
+
# The other actions are untouched.
|
|
54
|
+
#
|
|
55
|
+
# @param only [Symbol, String, Array<Symbol, String>] the actions that stream
|
|
56
|
+
# @param except [Symbol, String, Array<Symbol, String>] every page but these
|
|
57
|
+
# @return [void]
|
|
58
|
+
# @raise [ArgumentError] when given both `only:` and `except:`
|
|
59
|
+
# @example The show page streams
|
|
60
|
+
# class PostsController < ApplicationController
|
|
61
|
+
# include Ruact::Controller
|
|
62
|
+
# ruact_streaming only: :show
|
|
63
|
+
# end
|
|
64
|
+
def ruact_streaming(only: nil, except: nil)
|
|
65
|
+
raise ArgumentError, "ruact_streaming takes only: or except:, not both (#{name})" if only && except
|
|
66
|
+
|
|
67
|
+
Streaming.include_live(self)
|
|
68
|
+
# Directly above Live, so the actions that do not stream skip it.
|
|
69
|
+
include LiveChoice unless include?(LiveChoice)
|
|
70
|
+
# Ahead of Live's `make_response!`, which this class now extends and
|
|
71
|
+
# which would otherwise answer before the choice the parent has.
|
|
72
|
+
singleton_class.prepend(ResponseChoice)
|
|
73
|
+
self.__ruact_streaming = { only: only && Array(only).map(&:to_s).freeze,
|
|
74
|
+
except: except && Array(except).map(&:to_s).freeze,
|
|
75
|
+
declared_in: self }.freeze
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Whether `action` sends its page as it renders: an action declared
|
|
79
|
+
# with `ruact_streaming`, or any action of a controller that includes
|
|
80
|
+
# `ActionController::Live` and declares nothing.
|
|
81
|
+
#
|
|
82
|
+
# @param action [String, Symbol]
|
|
83
|
+
# @return [Boolean]
|
|
84
|
+
# @raise [Ruact::ConfigurationError] when `ruact_streaming only:` names
|
|
85
|
+
# an action this controller does not have
|
|
86
|
+
def ruact_streaming?(action)
|
|
87
|
+
declared = __ruact_streaming
|
|
88
|
+
return include?(ActionController::Live) if declared.nil?
|
|
89
|
+
|
|
90
|
+
name = action.to_s
|
|
91
|
+
if declared[:only]
|
|
92
|
+
Streaming.check_declared!(self, declared) if declared[:declared_in].equal?(self)
|
|
93
|
+
return declared[:only].include?(name)
|
|
94
|
+
end
|
|
95
|
+
return false if declared[:except]&.include?(name)
|
|
96
|
+
|
|
97
|
+
ruact_page?(name)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Whether `action` runs under `ActionController::Live`: it streams a
|
|
101
|
+
# ruact page, or the app included Live itself — in this controller or
|
|
102
|
+
# in a parent — for actions of its own (an SSE stream, `send_stream`),
|
|
103
|
+
# which keep it.
|
|
104
|
+
#
|
|
105
|
+
# @api private
|
|
106
|
+
# @param action [String, Symbol]
|
|
107
|
+
# @return [Boolean]
|
|
108
|
+
def __ruact_live?(action)
|
|
109
|
+
ruact_streaming?(action) || Streaming.live_by_app?(self)
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Includes `ActionController::Live` in +controller+ for `ruact_streaming`,
|
|
114
|
+
# marked as ruact's doing so {LiveInclusion} does not take it for the app's.
|
|
115
|
+
#
|
|
116
|
+
# @api private
|
|
117
|
+
def self.include_live(controller)
|
|
118
|
+
return if controller.include?(ActionController::Live)
|
|
119
|
+
|
|
120
|
+
controller.instance_variable_set(:@__ruact_including_live, true)
|
|
121
|
+
controller.include(ActionController::Live)
|
|
122
|
+
ensure
|
|
123
|
+
controller.remove_instance_variable(:@__ruact_including_live) if
|
|
124
|
+
controller.instance_variable_defined?(:@__ruact_including_live)
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# Whether the app included `ActionController::Live` in +controller+ or a
|
|
128
|
+
# parent, itself and not through `ruact_streaming` (see {LiveInclusion}).
|
|
129
|
+
#
|
|
130
|
+
# @api private
|
|
131
|
+
def self.live_by_app?(controller)
|
|
132
|
+
controller.ancestors.any? { |ancestor| ancestor.instance_variable_get(:@__ruact_live_by_app) }
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# Notes, on a class, that the app included `ActionController::Live` in it
|
|
136
|
+
# itself. Ruby calls `append_features` on every `include`, even of a
|
|
137
|
+
# module the class already has — the case of a controller that includes
|
|
138
|
+
# Live under a parent that declared `ruact_streaming`, where the include
|
|
139
|
+
# changes nothing and has to be seen anyway.
|
|
140
|
+
module LiveInclusion
|
|
141
|
+
def append_features(base)
|
|
142
|
+
unless base.instance_variable_defined?(:@__ruact_including_live)
|
|
143
|
+
base.instance_variable_set(:@__ruact_live_by_app, true)
|
|
144
|
+
end
|
|
145
|
+
super
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
ActionController::Live.singleton_class.prepend(LiveInclusion)
|
|
149
|
+
|
|
150
|
+
# The typo check for `ruact_streaming only:`, as `ruact_pages` has it
|
|
151
|
+
# (see {Pages.check_declared!}).
|
|
152
|
+
#
|
|
153
|
+
# @api private
|
|
154
|
+
# @raise [Ruact::ConfigurationError]
|
|
155
|
+
def self.check_declared!(owner, declared)
|
|
156
|
+
unknown = declared[:only] - owner.action_methods.to_a
|
|
157
|
+
unknown = unknown.reject { |action| File.exist?(owner.ruact_template_path(action)) }
|
|
158
|
+
return if unknown.empty?
|
|
159
|
+
|
|
160
|
+
what = unknown.one? ? "is not an action" : "are not actions"
|
|
161
|
+
raise Ruact::ConfigurationError,
|
|
162
|
+
"#{owner.name} declares ruact_streaming for #{unknown.join(', ')}, which #{what} of it " \
|
|
163
|
+
"(no method and no template). Check the spelling in `ruact_streaming`."
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# `process` for an action that does not stream, in a controller that
|
|
167
|
+
# includes Live: everything but Live's own `process`, which would run the
|
|
168
|
+
# action in another thread.
|
|
169
|
+
#
|
|
170
|
+
# @api private
|
|
171
|
+
def self.process_without_live(controller, name)
|
|
172
|
+
method = controller.method(:process)
|
|
173
|
+
method = method.super_method until method.owner == ActionController::Live
|
|
174
|
+
method.super_method.call(name)
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
# The response, chosen per action: Live's for an action that streams, the
|
|
178
|
+
# ordinary one for the rest. Prepended to every ruact controller's
|
|
179
|
+
# singleton class.
|
|
180
|
+
module ResponseChoice
|
|
181
|
+
def make_response!(request)
|
|
182
|
+
request.set_header(REQUEST_THREAD_ENV, Thread.current)
|
|
183
|
+
return super unless __ruact_streaming
|
|
184
|
+
|
|
185
|
+
name = request.get_header(ACTION_ENV) || request.path_parameters[:action]
|
|
186
|
+
return super if name.nil? || __ruact_live?(name)
|
|
187
|
+
|
|
188
|
+
ActionController::Metal.singleton_class.instance_method(:make_response!).bind_call(self, request)
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
# The app `Controller.action(:name)` returns, which tells
|
|
192
|
+
# `make_response!` the action's name on the way in.
|
|
193
|
+
def action(name)
|
|
194
|
+
app = super
|
|
195
|
+
action_name = name.to_s.freeze
|
|
196
|
+
lambda do |env|
|
|
197
|
+
env[ACTION_ENV] = action_name
|
|
198
|
+
app.call(env)
|
|
199
|
+
end
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
# Included right after Live by `ruact_streaming`: an action that does not
|
|
204
|
+
# stream runs on the request's thread, as without Live — unless the app
|
|
205
|
+
# included Live itself, for actions of its own.
|
|
206
|
+
module LiveChoice
|
|
207
|
+
def process(name)
|
|
208
|
+
return super if self.class.__ruact_live?(name)
|
|
209
|
+
|
|
210
|
+
Streaming.process_without_live(self, name)
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
private
|
|
215
|
+
|
|
216
|
+
# Story 19-1 (D4) — under Live the action ran in a thread of the pool, and
|
|
217
|
+
# Rails gives back what the request's thread leased, not what this one
|
|
218
|
+
# did. Without this a `Model.connection` in a streamed action keeps its
|
|
219
|
+
# connection until the thread is reused (measured in 19-0, in 0.0.19
|
|
220
|
+
# too). Not when the action ran on the request's thread (Rails' test
|
|
221
|
+
# support runs Live inline).
|
|
222
|
+
def process_action(*)
|
|
223
|
+
super
|
|
224
|
+
ensure
|
|
225
|
+
__ruact_check_writes_after_stream
|
|
226
|
+
__ruact_release_connections if __ruact_on_live_thread?
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
def __ruact_on_live_thread?
|
|
230
|
+
request_thread = request&.get_header(REQUEST_THREAD_ENV)
|
|
231
|
+
!request_thread.nil? && !request_thread.equal?(Thread.current)
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
def __ruact_release_connections
|
|
235
|
+
return unless defined?(::ActiveRecord::Base)
|
|
236
|
+
|
|
237
|
+
::ActiveRecord::Base.connection_handler.clear_active_connections!(:all)
|
|
238
|
+
end
|
|
239
|
+
end
|
|
240
|
+
end
|
|
241
|
+
end
|
data/lib/ruact/controller.rb
CHANGED
|
@@ -7,6 +7,8 @@ require_relative "redirect_path"
|
|
|
7
7
|
require_relative "view_helper"
|
|
8
8
|
require_relative "validation_errors_collector"
|
|
9
9
|
require_relative "controller/document_rendering"
|
|
10
|
+
require_relative "controller/delivery"
|
|
11
|
+
require_relative "controller/streaming"
|
|
10
12
|
require_relative "controller/pages"
|
|
11
13
|
require_relative "controller/page_rendering"
|
|
12
14
|
require_relative "controller/format_negotiation"
|
|
@@ -36,9 +38,14 @@ module Ruact
|
|
|
36
38
|
# and so the shared `vite_dev_running?` / `vite_manifest_entry` helpers are
|
|
37
39
|
# reachable here without duplication.
|
|
38
40
|
include Ruact::ViewHelper
|
|
39
|
-
#
|
|
40
|
-
#
|
|
41
|
-
|
|
41
|
+
# How a rendered page is written — a Flight body, all at once or row by
|
|
42
|
+
# row, or an HTML document whose `<head>` the app's layout owns. See
|
|
43
|
+
# Ruact::Controller::Delivery and Ruact::Controller::DocumentRendering.
|
|
44
|
+
include Ruact::Controller::Delivery
|
|
45
|
+
# Which actions send their page as it renders — `ruact_streaming`, or a
|
|
46
|
+
# controller that includes `ActionController::Live` (Story 19-1). See
|
|
47
|
+
# Ruact::Controller::Streaming.
|
|
48
|
+
include Ruact::Controller::Streaming
|
|
42
49
|
# Which actions are ruact pages — the whole controller, or the ones it
|
|
43
50
|
# declares with `ruact_pages` (Story 17.0g). See Ruact::Controller::Pages.
|
|
44
51
|
include Ruact::Controller::Pages
|
|
@@ -79,11 +86,13 @@ module Ruact
|
|
|
79
86
|
|
|
80
87
|
private
|
|
81
88
|
|
|
82
|
-
#
|
|
83
|
-
#
|
|
84
|
-
#
|
|
85
|
-
# only ever invoked internally by `ruact_html_shell
|
|
86
|
-
|
|
89
|
+
# The ruact helpers are public VIEW helpers, but on a controller a public
|
|
90
|
+
# instance method is exposed as a routable action. Demote the mixed-in
|
|
91
|
+
# helper methods so they are never callable as actions (the asset helpers
|
|
92
|
+
# are only ever invoked internally by `ruact_html_shell`; the others only
|
|
93
|
+
# by templates).
|
|
94
|
+
private :ruact_js_assets, :ruact_head_assets, :__ruact_component__, :__ruact_component_open__,
|
|
95
|
+
:__ruact_suspense__
|
|
87
96
|
|
|
88
97
|
# Resolves the manifest for this render. In PRODUCTION this is the boot-time
|
|
89
98
|
# cached +Ruact.manifest+ (set by Railtie#config.to_prepare) — no per-request
|
|
@@ -171,7 +180,13 @@ module Ruact
|
|
|
171
180
|
pipeline = RenderPipeline.new(ruact_manifest,
|
|
172
181
|
controller_path: controller_path, logger: logger,
|
|
173
182
|
development: Flight::Renderer.development_default)
|
|
174
|
-
|
|
183
|
+
# Story 19-1 — in an action that streams, the first load and a
|
|
184
|
+
# navigation alike, each `<Suspense>`'s content renders after the shell
|
|
185
|
+
# is sent: the view keeps its blocks instead of running them. Only when
|
|
186
|
+
# the response really is Live's: on HTTP/1.0 (nginx's default to its
|
|
187
|
+
# upstream) Live gives an ordinary response, which the server sends as
|
|
188
|
+
# soon as anything is written — the page then renders whole, in place.
|
|
189
|
+
streaming = self.class.ruact_streaming?(action_name) && response.is_a?(ActionController::Live::Response)
|
|
175
190
|
|
|
176
191
|
# Allocate a per-render context and expose it to the view via a normal
|
|
177
192
|
# (non-`@_`-prefixed) instance variable on the controller. Rails 8's
|
|
@@ -186,7 +201,7 @@ module Ruact
|
|
|
186
201
|
# controller instance is per-request (Rails allocates a new one per
|
|
187
202
|
# action), so this is per-request safe under multi-threaded servers
|
|
188
203
|
# (NFR8). See Story 7.9 / Bug 7.8-B.
|
|
189
|
-
with_render_context do |render_context|
|
|
204
|
+
with_render_context(deferring: streaming) do |render_context|
|
|
190
205
|
opts = template ? { template: template } : { action: action_name }
|
|
191
206
|
html = render_to_string(opts.merge(details).merge(layout: false, locals: locals))
|
|
192
207
|
emit_ruact_response(pipeline, html, render_context, streaming: streaming)
|
|
@@ -201,10 +216,10 @@ module Ruact
|
|
|
201
216
|
# `nil` would leak a phantom assignment into `view_assigns`
|
|
202
217
|
# (`{"ruact_render_context" => nil}`) on any subsequent error/rescue
|
|
203
218
|
# render in the same request.
|
|
204
|
-
def with_render_context
|
|
219
|
+
def with_render_context(deferring: false)
|
|
205
220
|
had_previous = instance_variable_defined?(:@ruact_render_context)
|
|
206
221
|
previous = @ruact_render_context if had_previous
|
|
207
|
-
render_context = RenderContext.new
|
|
222
|
+
render_context = RenderContext.new(deferring: deferring)
|
|
208
223
|
@ruact_render_context = render_context
|
|
209
224
|
begin
|
|
210
225
|
yield render_context
|
|
@@ -217,38 +232,6 @@ module Ruact
|
|
|
217
232
|
end
|
|
218
233
|
end
|
|
219
234
|
|
|
220
|
-
# Build the wire output and write it to the response. Streaming responses
|
|
221
|
-
# only fire after `pipeline.render` returns without raising — that way a
|
|
222
|
-
# missing-component error can still surface as a normal 500 response
|
|
223
|
-
# (matching the legacy `#from_html` ordering) before any streaming
|
|
224
|
-
# response headers are mutated.
|
|
225
|
-
def emit_ruact_response(pipeline, html, render_context, streaming:)
|
|
226
|
-
# Story 15.5 (FR109) — record which page shape ruact rendered so the
|
|
227
|
-
# `Ruact::Server` dev log (`__ruact_log_response_shape!`) can name it and,
|
|
228
|
-
# crucially, stay SILENT on a plain Rails render (this flag is never set
|
|
229
|
-
# there). `@__`-prefixed → never leaks into `view_assigns`.
|
|
230
|
-
@__ruact_negotiated_page = ruact_request? ? :flight : :html_shell
|
|
231
|
-
|
|
232
|
-
if ruact_request? && streaming
|
|
233
|
-
enumerator = pipeline.render({ html: html, render_context: render_context }, mode: :stream)
|
|
234
|
-
response.headers["Content-Type"] = "text/x-component; charset=utf-8"
|
|
235
|
-
response.headers["Cache-Control"] = "no-cache"
|
|
236
|
-
response.headers["X-Accel-Buffering"] = "no"
|
|
237
|
-
begin
|
|
238
|
-
enumerator.each { |row| response.stream.write(row) }
|
|
239
|
-
ensure
|
|
240
|
-
response.stream.close
|
|
241
|
-
end
|
|
242
|
-
else
|
|
243
|
-
payload = pipeline.render({ html: html, render_context: render_context }, mode: :string)
|
|
244
|
-
if ruact_request?
|
|
245
|
-
render plain: payload, content_type: "text/x-component"
|
|
246
|
-
else
|
|
247
|
-
render_ruact_document(payload)
|
|
248
|
-
end
|
|
249
|
-
end
|
|
250
|
-
end
|
|
251
|
-
|
|
252
235
|
# Overrides Rails redirect_to for RSC requests: emits a Flight redirect row
|
|
253
236
|
# (`0:` followed by a JSON object with `redirectUrl` and `redirectType: "push"`)
|
|
254
237
|
# instead of a 302 response.
|
|
@@ -291,13 +274,11 @@ module Ruact
|
|
|
291
274
|
# Story 15.5 (FR109) — mark the Flight-redirect sub-shape for the dev log.
|
|
292
275
|
@__ruact_negotiated_page = :flight_redirect
|
|
293
276
|
|
|
294
|
-
|
|
295
|
-
content_type: "text/x-component"
|
|
277
|
+
__ruact_render_flight_redirect(redirect_url)
|
|
296
278
|
end
|
|
297
279
|
|
|
298
280
|
def ruact_request?
|
|
299
|
-
|
|
300
|
-
request.headers["Ruact-Request"] == "1"
|
|
281
|
+
RequestKind.flight?(request)
|
|
301
282
|
end
|
|
302
283
|
|
|
303
284
|
# Rails keeps `redirect_to` PUBLIC, and this module's body is private from
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "timeout"
|
|
5
|
+
|
|
6
|
+
module Ruact
|
|
7
|
+
# Story 19-1 — the render of a page whose `<Suspense>` content runs after the
|
|
8
|
+
# shell is sent: the shell first, then each boundary, one at a time, in page
|
|
9
|
+
# order, each block evaluated only when its row is due.
|
|
10
|
+
#
|
|
11
|
+
# run = DeferredRender.new(html, render_context, pipeline)
|
|
12
|
+
# write(run.shell)
|
|
13
|
+
# write(run.next_rows) while run.pending?
|
|
14
|
+
#
|
|
15
|
+
# A block runs in the view it was written in, with the template path Rails
|
|
16
|
+
# had when it met the tag, under `Ruact.config.suspense_timeout`. A block
|
|
17
|
+
# that raises, or runs past the timeout, becomes an error row for its
|
|
18
|
+
# boundary — reported through `Rails.error` and logged — and the next block
|
|
19
|
+
# runs. {Flight::Renderer} only ever serializes elements: evaluating, logging
|
|
20
|
+
# and reporting are here, outside `lib/ruact/flight/` (NFR10).
|
|
21
|
+
#
|
|
22
|
+
# Internal API: not part of the public compatibility contract.
|
|
23
|
+
class DeferredRender
|
|
24
|
+
TIMEOUT_DIGEST = "ruact:suspense-timeout"
|
|
25
|
+
ERROR_DIGEST = "ruact:suspense-error"
|
|
26
|
+
|
|
27
|
+
# The content ran past `suspense_timeout`. Its own: a `Timeout::Error` the
|
|
28
|
+
# content raised itself (`Net::ReadTimeout` is one) is the content's error.
|
|
29
|
+
class Expired < StandardError; end
|
|
30
|
+
|
|
31
|
+
# What production says about a block that raised: nothing of the error.
|
|
32
|
+
PRODUCTION_MESSAGE = "An error occurred in a <Suspense>"
|
|
33
|
+
|
|
34
|
+
# React's development client reads an error's `stack` as frames; a few
|
|
35
|
+
# name the template and the gem without burying the overlay.
|
|
36
|
+
STACK_FRAMES = 12
|
|
37
|
+
|
|
38
|
+
# @param html [String] the page ActionView rendered with its blocks kept
|
|
39
|
+
# (`RenderContext.new(deferring: true)`)
|
|
40
|
+
# @param pipeline [RenderPipeline] the manifest, controller path, logger
|
|
41
|
+
# and wire flavour the render uses
|
|
42
|
+
# @param guard [#call, nil] wraps each block's evaluation, inside the
|
|
43
|
+
# timeout: called with a block, returns its value or raises (the
|
|
44
|
+
# controller's development check for writes the response can no longer
|
|
45
|
+
# carry)
|
|
46
|
+
def initialize(html, render_context, pipeline, guard: nil)
|
|
47
|
+
@render_context = render_context
|
|
48
|
+
@manifest = pipeline.manifest
|
|
49
|
+
@controller_path = pipeline.controller_path
|
|
50
|
+
@development = pipeline.development
|
|
51
|
+
@logger = pipeline.logger
|
|
52
|
+
@guard = guard
|
|
53
|
+
@registry = []
|
|
54
|
+
@renderer = Flight::Renderer.new(convert(html), @manifest,
|
|
55
|
+
strict_serialization: Ruact.config.strict_serialization,
|
|
56
|
+
on_as_json_warning: pipeline.as_json_warning_callback,
|
|
57
|
+
development: @development)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# The shell's rows, as one string.
|
|
61
|
+
def shell
|
|
62
|
+
@renderer.shell.join
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Whether a boundary is still to be rendered.
|
|
66
|
+
def pending?
|
|
67
|
+
@renderer.pending?
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# How many boundaries are still to be rendered.
|
|
71
|
+
def pending_count
|
|
72
|
+
@renderer.pending_count
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Renders the next boundary and returns its rows, as one string: the rows
|
|
76
|
+
# its content brings and its model row, or an error row.
|
|
77
|
+
def next_rows
|
|
78
|
+
boundary = @renderer.take_boundary
|
|
79
|
+
within_timeout(boundary[:delay]) do
|
|
80
|
+
@renderer.boundary_rows(boundary[:id], content(boundary[:element]))
|
|
81
|
+
end.join
|
|
82
|
+
rescue StandardError => e
|
|
83
|
+
raise if boundary.nil?
|
|
84
|
+
|
|
85
|
+
skip_unconverted_components
|
|
86
|
+
report(e, boundary)
|
|
87
|
+
@renderer.error_rows(boundary[:id], error_payload(e), boundary: boundary).join
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
private
|
|
91
|
+
|
|
92
|
+
# A block that failed may have registered components its HTML, now thrown
|
|
93
|
+
# away, was going to use: the next block's conversion starts after them.
|
|
94
|
+
def skip_unconverted_components
|
|
95
|
+
@registry.fill(nil, @registry.length...@render_context.components.length)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# The block's HTML as elements; content that is already elements as is.
|
|
99
|
+
def content(element)
|
|
100
|
+
return element unless element.is_a?(Flight::DeferredContent)
|
|
101
|
+
|
|
102
|
+
block = @render_context.block(element.index)
|
|
103
|
+
html = @guard ? @guard.call { evaluate(block) } : evaluate(block)
|
|
104
|
+
convert(html)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Runs the block in its view, with the template path restored, and returns
|
|
108
|
+
# what it wrote. Only what it writes: a value it ends on is not content.
|
|
109
|
+
def evaluate(pending)
|
|
110
|
+
pending.view.instance_exec(pending) do |entry|
|
|
111
|
+
saved = [@virtual_path, @current_template]
|
|
112
|
+
@virtual_path = entry.virtual_path
|
|
113
|
+
@current_template = entry.template
|
|
114
|
+
begin
|
|
115
|
+
capture do
|
|
116
|
+
entry.block.call
|
|
117
|
+
nil
|
|
118
|
+
end
|
|
119
|
+
ensure
|
|
120
|
+
@virtual_path, @current_template = saved
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# The components registered since the last conversion are resolved and
|
|
126
|
+
# added: a block registers its own as it runs. One the manifest cannot
|
|
127
|
+
# resolve fails this conversion and keeps its place (nil), so the next
|
|
128
|
+
# block does not meet it again.
|
|
129
|
+
def convert(html)
|
|
130
|
+
failure = nil
|
|
131
|
+
@render_context.components.drop(@registry.length).each do |entry|
|
|
132
|
+
@registry << @render_context.resolve_entry(entry, @manifest, controller_path: @controller_path)
|
|
133
|
+
rescue StandardError => e
|
|
134
|
+
@registry << nil
|
|
135
|
+
failure ||= e
|
|
136
|
+
end
|
|
137
|
+
raise failure if failure
|
|
138
|
+
|
|
139
|
+
HtmlConverter.convert(html.to_s, @registry.compact)
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# The block's `delay` and the block itself, within `suspense_timeout`.
|
|
143
|
+
# `Timeout.timeout` with no exception class unwinds the block with an
|
|
144
|
+
# exception that is not a StandardError, so a `rescue` in the view, or
|
|
145
|
+
# Active Record wrapping what a query raised, cannot take it; it raises
|
|
146
|
+
# `Timeout::Error` out here. It interrupts Ruby code, not a query running
|
|
147
|
+
# inside a database driver that holds the VM lock (measured in 19-0).
|
|
148
|
+
# A `delay` longer than the limit ends in the timeout row at once, as it
|
|
149
|
+
# did before blocks ran later.
|
|
150
|
+
def within_timeout(delay, &)
|
|
151
|
+
limit = Ruact.config.suspense_timeout
|
|
152
|
+
return wait_then(delay, &) unless limit&.positive?
|
|
153
|
+
raise Expired, "the delay (#{delay}s) is longer than suspense_timeout" if delay && delay > limit
|
|
154
|
+
|
|
155
|
+
started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
156
|
+
begin
|
|
157
|
+
Timeout.timeout(limit) { wait_then(delay, &) }
|
|
158
|
+
rescue Timeout::Error => e
|
|
159
|
+
# Ours only when the limit has passed; earlier, it is the content's.
|
|
160
|
+
raise if Process.clock_gettime(Process::CLOCK_MONOTONIC) - started < limit
|
|
161
|
+
|
|
162
|
+
raise Expired, "ran past suspense_timeout (#{limit}s)", cause: e
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def wait_then(delay)
|
|
167
|
+
sleep(delay) if delay&.positive?
|
|
168
|
+
yield
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def timed_out?(error)
|
|
172
|
+
error.is_a?(Expired)
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def error_payload(error)
|
|
176
|
+
return Flight::RowEmitter.error_payload("Suspense timeout exceeded", digest: TIMEOUT_DIGEST) if timed_out?(error)
|
|
177
|
+
return Flight::RowEmitter.error_payload(PRODUCTION_MESSAGE, digest: ERROR_DIGEST) unless @development
|
|
178
|
+
|
|
179
|
+
payload = Flight::RowEmitter.error_payload("#{error.class}: #{error.message}", digest: ERROR_DIGEST)
|
|
180
|
+
payload["stack"] = development_stack(error)
|
|
181
|
+
payload
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
# React's development client reads `stack` as `[function, file, line, column]`.
|
|
185
|
+
def development_stack(error)
|
|
186
|
+
Array(error.backtrace).first(STACK_FRAMES).filter_map do |frame|
|
|
187
|
+
match = frame.match(/\A(.+?):(\d+)(?::in [`'](.+)')?\z/)
|
|
188
|
+
match && [match[3] || "(ruby)", match[1], Integer(match[2], 10), 0]
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def report(error, boundary)
|
|
193
|
+
what = timed_out?(error) ? "ran past suspense_timeout (#{Ruact.config.suspense_timeout}s)" : "raised"
|
|
194
|
+
@logger&.error("[ruact] <Suspense> content#{where(boundary)} #{what}: #{error.class}: #{error.message}")
|
|
195
|
+
return unless defined?(Rails) && Rails.respond_to?(:error)
|
|
196
|
+
|
|
197
|
+
Rails.error.report(error, handled: true, severity: :error, source: "ruact.suspense")
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
def where(boundary)
|
|
201
|
+
element = boundary[:element]
|
|
202
|
+
return "" unless element.is_a?(Flight::DeferredContent)
|
|
203
|
+
|
|
204
|
+
location = @render_context.block(element.index).location
|
|
205
|
+
location ? " at #{location.path}:#{location.lineno}" : ""
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
end
|
data/lib/ruact/doctor.rb
CHANGED
|
@@ -8,7 +8,7 @@ module Ruact
|
|
|
8
8
|
# Runs a suite of installation health checks and prints ✓/✗ per check.
|
|
9
9
|
# Extracted from the ruact:doctor Rake task for direct testability (FR27).
|
|
10
10
|
class Doctor # rubocop:disable Metrics/ClassLength
|
|
11
|
-
CHECKS = %i[manifest vite flight_client controller layout head_assets streaming legacy_constant
|
|
11
|
+
CHECKS = %i[manifest vite flight_client controller layout head_assets streaming load_async legacy_constant
|
|
12
12
|
serialize_only flight_middleware].freeze
|
|
13
13
|
# Built via Array#join so the gem-CI `name-propagation` guard does not
|
|
14
14
|
# match these literals against itself (Story 5.1 review F4 — the doctor
|
|
@@ -487,6 +487,23 @@ module Ruact
|
|
|
487
487
|
[:pass, "streaming: #{label} (#{streaming_server_hint})"]
|
|
488
488
|
end
|
|
489
489
|
|
|
490
|
+
# Story 19-1 (ADR D1) — whether `load_async` runs its query in the
|
|
491
|
+
# background. It is how a page starts its queries early, so they finish
|
|
492
|
+
# while the shell is sent; without an executor (a new Rails app's default)
|
|
493
|
+
# it loads where it is called. A warning, not a failure: an app that never
|
|
494
|
+
# calls `load_async` has nothing to fix.
|
|
495
|
+
def check_load_async
|
|
496
|
+
return [:pass, "load_async: Active Record is not loaded"] unless defined?(::ActiveRecord::Base)
|
|
497
|
+
|
|
498
|
+
executor = ::ActiveRecord.async_query_executor
|
|
499
|
+
return [:pass, "load_async: runs in the background (async_query_executor = #{executor.inspect})"] if executor
|
|
500
|
+
|
|
501
|
+
[:warn,
|
|
502
|
+
"load_async: runs its query where it is called — config.active_record.async_query_executor is not " \
|
|
503
|
+
"set. Set it to :global_thread_pool (config/application.rb) so a page's queries start early and " \
|
|
504
|
+
"finish while its shell is sent."]
|
|
505
|
+
end
|
|
506
|
+
|
|
490
507
|
# Detects host-app references to the legacy gem constant or require path
|
|
491
508
|
# left over from the rename to `ruact`. Literal names are interpolated
|
|
492
509
|
# from LEGACY_CONST / LEGACY_GEM so this file passes the gem-CI
|