ruact 0.0.19 → 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.
Files changed (42) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +30 -1
  3. data/CONTRIBUTING.md +10 -3
  4. data/README.md +1 -1
  5. data/lib/generators/ruact/install/templates/AGENTS.md.tt +8 -8
  6. data/lib/generators/ruact/install/templates/initializer.rb.tt +4 -1
  7. data/lib/ruact/configuration.rb +6 -1
  8. data/lib/ruact/controller/delivery.rb +247 -0
  9. data/lib/ruact/controller/document_rendering.rb +15 -12
  10. data/lib/ruact/controller/streaming.rb +241 -0
  11. data/lib/ruact/controller.rb +29 -48
  12. data/lib/ruact/deferred_render.rb +208 -0
  13. data/lib/ruact/doctor.rb +18 -1
  14. data/lib/ruact/erb_preprocessor.rb +203 -22
  15. data/lib/ruact/flight/react_element.rb +17 -2
  16. data/lib/ruact/flight/renderer.rb +91 -41
  17. data/lib/ruact/flight/request.rb +71 -27
  18. data/lib/ruact/flight/serializer.rb +3 -10
  19. data/lib/ruact/html_converter.rb +23 -31
  20. data/lib/ruact/inline_script.rb +24 -0
  21. data/lib/ruact/load_async_note.rb +51 -0
  22. data/lib/ruact/markers.rb +95 -0
  23. data/lib/ruact/navigation_boundary.rb +2 -2
  24. data/lib/ruact/railtie.rb +12 -0
  25. data/lib/ruact/render_context.rb +61 -6
  26. data/lib/ruact/render_pipeline.rb +29 -21
  27. data/lib/ruact/request_kind.rb +36 -0
  28. data/lib/ruact/server.rb +3 -7
  29. data/lib/ruact/testing/flight_extractor.rb +21 -4
  30. data/lib/ruact/version.rb +1 -1
  31. data/lib/ruact/view_helper/suspense.rb +62 -0
  32. data/lib/ruact/view_helper.rb +24 -32
  33. data/lib/ruact.rb +4 -0
  34. data/vendor/javascript/vite-plugin-ruact/bootstrap.test.mjs +16 -3
  35. data/vendor/javascript/vite-plugin-ruact/page-mount.test.mjs +67 -0
  36. data/vendor/javascript/vite-plugin-ruact/page-stream.test.mjs +107 -0
  37. data/vendor/javascript/vite-plugin-ruact/runtime/bootstrap.jsx +23 -18
  38. data/vendor/javascript/vite-plugin-ruact/runtime/dev-overlay.js +39 -0
  39. data/vendor/javascript/vite-plugin-ruact/runtime/page-stream.js +88 -0
  40. data/vendor/javascript/vite-plugin-ruact/runtime/ruact-router.js +11 -17
  41. data/vendor/javascript/vite-plugin-ruact/runtime/suspense-boundary.js +1 -0
  42. 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
@@ -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
- # Who owns the `<head>` — the host app's layout, with ruact's built-in shell
40
- # as the un-migrated fallback. See Ruact::Controller::DocumentRendering.
41
- include Ruact::Controller::DocumentRendering
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
- # `ruact_js_assets` / `__ruact_component__` are public VIEW helpers, but on a
83
- # controller a public instance method is exposed as a routable action. Demote
84
- # the mixed-in helper methods so they are never callable as actions (they are
85
- # only ever invoked internally by `ruact_html_shell`).
86
- private :ruact_js_assets, :ruact_head_assets, :__ruact_component__
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
- streaming = ruact_request? && self.class.ancestors.include?(ActionController::Live)
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
- render plain: "0:#{JSON.generate({ 'redirectUrl' => redirect_url, 'redirectType' => 'push' })}\n",
295
- content_type: "text/x-component"
277
+ __ruact_render_flight_redirect(redirect_url)
296
278
  end
297
279
 
298
280
  def ruact_request?
299
- request.headers["Accept"]&.include?("text/x-component") ||
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