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
@@ -52,7 +52,9 @@ module Ruact
52
52
  end
53
53
 
54
54
  def initialize(component_registry)
55
- @registry = component_registry
55
+ # Indexed once by token: each marker in the HTML is one lookup. Built
56
+ # without a pair array per entry (the render allocation guard counts them).
57
+ @registry = component_registry.each_with_object({}) { |entry, by_token| by_token[entry[:token]] = entry } # rubocop:disable Style/ReduceToHash
56
58
  end
57
59
 
58
60
  # Convert an HTML string into a ReactElement tree (instance form).
@@ -163,8 +165,8 @@ module Ruact
163
165
 
164
166
  when Nokogiri::XML::Node::COMMENT_NODE
165
167
  # Check if this is a Ruact component placeholder
166
- token = node.content.strip
167
- entry = @registry.find { |c| c[:token] == token }
168
+ token = Markers.comment_token(node)
169
+ entry = @registry[token]
168
170
  return nil unless entry
169
171
 
170
172
  Flight::ReactElement.new(
@@ -181,8 +183,8 @@ module Ruact
181
183
 
182
184
  def convert_element(node)
183
185
  tag = node.name.downcase
184
- return convert_suspense_element(node) if tag == "ruact-suspense"
185
- return convert_component_with_children(node) if tag == ErbPreprocessor::WRAPPER_TAG
186
+ return convert_suspense_element(node) if tag == Markers::SUSPENSE_TAG
187
+ return convert_component_with_children(node) if tag == Markers::WRAPPER_TAG
186
188
 
187
189
  props = build_props(node, tag)
188
190
  children = convert_children(node)
@@ -196,8 +198,8 @@ module Ruact
196
198
  # component's `children` prop, converted like any element's; an empty pair
197
199
  # passes none, like `<Card />`.
198
200
  def convert_component_with_children(node)
199
- token = node["data-ruact-token"]
200
- entry = @registry.find { |c| c[:token] == token }
201
+ token = Markers.wrapper_token(node)
202
+ entry = @registry[token]
201
203
  raise Ruact::HtmlConverterError, "ruact: no component registered for #{token.inspect}" unless entry
202
204
 
203
205
  props = entry[:props]
@@ -209,38 +211,28 @@ module Ruact
209
211
  end
210
212
 
211
213
  def convert_suspense_element(node)
212
- fallback_text = node["data-ruact-fallback"] || ""
214
+ fallback_text = Markers.suspense_fallback(node)
213
215
  fallback = if fallback_text.empty?
214
216
  nil
215
217
  else
216
218
  Flight::ReactElement.new(type: "p", key: nil, props: { "children" => fallback_text })
217
219
  end
218
220
 
219
- child_nodes = convert_children(node)
220
- children = child_nodes.length == 1 ? child_nodes.first : child_nodes
221
-
222
221
  # Optional `delay="2.5"` from the ERB → SuspenseElement#delay (the
223
- # server-side wait, in seconds, before the deferred chunk streams). When
224
- # absent, blank, unparseable, or non-finite, SuspenseElement falls back to
225
- # its default.
226
- delay_attr = node["data-ruact-delay"]
227
- delay = begin
228
- if delay_attr && !delay_attr.to_s.strip.empty?
229
- # A string overflow like "1e309" parses to Infinity (Float() does not
230
- # raise) — guard finiteness so a non-finite delay can't reach the
231
- # renderer's sleep(delay) and raise RangeError.
232
- parsed = Float(delay_attr)
233
- parsed.finite? ? parsed : nil
234
- end
235
- rescue ArgumentError, TypeError
236
- nil
237
- end
222
+ # server-side wait, in seconds, before the deferred row streams); nil
223
+ # when absent or unusable.
224
+ delay = Markers.suspense_delay(node)
225
+ Flight::SuspenseElement.new(fallback: fallback, children: suspense_children(node), delay: delay)
226
+ end
238
227
 
239
- if delay
240
- Flight::SuspenseElement.new(fallback: fallback, children: children, delay: delay)
241
- else
242
- Flight::SuspenseElement.new(fallback: fallback, children: children)
243
- end
228
+ # The content of a Suspense wrapper: its converted children, or — when its
229
+ # block is rendered later — a reference to that block.
230
+ def suspense_children(node)
231
+ block = Markers.suspense_block(node)
232
+ return Flight::DeferredContent.new(block) if block
233
+
234
+ child_nodes = convert_children(node)
235
+ child_nodes.length == 1 ? child_nodes.first : child_nodes
244
236
  end
245
237
 
246
238
  def convert_children(node)
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Ruact
6
+ # Internal — data written inside an inline `<script>` element.
7
+ #
8
+ # The Flight payload carries application data (props, record content), so it
9
+ # is embedded as a JSON string literal with `<`, `>`, `&`, U+2028 and U+2029
10
+ # escaped as `\uXXXX`. No byte of the payload can then close the `<script>` —
11
+ # `</SCRIPT>`, `</script >` and `<!--` included — and every character reaches
12
+ # JavaScript intact.
13
+ module InlineScript
14
+ UNSAFE = /[<>&\u2028\u2029]/
15
+
16
+ # A JSON string literal for +string+ that is safe inside a `<script>`.
17
+ # The payload is UTF-8 by construction (the serializer normalizes every
18
+ # string); a binary-tagged buffer is retagged, not transcoded.
19
+ def self.string_literal(string)
20
+ utf8 = string.encoding == Encoding::UTF_8 ? string : string.dup.force_encoding(Encoding::UTF_8)
21
+ JSON.generate(utf8).gsub(UNSAFE) { |char| format("\\u%04x", char.ord) }
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ruact
4
+ # Story 19-1 (ADR D1) — in development, a `load_async` that cannot run in
5
+ # the background says so, once per process.
6
+ #
7
+ # `load_async` is how a page starts its queries early, so they finish while
8
+ # the shell is sent. Without `config.active_record.async_query_executor` —
9
+ # the default of a new Rails app — Active Record has no thread to run the
10
+ # query in and loads it where `load_async` is called, waiting for it, with no
11
+ # sign that it did. The note sits in `load_async` itself, not in every
12
+ # `<Suspense>`: an app that never calls it has nothing to fix.
13
+ #
14
+ # The other cases where Rails loads synchronously with the executor set
15
+ # (inside a transaction, a full queue) are in the guide, not here.
16
+ module LoadAsyncNote
17
+ MESSAGE = "[ruact] load_async ran its query where it was called and waited for it: " \
18
+ "config.active_record.async_query_executor is not set, so Active Record has no thread to run " \
19
+ "it in. Set `config.active_record.async_query_executor = :global_thread_pool` " \
20
+ "(config/application.rb) so a page's queries start early and finish while its shell is sent. " \
21
+ "(Said once.)"
22
+
23
+ # Prepended to ActiveRecord::Relation in development by the Railtie.
24
+ module Relation
25
+ def load_async
26
+ LoadAsyncNote.note(::ActiveRecord.async_query_executor)
27
+ super
28
+ end
29
+ end
30
+
31
+ # Logs {MESSAGE} the first time it is asked with no executor.
32
+ #
33
+ # @param executor [Symbol, nil] `ActiveRecord.async_query_executor`
34
+ # @return [void]
35
+ def self.note(executor)
36
+ return unless executor.nil?
37
+ return if @noted
38
+
39
+ @noted = true
40
+ Rails.logger&.warn(MESSAGE)
41
+ nil
42
+ end
43
+
44
+ # Forgets that it logged (specs).
45
+ #
46
+ # @api private
47
+ def self.reset!
48
+ @noted = false
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,95 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ruact
4
+ # Internal — what an evaluated template leaves in its HTML for
5
+ # {HtmlConverter} to turn into React elements: the protocol between
6
+ # evaluating a template and converting it.
7
+ #
8
+ # Three markers:
9
+ #
10
+ # - a client component: an HTML comment holding the token its
11
+ # {RenderContext} entry was registered under — `<!-- __RUACT_0__ -->`;
12
+ # - a client component with children: an element wrapped around the
13
+ # children's HTML — `<ruact-component data-ruact-token="__RUACT_0__">…`;
14
+ # - a Suspense boundary: an element wrapped around its content, carrying the
15
+ # fallback text and the optional delay —
16
+ # `<ruact-suspense data-ruact-fallback="Loading…">…</ruact-suspense>`. A
17
+ # boundary whose content is rendered later is empty and carries the index
18
+ # of its block in the {RenderContext} instead (`data-ruact-block="0"`).
19
+ #
20
+ # {ErbPreprocessor} writes the ERB that produces them, the view helpers
21
+ # ({ViewHelper}, and {RenderPipeline} for a plain-ERB render) write the
22
+ # component markers, {HtmlConverter} reads all three. None of them spells
23
+ # these strings itself.
24
+ module Markers
25
+ # The element a paired component's content is wrapped in.
26
+ WRAPPER_TAG = "ruact-component"
27
+ WRAPPER_CLOSE = "</#{WRAPPER_TAG}>".freeze
28
+
29
+ # The element a Suspense boundary's content is wrapped in.
30
+ SUSPENSE_TAG = "ruact-suspense"
31
+ SUSPENSE_CLOSE = "</#{SUSPENSE_TAG}>".freeze
32
+
33
+ TOKEN_ATTR = "data-ruact-token"
34
+ FALLBACK_ATTR = "data-ruact-fallback"
35
+ DELAY_ATTR = "data-ruact-delay"
36
+ BLOCK_ATTR = "data-ruact-block"
37
+
38
+ # The marker for a client component, by its render-context token.
39
+ def self.component(token)
40
+ "<!-- #{token} -->"
41
+ end
42
+
43
+ # The opening of a client component with children; {WRAPPER_CLOSE} ends it.
44
+ def self.component_open(token)
45
+ %(<#{WRAPPER_TAG} #{TOKEN_ATTR}="#{token}">)
46
+ end
47
+
48
+ # The opening of a Suspense boundary, from the `fallback` and `delay`
49
+ # attribute values as written in the template. Only `"` is escaped: the
50
+ # rest of the text stays as the template wrote it (entities included, which
51
+ # the HTML parser then decodes). The preprocessor refuses ERB in either.
52
+ # +block+ is the index of the boundary's content in the render context,
53
+ # when that content is rendered later.
54
+ def self.suspense_open(fallback, delay, block: nil)
55
+ delay_attr = delay ? %( #{DELAY_ATTR}="#{delay.gsub('"', '&quot;')}") : ""
56
+ block_attr = block ? %( #{BLOCK_ATTR}="#{block}") : ""
57
+ %(<#{SUSPENSE_TAG} #{FALLBACK_ATTR}="#{fallback.gsub('"', '&quot;')}"#{delay_attr}#{block_attr}>)
58
+ end
59
+
60
+ # The token a component marker (an HTML comment node) carries.
61
+ def self.comment_token(node)
62
+ node.content.strip
63
+ end
64
+
65
+ # The token a component-with-children wrapper carries.
66
+ def self.wrapper_token(node)
67
+ node[TOKEN_ATTR]
68
+ end
69
+
70
+ # The fallback text of a Suspense wrapper ("" when there is none).
71
+ def self.suspense_fallback(node)
72
+ node[FALLBACK_ATTR] || ""
73
+ end
74
+
75
+ # The index of the block a Suspense wrapper's content is rendered from
76
+ # later, or nil when the content is in the wrapper.
77
+ def self.suspense_block(node)
78
+ value = node[BLOCK_ATTR]
79
+ value && Integer(value, 10)
80
+ end
81
+
82
+ # The delay of a Suspense wrapper, in seconds, or nil when it is absent,
83
+ # blank, unparseable or not finite (`"1e309"` parses to Infinity without
84
+ # raising, and must not reach a `sleep`).
85
+ def self.suspense_delay(node)
86
+ value = node[DELAY_ATTR]
87
+ return nil if value.nil? || value.to_s.strip.empty?
88
+
89
+ parsed = Float(value)
90
+ parsed.finite? ? parsed : nil
91
+ rescue ArgumentError, TypeError
92
+ nil
93
+ end
94
+ end
95
+ end
@@ -37,7 +37,7 @@ module Ruact
37
37
  end
38
38
 
39
39
  def call(env)
40
- return @app.call(env) unless env["HTTP_RUACT_REQUEST"] == "1"
40
+ return @app.call(env) unless RequestKind.from_router?(env)
41
41
  return native_response if @classifier.classify(env) == :native
42
42
 
43
43
  @app.call(env)
@@ -60,7 +60,7 @@ module Ruact
60
60
  headers[HEADER] = NATIVE
61
61
  headers["content-type"] = "text/plain; charset=utf-8"
62
62
  headers["cache-control"] = "no-store"
63
- headers["vary"] = "Ruact-Request"
63
+ headers["vary"] = RequestKind::ROUTER_HEADER
64
64
  headers
65
65
  end
66
66
  end
data/lib/ruact/railtie.rb CHANGED
@@ -46,6 +46,18 @@ module Ruact
46
46
  app.config.middleware.use Ruact::NavigationBoundary::Middleware
47
47
  end
48
48
 
49
+ # Story 19-1 (ADR D1) — in development, a `load_async` that cannot run in
50
+ # the background says so (Ruact::LoadAsyncNote). Nothing in production, and
51
+ # nothing at all without Active Record.
52
+ initializer "ruact.load_async_note" do
53
+ next unless Rails.env.development?
54
+
55
+ ActiveSupport.on_load(:active_record) do
56
+ require_relative "load_async_note"
57
+ ActiveRecord::Relation.prepend(Ruact::LoadAsyncNote::Relation)
58
+ end
59
+ end
60
+
49
61
  rake_tasks { load File.expand_path("../tasks/ruact.rake", __dir__) }
50
62
 
51
63
  # Load the client manifest at boot (and on each code reload in development).
@@ -2,8 +2,10 @@
2
2
 
3
3
  module Ruact
4
4
  # Per-render mutable state holding the components encountered during ERB
5
- # evaluation. One instance is allocated by the controller per render, passed
6
- # explicitly through the pipeline, and discarded when the response is sent.
5
+ # evaluation, and — in an action that streams — the `<Suspense>` blocks whose
6
+ # content is rendered after the shell is sent. One instance is allocated by
7
+ # the controller per render, passed explicitly through the pipeline, and
8
+ # discarded when the response is sent.
7
9
  #
8
10
  # No shared state, no thread-local lookup — the instance lives only as long
9
11
  # as the render call. Satisfies NFR8 and the `Ruact/NoSharedState` cop with
@@ -11,20 +13,73 @@ module Ruact
11
13
  #
12
14
  # Internal API: not part of the public compatibility contract.
13
15
  class RenderContext
14
- def initialize
16
+ # A `<Suspense>` block waiting to be rendered: the view it was written in,
17
+ # the block, and the template Rails was rendering when it met the tag
18
+ # (`t(".key")` and error messages read it).
19
+ Block = Struct.new(:view, :block, :virtual_path, :template, :location)
20
+
21
+ # @param deferring [Boolean] whether a `<Suspense>`'s content is rendered
22
+ # after the shell (an action that streams) instead of in place
23
+ def initialize(deferring: false)
15
24
  @components = []
25
+ @deferring = deferring
26
+ @blocks = []
16
27
  end
17
28
 
18
29
  attr_reader :components
19
30
 
31
+ # Whether a `<Suspense>`'s content is rendered after the shell.
32
+ def deferring?
33
+ @deferring
34
+ end
35
+
20
36
  def register(name, props)
21
- token = "__RUACT_#{@components.length}__"
37
+ # Frozen: the converter keys a Hash by it, which would copy an unfrozen one.
38
+ token = "__RUACT_#{@components.length}__".freeze
22
39
  @components << { token: token, name: name, props: props }
23
40
  token
24
41
  end
25
42
 
26
- def by_token(token)
27
- @components.find { |c| c[:token] == token }
43
+ # Keeps a `<Suspense>` block for later and returns its index.
44
+ #
45
+ # @param block [Block]
46
+ # @return [Integer]
47
+ def defer_block(block)
48
+ @blocks << block
49
+ @blocks.length - 1
50
+ end
51
+
52
+ # The block registered under +index+.
53
+ #
54
+ # @return [Block]
55
+ def block(index)
56
+ @blocks.fetch(index)
57
+ end
58
+
59
+ # Whether the development message for a `<Suspense>` in an action that
60
+ # does not stream was given in this render: once per response.
61
+ def unstreamed_suspense_noted?
62
+ @unstreamed_suspense_noted == true
63
+ end
64
+
65
+ def note_unstreamed_suspense!
66
+ @unstreamed_suspense_noted = true
67
+ end
68
+
69
+ # The registered components with the client reference +manifest+ gives
70
+ # each, in the shape {HtmlConverter} reads:
71
+ # `[{ token:, name:, ref:, props: }, ...]`. Raises what the manifest raises
72
+ # for a component it does not have. +from+ skips the first components (the
73
+ # ones a previous call resolved).
74
+ def resolve(manifest, controller_path: nil, from: 0)
75
+ entries = from.zero? ? @components : @components.drop(from)
76
+ entries.map { |entry| resolve_entry(entry, manifest, controller_path: controller_path) }
77
+ end
78
+
79
+ # One registered component, resolved as {#resolve} does.
80
+ def resolve_entry(entry, manifest, controller_path: nil)
81
+ ref = manifest.reference_for(entry[:name], controller_path: controller_path)
82
+ { token: entry[:token], name: entry[:name], ref: ref, props: entry[:props] }
28
83
  end
29
84
  end
30
85
  end
@@ -55,6 +55,8 @@ module Ruact
55
55
  # - `:erb` or `:html` is not a `String`,
56
56
  # - `input` contains extra keys beyond the documented shapes,
57
57
  # - `mode` is not in {VALID_MODES}.
58
+ # - (`:erb` input) the template holds a `<Suspense>`: its content is a block only ActionView
59
+ # renders. Raised when the Enumerator is consumed.
58
60
  # All `ArgumentError` messages name the offending input and reference
59
61
  # `RenderPipeline#render` for the canonical contract.
60
62
  #
@@ -72,6 +74,20 @@ module Ruact
72
74
  mode == :string ? enum.to_a.join : enum
73
75
  end
74
76
 
77
+ # What the render reads — for a {DeferredRender} built on it.
78
+ attr_reader :manifest, :controller_path, :logger, :development
79
+
80
+ # The warning for a value serialized through `as_json`, logged; nil without
81
+ # a logger.
82
+ def as_json_warning_callback
83
+ return nil if @logger.nil?
84
+
85
+ lambda do |class_name, attrs|
86
+ @logger.warn("[ruact] WARNING: #{class_name} serialized via as_json — ALL attributes exposed to client: " \
87
+ "#{attrs}. Use `include Ruact::Serializable` with `ruact_props` for explicit control")
88
+ end
89
+ end
90
+
75
91
  private
76
92
 
77
93
  ALLOWED_INPUT_KEYS = %i[erb binding html render_context].freeze
@@ -142,6 +158,16 @@ module Ruact
142
158
  "ruact: render input :binding must be a Binding; got #{bnd.class} (#{DOCSTRING_REF})"
143
159
  end
144
160
 
161
+ # A `<Suspense>`'s content is the body of a helper block, and only
162
+ # ActionView's `capture` renders one.
163
+ def refuse_suspense!(transformed)
164
+ return unless transformed.include?("__ruact_suspense__(")
165
+
166
+ raise ArgumentError,
167
+ "ruact: render input :erb cannot hold a <Suspense>: its content is a block, which only " \
168
+ "ActionView's `capture` can render. Render the template through ActionView (#{DOCSTRING_REF})"
169
+ end
170
+
145
171
  def validate_html_shape!(input)
146
172
  html = input[:html]
147
173
  unless html.is_a?(String)
@@ -179,6 +205,7 @@ module Ruact
179
205
  # so passing nil is byte-for-byte equivalent while avoiding any dev HTTP
180
206
  # fetch / test-double dispatch inside callers that measure this path.
181
207
  transformed = ErbPreprocessor.transform(erb_source, registry: nil)
208
+ refuse_suspense!(transformed)
182
209
  receiver = binding_context.eval("self")
183
210
  prev_ctx = receiver.instance_variable_get(:@__ruact_render_context__)
184
211
  inject_helper!(binding_context, render_context)
@@ -189,11 +216,7 @@ module Ruact
189
216
  receiver.instance_variable_set(:@__ruact_render_context__, prev_ctx)
190
217
  end
191
218
 
192
- registry = render_context.components.map do |entry|
193
- ref = @manifest.reference_for(entry[:name], controller_path: @controller_path)
194
- { token: entry[:token], name: entry[:name], ref: ref, props: entry[:props] }
195
- end
196
-
219
+ registry = render_context.resolve(@manifest, controller_path: @controller_path)
197
220
  root_element = HtmlConverter.convert(html, registry)
198
221
 
199
222
  Flight::Renderer.each(root_element, @manifest,
@@ -209,10 +232,7 @@ module Ruact
209
232
  # the caller may discard the RenderContext immediately after `render` returns — the
210
233
  # returned Enumerator does not reference the RenderContext.
211
234
  def render_html_enum(html, render_context, streaming:)
212
- registry = render_context.components.map do |entry|
213
- ref = @manifest.reference_for(entry[:name], controller_path: @controller_path)
214
- { token: entry[:token], name: entry[:name], ref: ref, props: entry[:props] }
215
- end
235
+ registry = render_context.resolve(@manifest, controller_path: @controller_path)
216
236
  strict = Ruact.config.strict_serialization
217
237
  warning_cb = as_json_warning_callback
218
238
 
@@ -226,18 +246,6 @@ module Ruact
226
246
  end
227
247
  end
228
248
 
229
- def as_json_warning_callback
230
- return nil if @logger.nil?
231
-
232
- lambda do |class_name, attrs|
233
- @logger.warn(
234
- "[ruact] WARNING: #{class_name} serialized via as_json — " \
235
- "ALL attributes exposed to client: #{attrs}. " \
236
- "Use `include Ruact::Serializable` with `ruact_props` for explicit control"
237
- )
238
- end
239
- end
240
-
241
249
  # Install __ruact_component__ on the binding's receiver and stash the active
242
250
  # render context on the receiver as @__ruact_render_context__. The singleton
243
251
  # method reads the ivar dynamically, so nested renders sharing the same
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ruact
4
+ # Internal — how a request says what it wants from ruact. Two questions, with
5
+ # different answers on purpose:
6
+ #
7
+ # - {.flight?} — does the client want a Flight body back rather than an HTML
8
+ # document? Either header says so: `Accept: text/x-component` (any Flight
9
+ # client) or `Ruact-Request: 1` (ruact's router, which sends both).
10
+ # - {.from_router?} — did ruact's own router send it? Only `Ruact-Request: 1`
11
+ # says that; {NavigationBoundary} answers router navigations to pages that
12
+ # are not ruact's, and must not answer anyone else.
13
+ module RequestKind
14
+ # The Flight media type, as requested and as answered.
15
+ FLIGHT_MIME = "text/x-component"
16
+ # The header ruact's router adds to every request it makes.
17
+ ROUTER_HEADER = "Ruact-Request"
18
+ ROUTER_ENV = "HTTP_RUACT_REQUEST"
19
+
20
+ # @param request [ActionDispatch::Request]
21
+ def self.flight?(request)
22
+ request.headers["Accept"]&.include?(FLIGHT_MIME) || request.headers[ROUTER_HEADER] == "1"
23
+ end
24
+
25
+ # @param env [Hash] the Rack env (it runs before Rails builds a request)
26
+ def self.from_router?(env)
27
+ env[ROUTER_ENV] == "1"
28
+ end
29
+
30
+ # Which header made {.flight?} true, as the request sent it — for a log
31
+ # line that names it.
32
+ def self.flight_signal(request)
33
+ request.headers["Accept"].to_s.include?(FLIGHT_MIME) ? "Accept: #{FLIGHT_MIME}" : "#{ROUTER_HEADER}: 1"
34
+ end
35
+ end
36
+ end
data/lib/ruact/server.rb CHANGED
@@ -407,7 +407,7 @@ module Ruact
407
407
  # Classify the negotiated response shape from the request signals
408
408
  # (`__ruact_function_call?`, Accept, verb) and the ruact-owned outcome flags
409
409
  # (`@__ruact_function_redirect` set by the Bucket-2 {#redirect_to};
410
- # `@__ruact_negotiated_page` set by {Ruact::Controller#emit_ruact_response} /
410
+ # `@__ruact_negotiated_page` set by {Ruact::Controller::Delivery#emit_ruact_response} /
411
411
  # its Flight `redirect_to`). Returns nil when ruact did not negotiate the
412
412
  # response, so the log stays silent on plain Rails actions.
413
413
  #
@@ -441,7 +441,7 @@ module Ruact
441
441
  end
442
442
 
443
443
  # The page-render (Bucket-1 / HTML-shell) shape, keyed on the flag
444
- # {Ruact::Controller#emit_ruact_response} set when ruact rendered the page.
444
+ # {Ruact::Controller::Delivery#emit_ruact_response} set when ruact rendered the page.
445
445
  # nil for a plain Rails render (the flag was never set) → the log stays
446
446
  # silent on a non-negotiated response.
447
447
  def __ruact_page_shape_and_signal
@@ -458,11 +458,7 @@ module Ruact
458
458
  # The Flight deciding signal, faithful to how the request announced itself:
459
459
  # the `Accept: text/x-component` header, or the `Ruact-Request: 1` header.
460
460
  def __ruact_flight_signal
461
- if request.headers["Accept"].to_s.include?("text/x-component")
462
- "Accept: text/x-component"
463
- else
464
- "Ruact-Request: 1"
465
- end
461
+ RequestKind.flight_signal(request)
466
462
  end
467
463
 
468
464
  # @return [Boolean] true only in a real Rails development environment
@@ -15,7 +15,8 @@ module Ruact
15
15
  #
16
16
  # 1. a raw `text/x-component` body (RSC/navigation request), OR
17
17
  # 2. a full HTML document embedding the payload in the `__FLIGHT_DATA`
18
- # bootstrap `<script>` (a plain browser GET).
18
+ # bootstrap `<script>` (a plain browser GET) — and, in a page that
19
+ # streams, in the script each `<Suspense>`'s rows are pushed in.
19
20
  #
20
21
  # A `Ruact::Server` function-call/query answer is plain JSON, not Flight —
21
22
  # feeding it here raises {NotAFlightResponseError} pointing at `JSON.parse`,
@@ -36,6 +37,16 @@ module Ruact
36
37
  \)\s*;
37
38
  /mx
38
39
 
40
+ # Story 19-1 — in a page that streams, each `<Suspense>`'s rows arrive
41
+ # later, in a script of their own pushed onto the same queue
42
+ # (`Ruact::Controller::Delivery#__ruact_flight_push`). Read after the
43
+ # bootstrap script, in document order.
44
+ STREAMED_PUSH = /
45
+ \(self\.__FLIGHT_DATA=self\.__FLIGHT_DATA\|\|\[\]\)\.push\(
46
+ (?<literal>"(?:\\.|[^"\\])*")
47
+ \)
48
+ /x
49
+
39
50
  # A Flight wire body always starts with a row header: `<hex>:` (id + colon)
40
51
  # or a hint row `:H`. JSON bodies start with `{`/`[`/`"`; HTML with `<`.
41
52
  FLIGHT_ROW_START = /\A\s*(?:\h+:|:H)/
@@ -133,9 +144,15 @@ module Ruact
133
144
  "This response did not render a ruact component (no Flight data was inlined)."
134
145
  end
135
146
 
136
- literal = match[:literal]
137
- # The literal is JSON (ruact >= 0.0.15). The Ruby-literal decoder stays
138
- # for documents captured from earlier versions.
147
+ # The streamed pushes are JSON literals ({InlineScript}), written
148
+ # after the bootstrap script.
149
+ later = html[match.end(0)..].scan(STREAMED_PUSH).map { |(streamed)| JSON.parse(streamed) }
150
+ decode_literal(match[:literal]) + later.join
151
+ end
152
+
153
+ # The literal is JSON (ruact >= 0.0.15). The Ruby-literal decoder stays
154
+ # for documents captured from earlier versions.
155
+ def decode_literal(literal)
139
156
  JSON.parse(literal)
140
157
  rescue JSON::ParserError
141
158
  decode_ruby_string_literal(literal)
data/lib/ruact/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Ruact
4
- VERSION = "0.0.19"
4
+ VERSION = "0.0.20"
5
5
  end