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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +30 -1
- data/CONTRIBUTING.md +10 -3
- data/README.md +1 -1
- data/lib/generators/ruact/install/templates/AGENTS.md.tt +8 -8
- data/lib/generators/ruact/install/templates/initializer.rb.tt +4 -1
- data/lib/ruact/configuration.rb +6 -1
- data/lib/ruact/controller/delivery.rb +247 -0
- data/lib/ruact/controller/document_rendering.rb +15 -12
- data/lib/ruact/controller/streaming.rb +241 -0
- data/lib/ruact/controller.rb +29 -48
- data/lib/ruact/deferred_render.rb +208 -0
- data/lib/ruact/doctor.rb +18 -1
- data/lib/ruact/erb_preprocessor.rb +203 -22
- data/lib/ruact/flight/react_element.rb +17 -2
- data/lib/ruact/flight/renderer.rb +91 -41
- data/lib/ruact/flight/request.rb +71 -27
- data/lib/ruact/flight/serializer.rb +3 -10
- data/lib/ruact/html_converter.rb +23 -31
- data/lib/ruact/inline_script.rb +24 -0
- data/lib/ruact/load_async_note.rb +51 -0
- data/lib/ruact/markers.rb +95 -0
- data/lib/ruact/navigation_boundary.rb +2 -2
- data/lib/ruact/railtie.rb +12 -0
- data/lib/ruact/render_context.rb +61 -6
- data/lib/ruact/render_pipeline.rb +29 -21
- 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 +62 -0
- data/lib/ruact/view_helper.rb +24 -32
- data/lib/ruact.rb +4 -0
- data/vendor/javascript/vite-plugin-ruact/bootstrap.test.mjs +16 -3
- data/vendor/javascript/vite-plugin-ruact/page-mount.test.mjs +67 -0
- data/vendor/javascript/vite-plugin-ruact/page-stream.test.mjs +107 -0
- data/vendor/javascript/vite-plugin-ruact/runtime/bootstrap.jsx +23 -18
- data/vendor/javascript/vite-plugin-ruact/runtime/dev-overlay.js +39 -0
- data/vendor/javascript/vite-plugin-ruact/runtime/page-stream.js +88 -0
- data/vendor/javascript/vite-plugin-ruact/runtime/ruact-router.js +11 -17
- data/vendor/javascript/vite-plugin-ruact/runtime/suspense-boundary.js +1 -0
- 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
|
-
|
|
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
|
-
#
|
|
57
|
-
#
|
|
58
|
-
|
|
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
|
|
63
|
-
# ViewHelper writes the same
|
|
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) {
|
|
66
|
-
__ruact_component_open__: ->(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
|
-
|
|
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('"', """)
|
|
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('"', '"')}") : ""
|
|
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
|
|
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
|
|
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:
|
|
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
|
|
7
|
+
# Renders a React element tree to Flight rows, in the order React's client
|
|
8
|
+
# must read them.
|
|
8
9
|
#
|
|
9
|
-
#
|
|
10
|
-
#
|
|
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
|
-
#
|
|
44
|
-
#
|
|
45
|
-
|
|
46
|
-
|
|
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
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
data/lib/ruact/flight/request.rb
CHANGED
|
@@ -2,21 +2,17 @@
|
|
|
2
2
|
|
|
3
3
|
module Ruact
|
|
4
4
|
module Flight
|
|
5
|
-
# Central state for a single Flight render
|
|
6
|
-
#
|
|
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 :
|
|
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
|
-
@
|
|
33
|
-
@
|
|
34
|
-
@
|
|
35
|
-
@
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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
|