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
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "ripper"
4
+
3
5
  module Ruact
4
6
  # Transforms ERB source before Ruby evaluation.
5
7
  #
@@ -34,6 +36,10 @@ module Ruact
34
36
  SUSPENSE_OPEN_RE = /<Suspense\b([^>]*?)>/m
35
37
  SUSPENSE_CLOSE_RE = %r{</Suspense\s*>}
36
38
 
39
+ # Any Suspense tag: capture 1 is the slash of a closing tag, capture 2 the
40
+ # attributes, capture 3 the slash of a self-closing one.
41
+ SUSPENSE_TAG_RE = %r{<(/)?Suspense\b([^>]*?)(/)?>}m
42
+
37
43
  # Matches ANY PascalCase component tag: opening (`<Card>`), self-closing
38
44
  # (`<Card />`), or closing (`</Card>`). Capture 1 is the leading slash
39
45
  # (present only on a closing tag); capture 2 is the name. The pairing scan
@@ -48,22 +54,36 @@ module Ruact
48
54
  # Newline-preserving mask for ERB islands (`<% … %>`, `<%= … %>`, `<%# … %>`).
49
55
  # The pairing scan blanks these first so a `</Card>` that lives inside
50
56
  # Ruby/ERB string or comment text is never taken for a real closing tag.
51
- ERB_ISLAND_RE = /<%.*?%>/m
57
+ # `<%%` is ERB's escape for a literal `<%`: text, not an island.
58
+ ERB_ISLAND_RE = /<%(?!%).*?%>/m
52
59
 
53
60
  # An ERB comment, which renders nothing.
54
61
  ERB_COMMENT_RE = /<%#.*?%>/m
55
62
 
