ruact 0.0.19 → 0.0.21
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 +57 -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/cached_blocks.rb +30 -0
- data/lib/ruact/configuration.rb +6 -1
- data/lib/ruact/controller/delivery.rb +262 -0
- data/lib/ruact/controller/document_rendering.rb +15 -12
- data/lib/ruact/controller/streaming.rb +305 -0
- data/lib/ruact/controller.rb +34 -51
- data/lib/ruact/declared_content.rb +33 -0
- data/lib/ruact/deferred_render.rb +254 -0
- data/lib/ruact/doctor.rb +42 -2
- data/lib/ruact/erb_preprocessor.rb +211 -24
- data/lib/ruact/flight/react_element.rb +22 -2
- data/lib/ruact/flight/renderer.rb +94 -41
- data/lib/ruact/flight/request.rb +71 -27
- data/lib/ruact/flight/serializer.rb +3 -10
- data/lib/ruact/html_converter.rb +44 -34
- data/lib/ruact/inline_script.rb +24 -0
- data/lib/ruact/load_async_note.rb +51 -0
- data/lib/ruact/markers.rb +98 -0
- data/lib/ruact/navigation_boundary.rb +2 -2
- data/lib/ruact/query_watch.rb +106 -0
- data/lib/ruact/railtie.rb +12 -0
- data/lib/ruact/render_context.rb +73 -6
- data/lib/ruact/render_pipeline.rb +32 -22
- 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 +70 -0
- data/lib/ruact/view_helper.rb +35 -33
- data/lib/ruact.rb +7 -0
- data/vendor/javascript/vite-plugin-ruact/bootstrap.test.mjs +16 -3
- data/vendor/javascript/vite-plugin-ruact/page-mount.test.mjs +75 -0
- data/vendor/javascript/vite-plugin-ruact/page-stream.test.mjs +132 -0
- data/vendor/javascript/vite-plugin-ruact/ruact-router.test.mjs +185 -0
- data/vendor/javascript/vite-plugin-ruact/runtime/bootstrap.jsx +23 -18
- data/vendor/javascript/vite-plugin-ruact/runtime/dev-overlay.js +53 -0
- data/vendor/javascript/vite-plugin-ruact/runtime/page-stream.js +108 -0
- data/vendor/javascript/vite-plugin-ruact/runtime/ruact-router.js +74 -20
- data/vendor/javascript/vite-plugin-ruact/runtime/suspense-boundary.js +1 -0
- metadata +17 -2
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "securerandom"
|
|
4
|
+
|
|
5
|
+
module Ruact
|
|
6
|
+
module Controller
|
|
7
|
+
# Story 19-1 (ADR D6, option A) — which actions send their page as it
|
|
8
|
+
# renders: the shell first, then each `<Suspense>`'s content when it is
|
|
9
|
+
# ready.
|
|
10
|
+
#
|
|
11
|
+
# Streaming needs `ActionController::Live`, and Live is decided before the
|
|
12
|
+
# action runs: it gives the action a response whose body is a queue, and
|
|
13
|
+
# runs the action in a thread of its own. So the app says which actions
|
|
14
|
+
# stream (`ruact_streaming`), and ruact applies Live to those and only
|
|
15
|
+
# those. The other actions of the controller respond as they did — a
|
|
16
|
+
# buffered response with its `ETag`, on the request's thread.
|
|
17
|
+
#
|
|
18
|
+
# A controller that includes `ActionController::Live` itself, and declares
|
|
19
|
+
# nothing, streams every action.
|
|
20
|
+
module Streaming
|
|
21
|
+
extend ActiveSupport::Concern
|
|
22
|
+
|
|
23
|
+
# Where `Controller.action(:name)` leaves the action's name: a route that
|
|
24
|
+
# names no action (`get "/x", to: PagesController.action(:show)`) gives
|
|
25
|
+
# `make_response!` nothing else to go on.
|
|
26
|
+
ACTION_ENV = "ruact.action"
|
|
27
|
+
|
|
28
|
+
# The thread that received the request. Under Live the action runs in
|
|
29
|
+
# another one, and what that thread leased from the connection pool is
|
|
30
|
+
# its own to give back.
|
|
31
|
+
REQUEST_THREAD_ENV = "ruact.request_thread"
|
|
32
|
+
|
|
33
|
+
included do
|
|
34
|
+
# Story 19-1 — the actions this controller declared with
|
|
35
|
+
# `ruact_streaming`, or nil. Inherited, and redeclarable by a subclass.
|
|
36
|
+
class_attribute :__ruact_streaming, instance_accessor: false, instance_predicate: false, default: nil
|
|
37
|
+
singleton_class.prepend(ResponseChoice)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
class_methods do
|
|
41
|
+
# Story 19-1 — the actions whose page is sent as it renders: the shell
|
|
42
|
+
# and the `<Suspense>` fallbacks first, then each `<Suspense>`'s
|
|
43
|
+
# content, rendered after the shell is sent, as it is ready. On the
|
|
44
|
+
# first load and on a navigation.
|
|
45
|
+
#
|
|
46
|
+
# Without arguments, every ruact page of the controller streams (an
|
|
47
|
+
# action with no template of its own, a JSON one, does not); `only:`
|
|
48
|
+
# names the actions, `except:` leaves some out. One line in
|
|
49
|
+
# `ApplicationController` makes every page of the app stream.
|
|
50
|
+
#
|
|
51
|
+
# The actions that stream run under `ActionController::Live`, in a
|
|
52
|
+
# thread of their own: an `ETag` is not computed for them, and what a
|
|
53
|
+
# middleware set up for the request's thread (a thread variable, the
|
|
54
|
+
# locale) is not theirs — set it in the controller (`around_action`).
|
|
55
|
+
# The other actions are untouched.
|
|
56
|
+
#
|
|
57
|
+
# @param only [Symbol, String, Array<Symbol, String>] the actions that stream
|
|
58
|
+
# @param except [Symbol, String, Array<Symbol, String>] every page but these
|
|
59
|
+
# @return [void]
|
|
60
|
+
# @raise [ArgumentError] when given both `only:` and `except:`
|
|
61
|
+
# @example The show page streams
|
|
62
|
+
# class PostsController < ApplicationController
|
|
63
|
+
# include Ruact::Controller
|
|
64
|
+
# ruact_streaming only: :show
|
|
65
|
+
# end
|
|
66
|
+
def ruact_streaming(only: nil, except: nil)
|
|
67
|
+
raise ArgumentError, "ruact_streaming takes only: or except:, not both (#{name})" if only && except
|
|
68
|
+
|
|
69
|
+
Streaming.include_live(self)
|
|
70
|
+
# Directly above Live, so the actions that do not stream skip it.
|
|
71
|
+
include LiveChoice unless include?(LiveChoice)
|
|
72
|
+
# Ahead of Live's `make_response!`, which this class now extends and
|
|
73
|
+
# which would otherwise answer before the choice the parent has.
|
|
74
|
+
singleton_class.prepend(ResponseChoice)
|
|
75
|
+
self.__ruact_streaming = { only: only && Array(only).map(&:to_s).freeze,
|
|
76
|
+
except: except && Array(except).map(&:to_s).freeze,
|
|
77
|
+
declared_in: self }.freeze
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Whether `action` sends its page as it renders: an action declared
|
|
81
|
+
# with `ruact_streaming`, or any action of a controller that includes
|
|
82
|
+
# `ActionController::Live` and declares nothing.
|
|
83
|
+
#
|
|
84
|
+
# @param action [String, Symbol]
|
|
85
|
+
# @return [Boolean]
|
|
86
|
+
# @raise [Ruact::ConfigurationError] when `ruact_streaming only:` names
|
|
87
|
+
# an action this controller does not have
|
|
88
|
+
def ruact_streaming?(action)
|
|
89
|
+
declared = __ruact_streaming
|
|
90
|
+
return include?(ActionController::Live) if declared.nil?
|
|
91
|
+
|
|
92
|
+
name = action.to_s
|
|
93
|
+
if declared[:only]
|
|
94
|
+
Streaming.check_declared!(self, declared) if declared[:declared_in].equal?(self)
|
|
95
|
+
return declared[:only].include?(name)
|
|
96
|
+
end
|
|
97
|
+
return false if declared[:except]&.include?(name)
|
|
98
|
+
|
|
99
|
+
ruact_page?(name)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Whether `action` runs under `ActionController::Live`: it streams a
|
|
103
|
+
# ruact page, or the app included Live itself — in this controller or
|
|
104
|
+
# in a parent — for actions of its own (an SSE stream, `send_stream`),
|
|
105
|
+
# which keep it.
|
|
106
|
+
#
|
|
107
|
+
# @api private
|
|
108
|
+
# @param action [String, Symbol]
|
|
109
|
+
# @return [Boolean]
|
|
110
|
+
def __ruact_live?(action)
|
|
111
|
+
ruact_streaming?(action) || Streaming.live_by_app?(self)
|
|
112
|
+
end
|
|
113
|
+
end
|
|
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
|
+
|
|
169
|
+
# Includes `ActionController::Live` in +controller+ for `ruact_streaming`,
|
|
170
|
+
# marked as ruact's doing so {LiveInclusion} does not take it for the app's.
|
|
171
|
+
#
|
|
172
|
+
# @api private
|
|
173
|
+
def self.include_live(controller)
|
|
174
|
+
return if controller.include?(ActionController::Live)
|
|
175
|
+
|
|
176
|
+
controller.instance_variable_set(:@__ruact_including_live, true)
|
|
177
|
+
controller.include(ActionController::Live)
|
|
178
|
+
ensure
|
|
179
|
+
controller.remove_instance_variable(:@__ruact_including_live) if
|
|
180
|
+
controller.instance_variable_defined?(:@__ruact_including_live)
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
# Whether the app included `ActionController::Live` in +controller+ or a
|
|
184
|
+
# parent, itself and not through `ruact_streaming` (see {LiveInclusion}).
|
|
185
|
+
#
|
|
186
|
+
# @api private
|
|
187
|
+
def self.live_by_app?(controller)
|
|
188
|
+
controller.ancestors.any? { |ancestor| ancestor.instance_variable_get(:@__ruact_live_by_app) }
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
# Notes, on a class, that the app included `ActionController::Live` in it
|
|
192
|
+
# itself. Ruby calls `append_features` on every `include`, even of a
|
|
193
|
+
# module the class already has — the case of a controller that includes
|
|
194
|
+
# Live under a parent that declared `ruact_streaming`, where the include
|
|
195
|
+
# changes nothing and has to be seen anyway.
|
|
196
|
+
module LiveInclusion
|
|
197
|
+
def append_features(base)
|
|
198
|
+
unless base.instance_variable_defined?(:@__ruact_including_live)
|
|
199
|
+
base.instance_variable_set(:@__ruact_live_by_app, true)
|
|
200
|
+
end
|
|
201
|
+
super
|
|
202
|
+
end
|
|
203
|
+
end
|
|
204
|
+
ActionController::Live.singleton_class.prepend(LiveInclusion)
|
|
205
|
+
|
|
206
|
+
# The typo check for `ruact_streaming only:`, as `ruact_pages` has it
|
|
207
|
+
# (see {Pages.check_declared!}).
|
|
208
|
+
#
|
|
209
|
+
# @api private
|
|
210
|
+
# @raise [Ruact::ConfigurationError]
|
|
211
|
+
def self.check_declared!(owner, declared)
|
|
212
|
+
unknown = declared[:only] - owner.action_methods.to_a
|
|
213
|
+
unknown = unknown.reject { |action| File.exist?(owner.ruact_template_path(action)) }
|
|
214
|
+
return if unknown.empty?
|
|
215
|
+
|
|
216
|
+
what = unknown.one? ? "is not an action" : "are not actions"
|
|
217
|
+
raise Ruact::ConfigurationError,
|
|
218
|
+
"#{owner.name} declares ruact_streaming for #{unknown.join(', ')}, which #{what} of it " \
|
|
219
|
+
"(no method and no template). Check the spelling in `ruact_streaming`."
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
# `process` for an action that does not stream, in a controller that
|
|
223
|
+
# includes Live: everything but Live's own `process`, which would run the
|
|
224
|
+
# action in another thread.
|
|
225
|
+
#
|
|
226
|
+
# @api private
|
|
227
|
+
def self.process_without_live(controller, name)
|
|
228
|
+
method = controller.method(:process)
|
|
229
|
+
method = method.super_method until method.owner == ActionController::Live
|
|
230
|
+
method.super_method.call(name)
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
# The response, chosen per action: Live's for an action that streams, the
|
|
234
|
+
# ordinary one for the rest. Prepended to every ruact controller's
|
|
235
|
+
# singleton class.
|
|
236
|
+
module ResponseChoice
|
|
237
|
+
def make_response!(request)
|
|
238
|
+
request.set_header(REQUEST_THREAD_ENV, Thread.current)
|
|
239
|
+
return super unless __ruact_streaming
|
|
240
|
+
|
|
241
|
+
name = request.get_header(ACTION_ENV) || request.path_parameters[:action]
|
|
242
|
+
return super if name.nil? || __ruact_live?(name)
|
|
243
|
+
|
|
244
|
+
ActionController::Metal.singleton_class.instance_method(:make_response!).bind_call(self, request)
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
# The app `Controller.action(:name)` returns, which tells
|
|
248
|
+
# `make_response!` the action's name on the way in.
|
|
249
|
+
def action(name)
|
|
250
|
+
app = super
|
|
251
|
+
action_name = name.to_s.freeze
|
|
252
|
+
lambda do |env|
|
|
253
|
+
env[ACTION_ENV] = action_name
|
|
254
|
+
app.call(env)
|
|
255
|
+
end
|
|
256
|
+
end
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
# Included right after Live by `ruact_streaming`: an action that does not
|
|
260
|
+
# stream runs on the request's thread, as without Live — unless the app
|
|
261
|
+
# included Live itself, for actions of its own.
|
|
262
|
+
module LiveChoice
|
|
263
|
+
def process(name)
|
|
264
|
+
return super if self.class.__ruact_live?(name)
|
|
265
|
+
|
|
266
|
+
Streaming.process_without_live(self, name)
|
|
267
|
+
end
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
private
|
|
271
|
+
|
|
272
|
+
# Story 19-1 (D4) — under Live the action ran in a thread of the pool, and
|
|
273
|
+
# Rails gives back what the request's thread leased, not what this one
|
|
274
|
+
# did. Without this a `Model.connection` in a streamed action keeps its
|
|
275
|
+
# connection until the thread is reused (measured in 19-0, in 0.0.19
|
|
276
|
+
# too). Not when the action ran on the request's thread (Rails' test
|
|
277
|
+
# support runs Live inline).
|
|
278
|
+
def process_action(*)
|
|
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
|
|
288
|
+
ensure
|
|
289
|
+
__ruact_check_writes_after_stream
|
|
290
|
+
__ruact_release_connections if __ruact_on_live_thread?
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
def __ruact_on_live_thread?
|
|
294
|
+
request_thread = request&.get_header(REQUEST_THREAD_ENV)
|
|
295
|
+
!request_thread.nil? && !request_thread.equal?(Thread.current)
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
def __ruact_release_connections
|
|
299
|
+
return unless defined?(::ActiveRecord::Base)
|
|
300
|
+
|
|
301
|
+
::ActiveRecord::Base.connection_handler.clear_active_connections!(:all)
|
|
302
|
+
end
|
|
303
|
+
end
|
|
304
|
+
end
|
|
305
|
+
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__, :__ruact_nonce_attr
|
|
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
|
|
@@ -168,10 +177,18 @@ module Ruact
|
|
|
168
177
|
# its headers on the first row.
|
|
169
178
|
self.status = status if status
|
|
170
179
|
|
|
171
|
-
pipeline
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
180
|
+
pipeline = RenderPipeline.new(ruact_manifest,
|
|
181
|
+
controller_path: controller_path, logger: logger,
|
|
182
|
+
development: Flight::Renderer.development_default)
|
|
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
|
+
declared = self.class.ruact_streaming?(action_name)
|
|
190
|
+
streaming = declared && response.is_a?(ActionController::Live::Response)
|
|
191
|
+
Streaming.note_unstreamed(self) if declared && !streaming
|
|
175
192
|
|
|
176
193
|
# Allocate a per-render context and expose it to the view via a normal
|
|
177
194
|
# (non-`@_`-prefixed) instance variable on the controller. Rails 8's
|
|
@@ -186,7 +203,7 @@ module Ruact
|
|
|
186
203
|
# controller instance is per-request (Rails allocates a new one per
|
|
187
204
|
# action), so this is per-request safe under multi-threaded servers
|
|
188
205
|
# (NFR8). See Story 7.9 / Bug 7.8-B.
|
|
189
|
-
with_render_context do |render_context|
|
|
206
|
+
with_render_context(deferring: streaming) do |render_context|
|
|
190
207
|
opts = template ? { template: template } : { action: action_name }
|
|
191
208
|
html = render_to_string(opts.merge(details).merge(layout: false, locals: locals))
|
|
192
209
|
emit_ruact_response(pipeline, html, render_context, streaming: streaming)
|
|
@@ -201,10 +218,10 @@ module Ruact
|
|
|
201
218
|
# `nil` would leak a phantom assignment into `view_assigns`
|
|
202
219
|
# (`{"ruact_render_context" => nil}`) on any subsequent error/rescue
|
|
203
220
|
# render in the same request.
|
|
204
|
-
def with_render_context
|
|
221
|
+
def with_render_context(deferring: false)
|
|
205
222
|
had_previous = instance_variable_defined?(:@ruact_render_context)
|
|
206
223
|
previous = @ruact_render_context if had_previous
|
|
207
|
-
render_context = RenderContext.new
|
|
224
|
+
render_context = RenderContext.new(deferring: deferring)
|
|
208
225
|
@ruact_render_context = render_context
|
|
209
226
|
begin
|
|
210
227
|
yield render_context
|
|
@@ -217,38 +234,6 @@ module Ruact
|
|
|
217
234
|
end
|
|
218
235
|
end
|
|
219
236
|
|
|
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
237
|
# Overrides Rails redirect_to for RSC requests: emits a Flight redirect row
|
|
253
238
|
# (`0:` followed by a JSON object with `redirectUrl` and `redirectType: "push"`)
|
|
254
239
|
# instead of a 302 response.
|
|
@@ -291,13 +276,11 @@ module Ruact
|
|
|
291
276
|
# Story 15.5 (FR109) — mark the Flight-redirect sub-shape for the dev log.
|
|
292
277
|
@__ruact_negotiated_page = :flight_redirect
|
|
293
278
|
|
|
294
|
-
|
|
295
|
-
content_type: "text/x-component"
|
|
279
|
+
__ruact_render_flight_redirect(redirect_url)
|
|
296
280
|
end
|
|
297
281
|
|
|
298
282
|
def ruact_request?
|
|
299
|
-
|
|
300
|
-
request.headers["Ruact-Request"] == "1"
|
|
283
|
+
RequestKind.flight?(request)
|
|
301
284
|
end
|
|
302
285
|
|
|
303
286
|
# Rails keeps `redirect_to` PUBLIC, and this module's body is private from
|
|
@@ -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
|