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
data/lib/ruact/html_converter.rb
CHANGED
|
@@ -52,7 +52,9 @@ module Ruact
|
|
|
52
52
|
end
|
|
53
53
|
|
|
54
54
|
def initialize(component_registry)
|
|
55
|
-
|
|
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
|
|
167
|
-
entry = @registry
|
|
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 ==
|
|
185
|
-
return convert_component_with_children(node) if 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
|
|
200
|
-
entry = @registry
|
|
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
|
|
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
|
|
224
|
-
# absent
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
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
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
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('"', '"')}") : ""
|
|
56
|
+
block_attr = block ? %( #{BLOCK_ATTR}="#{block}") : ""
|
|
57
|
+
%(<#{SUSPENSE_TAG} #{FALLBACK_ATTR}="#{fallback.gsub('"', '"')}"#{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
|
|
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"] =
|
|
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).
|
data/lib/ruact/render_context.rb
CHANGED
|
@@ -2,8 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
module Ruact
|
|
4
4
|
# Per-render mutable state holding the components encountered during ERB
|
|
5
|
-
# evaluation
|
|
6
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
27
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
137
|
-
#
|
|
138
|
-
|
|
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