56
- # The element a paired component's content is wrapped in until
57
- # {HtmlConverter} turns it into the component's `children`.
58
- WRAPPER_TAG = "ruact-component"
63
+ # What ends a `<Suspense>`: the {ViewHelper#__ruact_suspense__} block its
64
+ # content is the body of. The empty output tag after it keeps Rails' ERB
65
+ # from trimming a `</Suspense>` that sits alone on its line, which would eat
66
+ # the line break after it (text the page shows).
67
+ SUSPENSE_BLOCK_CLOSE = %(<% end %><%= "" %>)
68
+
69
+ # Ripper node types inside which `break`, `next`, `redo`, `return`, `it`
70
+ # and `_1` belong to a block of their own, not to the Suspense's block.
71
+ OWN_BLOCK_NODES = %i[brace_block do_block lambda def defs class module sclass].freeze
72
+ # Loops give `break`, `next` and `redo` a scope of their own, but not `it`
73
+ # or `_1`, which still name the enclosing block's parameter.
74
+ LOOP_NODES = %i[while until while_mod until_mod for].freeze
75
+ LOOP_WORDS = %w[break next redo].freeze
76
+
77
+ # One ERB tag's flag (`=`, `==`, `-`, or `#` for a comment) and its Ruby code.
78
+ ERB_CODE_RE = /<%(?!%)(==|=|-|#)?(.*?)-?%>/m
59
79
 
60
80
  # What each helper the transformed template calls leaves in the HTML, by
61
81
  # its render-context token: a component, or the opening of one with
62
- # children. {RenderPipeline} installs these for a plain-ERB render;
63
- # ViewHelper writes the same strings in ActionView.
82
+ # children ({Markers}). {RenderPipeline} installs these for a plain-ERB
83
+ # render; ViewHelper writes the same markers in ActionView.
64
84
  PLACEHOLDERS = {
65
- __ruact_component__: ->(token) { "<!-- #{token} -->" },
66
- __ruact_component_open__: ->(token) { %(<#{WRAPPER_TAG} data-ruact-token="#{token}">) }
85
+ __ruact_component__: ->(token) { Markers.component(token) },
86
+ __ruact_component_open__: ->(token) { Markers.component_open(token) }
67
87
  }.freeze
68
88
 
69
89
  # Transform ERB source, replacing component tags with ERB placeholders.
@@ -90,29 +110,188 @@ module Ruact
90
110
  # Step 1: component tags → ERB placeholders. Story 18-2: a tag with a
91
111
  # matching closing tag is a component WITH children; its content stays
92
112
  # ERB, evaluated in place, inside a wrapper element.
113
+ # Suspense pairs are checked on the template as written, so an error names
114
+ # the template's own line.
115
+ check_suspense!(source) if source.include?("Suspense")
116
+
93
117
  result = if source.match?(CLOSING_TAG_PROBE_RE)
94
118
  rewrite_paired(source)
95
119
  else
96
120
  rewrite_self_closing(source)
97
121
  end
98
122
 
99
- # Step 2: <Suspense> paired tags → <ruact-suspense> HTML elements.
100
- result
101
- .gsub(SUSPENSE_OPEN_RE) do
102
- attrs = ::Regexp.last_match(1)
103
- fallback = extract_string_attr(attrs, "fallback") || ""
104
- escaped = fallback.gsub('"', "&quot;")
105
- # Optional `delay="2.5"` — the server-side wait (seconds) before
106
- # the deferred chunk streams. Forwarded to SuspenseElement#delay.
107
- delay = extract_string_attr(attrs, "delay")
108
- delay_attr = delay ? %( data-ruact-delay="#{delay.gsub('"', '&quot;')}") : ""
109
- %(<ruact-suspense data-ruact-fallback="#{escaped}"#{delay_attr}>)
110
- end
111
- .gsub(SUSPENSE_CLOSE_RE, "</ruact-suspense>")
123
+ rewrite_suspense(result)
112
124
  end
113
125
 
114
126
  private
115
127
 
128
+ # Step 2: `<Suspense>` pairs. Each pair becomes a
129
+ # {ViewHelper#__ruact_suspense__} call whose block is what sits between the
130
+ # tags; the helper writes the Suspense marker around it ({Markers}), and
131
+ # decides when the block is evaluated. `delay="2.5"` is optional: the
132
+ # server-side wait (seconds) before the deferred chunk streams, forwarded to
133
+ # SuspenseElement#delay. `<Suspense ... />` is a pair with nothing in it. A
134
+ # tag inside an ERB island is Ruby text and is left alone. The generated ERB
135
+ # keeps the tag's line breaks, so later lines keep their numbers.
136
+ def rewrite_suspense(source)
137
+ return source unless source.include?("Suspense")
138
+
139
+ out = +""
140
+ cursor = 0
141
+ suspense_tags(source).each do |tag|
142
+ out << source[cursor...tag[:from]]
143
+ out << rewrite_suspense_tag(tag, source[tag[:from]...tag[:to]].count("\n"))
144
+ cursor = tag[:to]
145
+ end
146
+ out << source[cursor..]
147
+ end
148
+
149
+ # Every Suspense tag outside an ERB island, in order.
150
+ def suspense_tags(source)
151
+ tags = []
152
+ mask_erb(source).scan(SUSPENSE_TAG_RE) do
153
+ m = ::Regexp.last_match
154
+ kind = if m[1] then :close
155
+ elsif m[3] then :empty
156
+ else :open
157
+ end
158
+ tags << { kind: kind, from: m.begin(0), to: m.end(0), attrs: source[m.begin(2)...m.end(2)] }
159
+ end
160
+ tags
161
+ end
162
+
163
+ # Every `<Suspense>` has its `</Suspense>` (as a component tag does, Story
164
+ # 18-2), in the same ERB block — the helper call it becomes opens a Ruby
165
+ # block that the closing tag ends — and takes literal attribute values.
166
+ # Raises {ComponentTagError} / {PreprocessorError} with the line.
167
+ def check_suspense!(source)
168
+ open = []
169
+ suspense_tags(source).each do |tag|
170
+ case tag[:kind]
171
+ when :open then open << tag
172
+ when :close
173
+ opening = open.pop
174
+ raise_stray("Suspense", line_at(source, tag[:from])) if opening.nil?
175
+ check_same_block!(source, opening, tag)
176
+ end
177
+ check_literal_attributes!(source, tag) unless tag[:kind] == :close
178
+ end
179
+ return if open.empty?
180
+
181
+ raise ComponentTagError,
182
+ "ruact: <Suspense> at #{location(line_at(source, open.first[:from]))} is never closed — close it with " \
183
+ "`</Suspense>`."
184
+ end
185
+
186
+ # The Ruby of the ERB tags between a pair has to stand on its own: an `if`
187
+ # or a block opened or closed between them would take the pair's `end`.
188
+ # Parsed inside parentheses, which no `end` in it can close, within a
189
+ # method and a block, where `yield`, `next` and `return` are Ruby. When the
190
+ # template's Ruby does not parse at all, Rails reports that, with its line.
191
+ #
192
+ # The content is then the body of a block, so `break`, `next`, `return`,
193
+ # `it` and `_1` written at its own level would apply to that block, not to
194
+ # the loop or block around the Suspense: refused, with what to do instead.
195
+ def check_same_block!(source, opening, closing)
196
+ tree = parse_erb_code(source[opening[:to]...closing[:from]])
197
+ if tree.nil?
198
+ return if parse_erb_code(source).nil?
199
+
200
+ raise ComponentTagError,
201
+ "ruact: <Suspense> at #{location(line_at(source, opening[:from]))} and its </Suspense> at line " \
202
+ "#{line_at(source, closing[:from])} are not in the same ERB block — an `if`, a loop or a block opens " \
203
+ "or closes between them. Put both tags inside that block, or both outside it."
204
+ end
205
+
206
+ word = escaping_word(first_node(tree, :paren))
207
+ return unless word
208
+ return if word == "it" && it_is_a_local?(source[0...opening[:from]])
209
+
210
+ advice = word.start_with?("_") || word == "it" ? "Name the block's parameter." : "Move it outside the Suspense."
211
+ raise ComponentTagError,
212
+ "ruact: <Suspense> at #{location(line_at(source, opening[:from]))} — `#{word}` between its tags would " \
213
+ "apply to the Suspense's own block, not to the loop or block around it. #{advice}"
214
+ end
215
+
216
+ # The Ripper tree of the Ruby in +erb+'s tags (comments left out), as the
217
+ # body of a block in a method, in parentheses; nil when it does not parse.
218
+ def parse_erb_code(erb)
219
+ code = erb.to_enum(:scan, ERB_CODE_RE).filter_map do
220
+ m = ::Regexp.last_match
221
+ m[2] unless m[1] == "#"
222
+ end
223
+ Ripper.sexp("def __ruact_check\nproc do\n(\n#{code.join("\n")}\n)\nend\nend")
224
+ end
225
+
226
+ # The first node of +type+ in +tree+, depth first: for {#parse_erb_code}'s
227
+ # tree, the parentheses the template's code sits in.
228
+ def first_node(tree, type)
229
+ return nil unless tree.is_a?(Array)
230
+ return tree if tree.first == type
231
+
232
+ tree.each do |child|
233
+ found = first_node(child, type)
234
+ return found if found
235
+ end
236
+ nil
237
+ end
238
+
239
+ # The first `break`, `next`, `redo`, `return`, implicit `it` or numbered
240
+ # parameter that would apply to the Suspense's block, if any. +in_loop+:
241
+ # inside a loop of the content's own, where only `it` and `_1` still do.
242
+ def escaping_word(node, in_loop: false)
243
+ return nil unless node.is_a?(Array)
244
+ return nil if OWN_BLOCK_NODES.include?(node.first)
245
+
246
+ word = escaping_word_at(node)
247
+ return word if word && !(in_loop && LOOP_WORDS.include?(word))
248
+
249
+ in_loop ||= LOOP_NODES.include?(node.first)
250
+ node.each do |child|
251
+ found = escaping_word(child, in_loop: in_loop)
252
+ return found if found
253
+ end
254
+ nil
255
+ end
256
+
257
+ def escaping_word_at(node)
258
+ case node.first
259
+ when :break, :next, :redo, :return then node.first.to_s
260
+ when :return0 then "return"
261
+ when :vcall then "it" if node.dig(1, 1) == "it"
262
+ when :var_ref then node.dig(1, 1) if node.dig(1, 1).to_s.match?(/\A_[1-9]\z/)
263
+ end
264
+ end
265
+
266
+ # Whether the template before a `<Suspense>` makes `it` a local variable —
267
+ # a block parameter (`|it|`) or an assignment — in which case `it` between
268
+ # the tags is that variable, as Ruby would read it. (A partial's local
269
+ # named `it` is not seen here.)
270
+ def it_is_a_local?(before)
271
+ before.to_enum(:scan, ERB_CODE_RE).any? do
272
+ code = ::Regexp.last_match(2)
273
+ code.match?(/\|[^|]*\bit\b[^|]*\|/) || code.match?(/\bit\s*(?:=(?![=~>])|\|\|=|&&=)/)
274
+ end
275
+ end
276
+
277
+ def check_literal_attributes!(source, tag)
278
+ return unless tag[:attrs].include?("<%") || tag[:attrs].include?("%>")
279
+
280
+ raise PreprocessorError,
281
+ "ruact: <Suspense> at #{location(line_at(source, tag[:from]))} — `fallback` and `delay` take literal " \
282
+ "text, not ERB."
283
+ end
284
+
285
+ def rewrite_suspense_tag(tag, line_breaks)
286
+ breaks = "\n" * line_breaks
287
+ return %(<% end #{breaks}%><%= "" %>) if tag[:kind] == :close
288
+
289
+ fallback = extract_string_attr(tag[:attrs], "fallback") || ""
290
+ delay = extract_string_attr(tag[:attrs], "delay")
291
+ opening = %(<%= __ruact_suspense__(#{fallback.inspect}, #{delay.inspect}) do #{breaks}%>)
292
+ tag[:kind] == :empty ? opening + SUSPENSE_BLOCK_CLOSE : opening
293
+ end
294
+
116
295
  # The common path: no PascalCase closing tag anywhere, so every component
117
296
  # tag should be self-closing. One gsub, no masking copies — the render
118
297
  # benchmark's allocation profile depends on it. An opening tag that is not
@@ -188,7 +367,7 @@ module Ruact
188
367
  end
189
368
 
190
369
  def rewrite_tag(source, tag, paired)
191
- return "</#{WRAPPER_TAG}>" if tag[:closing]
370
+ return Markers::WRAPPER_CLOSE if tag[:closing]
192
371
 
193
372
  kind = if !paired.key?(tag[:from]) then :self_closing
194
373
  elsif paired[tag[:from]] then :with_children
@@ -248,6 +427,8 @@ module Ruact
248
427
  # allocation.
249
428
  def inside_erb?(source, offset)
250
429
  opened = source.rindex("<%", offset)
430
+ # `<%%` is a literal `<%`, not an island: look further back.
431
+ opened = opened.positive? ? source.rindex("<%", opened - 1) : nil while opened && source[opened + 2] == "%"
251
432
  return false unless opened
252
433
 
253
434
  closed = source.index("%>", opened + 2)
@@ -16,17 +16,32 @@ module Ruact
16
16
 
17
17
  # Represents a React Suspense boundary.
18
18
  # `fallback` is a ReactElement shown while the deferred content is loading.
19
- # `children` is the actual content (emitted as a deferred row after `delay` seconds).
19
+ # `children` is the content, emitted as a deferred row after the shell: an
20
+ # element tree, or a {DeferredContent} rendered when its row is due.
21
+ # `delay` is the wait, in seconds, before that row when the response
22
+ # streams; nil waits nothing.
20
23
  class SuspenseElement
21
24
  attr_reader :fallback, :children, :delay
22
25
 
23
- def initialize(fallback:, children:, delay: 1.5)
26
+ def initialize(fallback:, children:, delay: nil)
24
27
  @fallback = fallback
25
28
  @children = children
26
29
  @delay = delay
27
30
  end
28
31
  end
29
32
 
33
+ # The content of a Suspense boundary that has not been rendered yet: the
34
+ # index of its block in the render context. The serializer never sees one —
35
+ # it defers a boundary's children without looking at them, and the caller
36
+ # of {Renderer#take_boundary} renders it.
37
+ class DeferredContent
38
+ attr_reader :index
39
+
40
+ def initialize(index)
41
+ @index = index
42
+ end
43
+ end
44
+
30
45
  # Points to a "use client" module — will become an I row + $L<id> reference.
31
46
  class ClientReference
32
47
  attr_reader :module_id, :export_name
@@ -4,10 +4,26 @@ require "json"
4
4
 
5
5
  module Ruact
6
6
  module Flight
7
- # Renders a React element tree to a Flight wire format string.
7
+ # Renders a React element tree to Flight rows, in the order React's client
8
+ # must read them.
8
9
  #
9
- # Usage:
10
- # output = Renderer.render(root_element, bundler_config)
10
+ # A render has two parts. The **shell** is every row the page needs to show
11
+ # with its Suspense fallbacks: the imports and model rows the tree
12
+ # references, then the root row. Each **deferred boundary** — the content of
13
+ # a `<Suspense>` — then gets its own rows, one boundary at a time, each
14
+ # preceded by the import and text rows it brings.
15
+ #
16
+ # renderer = Renderer.new(root, manifest)
17
+ # renderer.shell # => ["1:I[...]\n", "0:[...]\n"]
18
+ # renderer.next_boundary(streaming: true) while renderer.pending?
19
+ #
20
+ # A boundary whose content is not rendered yet ({DeferredContent}) is taken
21
+ # with {#take_boundary}, rendered by the caller, and handed back to
22
+ # {#boundary_rows} — or to {#error_rows} when rendering it failed.
23
+ #
24
+ # {.each} and {.render} walk all of it:
25
+ #
26
+ # Renderer.render(root_element, bundler_config)
11
27
  # # => "1:I[...]\n0:[\"$\",\"div\",...]\n"
12
28
  class Renderer
13
29
  def self.render(model, bundler_config, **)
@@ -38,54 +54,88 @@ module Ruact
38
54
  strict_serialization: strict_serialization,
39
55
  on_as_json_warning: on_as_json_warning,
40
56
  development: development)
57
+ @serializer = Serializer.new(@request)
41
58
  end
42
59
 
43
- # Yields Flight rows one at a time.
44
- # Flush order: imports → regular → root → deferred (with optional delay) → errors.
45
- # When streaming: false (initial HTML shell), deferred delays are skipped.
46
- def each(streaming: true, &block)
47
- return enum_for(:each, streaming: streaming) unless block_given?
60
+ # The shell's rows: the imports and model rows the tree references, then
61
+ # the root row (id 0). Call once, before {#next_boundary}.
62
+ def shell
63
+ raise Ruact::Error, "ruact: Renderer#shell was already taken" if @shell_taken
48
64
 
65
+ @shell_taken = true
49
66
  root_id = @request.allocate_id # => 0
67
+ root_json = JSON.generate(@serializer.serialize_model(@request.root_model))
68
+ @request.take_rows << RowEmitter.model(root_id, root_json)
69
+ end
50
70
 
51
- serializer = Serializer.new(@request)
52
- root_value = serializer.serialize_model(@request.root_model)
53
- root_json = JSON.generate(root_value)
54
- root_row = RowEmitter.model(root_id, root_json)
55
-
56
- @request.completed_import_chunks.each(&block)
57
- @request.completed_regular_chunks.each(&block)
58
- yield root_row
59
-
60
- # Deferred chunks: emitted after root, optionally delayed (Suspense streaming).
61
- # When the chunk delay exceeds suspense_timeout, an E-type error row is emitted instead.
62
- @request.deferred_chunks.each do |deferred|
63
- if streaming && deferred[:delay]&.positive?
64
- timeout = Ruact.config.suspense_timeout
65
- if timeout&.positive? && deferred[:delay] > timeout
66
- error = RowEmitter.error_payload("Suspense timeout exceeded", digest: "ruact:suspense-timeout")
67
- yield RowEmitter.error(deferred[:id], JSON.generate(error))
68
- next
69
- end
70
- sleep(deferred[:delay])
71
- end
71
+ # Whether a deferred boundary is still to be rendered.
72
+ def pending?
73
+ @request.deferred?
74
+ end
72
75
 
73
- # Serialize deferred content — may produce new import rows, and new
74
- # regular rows (a string of 1024+ bytes becomes a `T` row the deferred
75
- # model row references as `$<id>`).
76
- import_count_before = @request.completed_import_chunks.length
77
- regular_count_before = @request.completed_regular_chunks.length
78
- deferred_value = serializer.serialize_model(deferred[:element])
76
+ # How many deferred boundaries are still to be rendered.
77
+ def pending_count
78
+ @request.deferred_left
79
+ end
79
80
 
80
- # Yield the rows discovered during deferred serialization BEFORE the
81
- # row that references them.
82
- @request.completed_import_chunks[import_count_before..].each(&block)
83
- @request.completed_regular_chunks[regular_count_before..].each(&block)
81
+ # The rows of the next deferred boundary: the import and text rows its
82
+ # content brings, then its model row. When +streaming+, a boundary with a
83
+ # `delay` waits it first — or, when the delay exceeds `suspense_timeout`,
84
+ # gets an error row instead. For content that is already an element tree;
85
+ # a {DeferredContent} needs {#take_boundary}.
86
+ def next_boundary(streaming:)
87
+ boundary = take_boundary
88
+ if boundary[:element].is_a?(DeferredContent)
89
+ raise Ruact::Error, "ruact: Renderer#next_boundary cannot render a block; use #take_boundary"
90
+ end
84
91
 
85
- yield RowEmitter.model(deferred[:id], JSON.generate(deferred_value))
92
+ if streaming && boundary[:delay]&.positive?
93
+ timeout = Ruact.config.suspense_timeout
94
+ if timeout&.positive? && boundary[:delay] > timeout
95
+ error = RowEmitter.error_payload("Suspense timeout exceeded", digest: "ruact:suspense-timeout")
96
+ return error_rows(boundary[:id], error)
97
+ end
98
+ sleep(boundary[:delay])
86
99
  end
87
100
 
88
- @request.completed_error_chunks.each(&block)
101
+ boundary_rows(boundary[:id], boundary[:element])
102
+ end
103
+
104
+ # Takes the next deferred boundary: `{ id:, element:, delay: }`, its
105
+ # content (`element`) still to be rendered. Hand it back, rendered, to
106
+ # {#boundary_rows}, or to {#error_rows}.
107
+ def take_boundary
108
+ raise Ruact::Error, "ruact: Renderer#next_boundary before #shell" unless @shell_taken
109
+ raise Ruact::Error, "ruact: Renderer#next_boundary with no boundary pending" unless pending?
110
+
111
+ boundary = @request.next_deferred
112
+ boundary.merge(mark: @request.deferred_count)
113
+ end
114
+
115
+ # The rows of a boundary taken with {#take_boundary}, whose content
116
+ # rendered to +element+.
117
+ def boundary_rows(id, element)
118
+ value = @serializer.serialize_model(element)
119
+ @request.take_rows << RowEmitter.model(id, JSON.generate(value))
120
+ end
121
+
122
+ # The row of a boundary taken with {#take_boundary} whose content failed
123
+ # to render: an error row with +payload+ ({RowEmitter.error_payload}).
124
+ # The boundaries its content deferred before it failed are dropped (no row
125
+ # references them); the rows it wrote before it failed still go out
126
+ # first, as they may be what a later row needs.
127
+ def error_rows(id, payload, boundary: nil)
128
+ @request.drop_deferred_since(boundary[:mark]) if boundary
129
+ @request.take_rows << RowEmitter.error(id, JSON.generate(payload))
130
+ end
131
+
132
+ # Yields every row: the shell, then each deferred boundary.
133
+ # When +streaming+ is false (the initial HTML document), delays are skipped.
134
+ def each(streaming: true, &)
135
+ return enum_for(:each, streaming: streaming) unless block_given?
136
+
137
+ shell.each(&)
138
+ next_boundary(streaming: streaming).each(&) while pending?
89
139
  end
90
140
  end
91
141
  end
@@ -2,21 +2,17 @@
2
2
 
3
3
  module Ruact
4
4
  module Flight
5
- # Central state for a single Flight render.
6
- # Owns the ID allocator, chunk queues, and dedup tracker.
5
+ # Central state for a single Flight render: the id allocator, the rows the
6
+ # serializer has written and not yet handed out, the deferred boundaries
7
+ # still to render, and the client-reference de-duplication table.
8
+ #
9
+ # The serializer writes rows here ({#add_import}, {#add_row}) and defers
10
+ # Suspense content ({#defer}); {Renderer} takes them in the order the
11
+ # client must read them ({#take_rows}, {#next_deferred}).
7
12
  class Request
8
- # I rows — flushed first
9
- attr_reader :completed_import_chunks
10
- # model rows
11
- attr_reader :completed_regular_chunks
12
- # E rows — flushed last
13
- attr_reader :completed_error_chunks
14
- # { id:, element:, delay: } — emitted after root row
15
- attr_reader :deferred_chunks
16
13
  # object_id => "$L<hex>" reference (dedup)
17
14
  attr_reader :written_objects
18
- attr_reader :next_chunk_id, :pending_chunks, :bundler_config, :root_model,
19
- :strict_serialization, :on_as_json_warning, :development
15
+ attr_reader :bundler_config, :root_model, :strict_serialization, :on_as_json_warning, :development
20
16
 
21
17
  def initialize(model, bundler_config, strict_serialization: false, on_as_json_warning: nil,
22
18
  development: false)
@@ -26,13 +22,12 @@ module Ruact
26
22
  @suspense_symbol_ref = nil
27
23
  @boundary_ref = nil
28
24
  @next_chunk_id = 0
29
- @pending_chunks = 0
30
25
  @bundler_config = bundler_config
31
26
 
32
- @completed_import_chunks = []
33
- @completed_regular_chunks = []
34
- @completed_error_chunks = []
35
- @deferred_chunks = []
27
+ @imports = [] # I rows
28
+ @rows = [] # model and text rows other than the root
29
+ @deferred = [] # { id:, element:, delay: }, in the order they were met
30
+ @next_deferred = 0
36
31
 
37
32
  @written_objects = {}.compare_by_identity
38
33
 
@@ -46,12 +41,69 @@ module Ruact
46
41
  id
47
42
  end
48
43
 
44
+ # An import row. Imports are handed out before any model row taken with them.
45
+ def add_import(row)
46
+ @imports << row
47
+ end
48
+
49
+ # A model or text row that a later row references.
50
+ def add_row(row)
51
+ @rows << row
52
+ end
53
+
54
+ # The rows written since the last call: imports first, then the rest, in
55
+ # the order they were written. Whatever references them comes after.
56
+ def take_rows
57
+ taken = @imports + @rows
58
+ @imports.clear
59
+ @rows.clear
60
+ taken
61
+ end
62
+
63
+ # Defers Suspense content: its row is rendered by {Renderer} after the
64
+ # shell. Returns the id the row will have.
65
+ def defer(element, delay)
66
+ id = allocate_id
67
+ @deferred << { id: id, element: element, delay: delay }
68
+ id
69
+ end
70
+
71
+ # Whether deferred content is still to be rendered (content deferred while
72
+ # rendering a deferred row counts too).
73
+ def deferred?
74
+ @next_deferred < @deferred.length
75
+ end
76
+
77
+ # The next deferred content, in the order it was met.
78
+ def next_deferred
79
+ deferred = @deferred[@next_deferred]
80
+ @next_deferred += 1
81
+ deferred
82
+ end
83
+
84
+ # How many deferred contents were met so far: a mark for
85
+ # {#drop_deferred_since}.
86
+ def deferred_count
87
+ @deferred.length
88
+ end
89
+
90
+ # Forgets the contents deferred since +mark+ — the Suspense boundaries
91
+ # inside a boundary whose row is an error, which no row references.
92
+ def drop_deferred_since(mark)
93
+ @deferred.slice!(mark..)
94
+ end
95
+
96
+ # How many deferred contents are still to be rendered.
97
+ def deferred_left
98
+ @deferred.length - @next_deferred
99
+ end
100
+
49
101
  # The `"$<hex>"` reference to this response's `"$Sreact.suspense"` symbol
50
102
  # row, emitted once, before the first row that uses it.
51
103
  def suspense_symbol_ref
52
104
  @suspense_symbol_ref ||= begin
53
105
  id = allocate_id
54
- @completed_regular_chunks << RowEmitter.model(id, JSON.generate("$Sreact.suspense"))
106
+ add_row(RowEmitter.model(id, JSON.generate("$Sreact.suspense")))
55
107
  "$#{id.to_s(16)}"
56
108
  end
57
109
  end
@@ -66,18 +118,10 @@ module Ruact
66
118
  def boundary_ref
67
119
  @boundary_ref ||= begin
68
120
  id = allocate_id
69
- @completed_import_chunks << RowEmitter.import(id, JSON.generate(BOUNDARY_METADATA))
121
+ add_import(RowEmitter.import(id, JSON.generate(BOUNDARY_METADATA)))
70
122
  "$L#{id.to_s(16)}"
71
123
  end
72
124
  end
73
-
74
- def increment_pending
75
- @pending_chunks += 1
76
- end
77
-
78
- def decrement_pending
79
- @pending_chunks -= 1
80
- end
81
125
  end
82
126
  end
83
127
  end
@@ -61,9 +61,7 @@ module Ruact
61
61
  # Large strings get their own T row
62
62
  if value.bytesize >= LARGE_TEXT_THRESHOLD
63
63
  id = @request.allocate_id
64
- @request.increment_pending
65
- row = RowEmitter.text(id, value)
66
- @request.completed_regular_chunks << row
64
+ @request.add_row(RowEmitter.text(id, value))
67
65
  # A plain "$<hex>" reference: React reads "$T" as a temporary
68
66
  # reference (Story 18-1).
69
67
  return "$#{id.to_s(16)}"
@@ -149,8 +147,7 @@ module Ruact
149
147
 
150
148
  def serialize_suspense(element)
151
149
  # Allocate ID for the deferred content row (emitted after root row + delay)
152
- deferred_id = @request.allocate_id
153
- @request.deferred_chunks << { id: deferred_id, element: element.children, delay: element.delay }
150
+ deferred_id = @request.defer(element.children, element.delay)
154
151
 
155
152
  fallback_value = element.fallback ? serialize_model(element.fallback) : nil
156
153
 
@@ -221,11 +218,7 @@ module Ruact
221
218
  metadata = @request.bundler_config.resolve(ref.module_id, ref.export_name)
222
219
 
223
220
  id = @request.allocate_id
224
- @request.increment_pending
225
-
226
- json = JSON.generate(metadata)
227
- row = RowEmitter.import(id, json)
228
- @request.completed_import_chunks << row
221
+ @request.add_import(RowEmitter.import(id, JSON.generate(metadata)))
229
222
 
230
223
  lazy_ref = "$L#{id.to_s(16)}"
231
224
  @request.written_objects[ref] = lazy_ref