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.
Files changed (46) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +57 -1
  3. data/CONTRIBUTING.md +10 -3
  4. data/README.md +1 -1
  5. data/lib/generators/ruact/install/templates/AGENTS.md.tt +13 -13
  6. data/lib/generators/ruact/install/templates/initializer.rb.tt +4 -1
  7. data/lib/ruact/cached_blocks.rb +30 -0
  8. data/lib/ruact/configuration.rb +6 -1
  9. data/lib/ruact/controller/delivery.rb +262 -0
  10. data/lib/ruact/controller/document_rendering.rb +15 -12
  11. data/lib/ruact/controller/streaming.rb +305 -0
  12. data/lib/ruact/controller.rb +34 -51
  13. data/lib/ruact/declared_content.rb +33 -0
  14. data/lib/ruact/deferred_render.rb +254 -0
  15. data/lib/ruact/doctor.rb +42 -2
  16. data/lib/ruact/erb_preprocessor.rb +211 -24
  17. data/lib/ruact/flight/react_element.rb +22 -2
  18. data/lib/ruact/flight/renderer.rb +94 -41
  19. data/lib/ruact/flight/request.rb +71 -27
  20. data/lib/ruact/flight/serializer.rb +3 -10
  21. data/lib/ruact/html_converter.rb +44 -34
  22. data/lib/ruact/inline_script.rb +24 -0
  23. data/lib/ruact/load_async_note.rb +51 -0
  24. data/lib/ruact/markers.rb +98 -0
  25. data/lib/ruact/navigation_boundary.rb +2 -2
  26. data/lib/ruact/query_watch.rb +106 -0
  27. data/lib/ruact/railtie.rb +12 -0
  28. data/lib/ruact/render_context.rb +73 -6
  29. data/lib/ruact/render_pipeline.rb +32 -22
  30. data/lib/ruact/request_kind.rb +36 -0
  31. data/lib/ruact/server.rb +3 -7
  32. data/lib/ruact/testing/flight_extractor.rb +21 -4
  33. data/lib/ruact/version.rb +1 -1
  34. data/lib/ruact/view_helper/suspense.rb +70 -0
  35. data/lib/ruact/view_helper.rb +35 -33
  36. data/lib/ruact.rb +7 -0
  37. data/vendor/javascript/vite-plugin-ruact/bootstrap.test.mjs +16 -3
  38. data/vendor/javascript/vite-plugin-ruact/page-mount.test.mjs +75 -0
  39. data/vendor/javascript/vite-plugin-ruact/page-stream.test.mjs +132 -0
  40. data/vendor/javascript/vite-plugin-ruact/ruact-router.test.mjs +185 -0
  41. data/vendor/javascript/vite-plugin-ruact/runtime/bootstrap.jsx +23 -18
  42. data/vendor/javascript/vite-plugin-ruact/runtime/dev-overlay.js +53 -0
  43. data/vendor/javascript/vite-plugin-ruact/runtime/page-stream.js +108 -0
  44. data/vendor/javascript/vite-plugin-ruact/runtime/ruact-router.js +74 -20
  45. data/vendor/javascript/vite-plugin-ruact/runtime/suspense-boundary.js +1 -0
  46. 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
@@ -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__, :__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 = RenderPipeline.new(ruact_manifest,
172
- controller_path: controller_path, logger: logger,
173
- development: Flight::Renderer.development_default)
174
- streaming = ruact_request? && self.class.ancestors.include?(ActionController::Live)
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
- render plain: "0:#{JSON.generate({ 'redirectUrl' => redirect_url, 'redirectType' => 'push' })}\n",
295
- content_type: "text/x-component"
279
+ __ruact_render_flight_redirect(redirect_url)
296
280
  end
297
281
 
298
282
  def ruact_request?
299
- request.headers["Accept"]&.include?("text/x-component") ||
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