ruact 0.0.19 → 0.0.21
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +57 -1
- data/CONTRIBUTING.md +10 -3
- data/README.md +1 -1
- data/lib/generators/ruact/install/templates/AGENTS.md.tt +13 -13
- data/lib/generators/ruact/install/templates/initializer.rb.tt +4 -1
- data/lib/ruact/cached_blocks.rb +30 -0
- data/lib/ruact/configuration.rb +6 -1
- data/lib/ruact/controller/delivery.rb +262 -0
- data/lib/ruact/controller/document_rendering.rb +15 -12
- data/lib/ruact/controller/streaming.rb +305 -0
- data/lib/ruact/controller.rb +34 -51
- data/lib/ruact/declared_content.rb +33 -0
- data/lib/ruact/deferred_render.rb +254 -0
- data/lib/ruact/doctor.rb +42 -2
- data/lib/ruact/erb_preprocessor.rb +211 -24
- data/lib/ruact/flight/react_element.rb +22 -2
- data/lib/ruact/flight/renderer.rb +94 -41
- data/lib/ruact/flight/request.rb +71 -27
- data/lib/ruact/flight/serializer.rb +3 -10
- data/lib/ruact/html_converter.rb +44 -34
- data/lib/ruact/inline_script.rb +24 -0
- data/lib/ruact/load_async_note.rb +51 -0
- data/lib/ruact/markers.rb +98 -0
- data/lib/ruact/navigation_boundary.rb +2 -2
- data/lib/ruact/query_watch.rb +106 -0
- data/lib/ruact/railtie.rb +12 -0
- data/lib/ruact/render_context.rb +73 -6
- data/lib/ruact/render_pipeline.rb +32 -22
- 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 +70 -0
- data/lib/ruact/view_helper.rb +35 -33
- data/lib/ruact.rb +7 -0
- data/vendor/javascript/vite-plugin-ruact/bootstrap.test.mjs +16 -3
- data/vendor/javascript/vite-plugin-ruact/page-mount.test.mjs +75 -0
- data/vendor/javascript/vite-plugin-ruact/page-stream.test.mjs +132 -0
- data/vendor/javascript/vite-plugin-ruact/ruact-router.test.mjs +185 -0
- data/vendor/javascript/vite-plugin-ruact/runtime/bootstrap.jsx +23 -18
- data/vendor/javascript/vite-plugin-ruact/runtime/dev-overlay.js +53 -0
- data/vendor/javascript/vite-plugin-ruact/runtime/page-stream.js +108 -0
- data/vendor/javascript/vite-plugin-ruact/runtime/ruact-router.js +74 -20
- data/vendor/javascript/vite-plugin-ruact/runtime/suspense-boundary.js +1 -0
- metadata +17 -2
|
@@ -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,91 @@ 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
|
+
# A block this render cannot run: its marker came out of a `cache`
|
|
89
|
+
# block a render that streamed wrote.
|
|
90
|
+
if boundary[:element].is_a?(DeferredContent)
|
|
91
|
+
return error_rows(boundary[:id],
|
|
92
|
+
RowEmitter.error_payload(DeferredContent::STALE, digest: "ruact:suspense-error"))
|
|
93
|
+
end
|
|
84
94
|
|
|
85
|
-
|
|
95
|
+
if streaming && boundary[:delay]&.positive?
|
|
96
|
+
timeout = Ruact.config.suspense_timeout
|
|
97
|
+
if timeout&.positive? && boundary[:delay] > timeout
|
|
98
|
+
error = RowEmitter.error_payload("Suspense timeout exceeded", digest: "ruact:suspense-timeout")
|
|
99
|
+
return error_rows(boundary[:id], error)
|
|
100
|
+
end
|
|
101
|
+
sleep(boundary[:delay])
|
|
86
102
|
end
|
|
87
103
|
|
|
88
|
-
|
|
104
|
+
boundary_rows(boundary[:id], boundary[:element])
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Takes the next deferred boundary: `{ id:, element:, delay: }`, its
|
|
108
|
+
# content (`element`) still to be rendered. Hand it back, rendered, to
|
|
109
|
+
# {#boundary_rows}, or to {#error_rows}.
|
|
110
|
+
def take_boundary
|
|
111
|
+
raise Ruact::Error, "ruact: Renderer#next_boundary before #shell" unless @shell_taken
|
|
112
|
+
raise Ruact::Error, "ruact: Renderer#next_boundary with no boundary pending" unless pending?
|
|
113
|
+
|
|
114
|
+
boundary = @request.next_deferred
|
|
115
|
+
boundary.merge(mark: @request.deferred_count)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# The rows of a boundary taken with {#take_boundary}, whose content
|
|
119
|
+
# rendered to +element+.
|
|
120
|
+
def boundary_rows(id, element)
|
|
121
|
+
value = @serializer.serialize_model(element)
|
|
122
|
+
@request.take_rows << RowEmitter.model(id, JSON.generate(value))
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# The row of a boundary taken with {#take_boundary} whose content failed
|
|
126
|
+
# to render: an error row with +payload+ ({RowEmitter.error_payload}).
|
|
127
|
+
# The boundaries its content deferred before it failed are dropped (no row
|
|
128
|
+
# references them); the rows it wrote before it failed still go out
|
|
129
|
+
# first, as they may be what a later row needs.
|
|
130
|
+
def error_rows(id, payload, boundary: nil)
|
|
131
|
+
@request.drop_deferred_since(boundary[:mark]) if boundary
|
|
132
|
+
@request.take_rows << RowEmitter.error(id, JSON.generate(payload))
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# Yields every row: the shell, then each deferred boundary.
|
|
136
|
+
# When +streaming+ is false (the initial HTML document), delays are skipped.
|
|
137
|
+
def each(streaming: true, &)
|
|
138
|
+
return enum_for(:each, streaming: streaming) unless block_given?
|
|
139
|
+
|
|
140
|
+
shell.each(&)
|
|
141
|
+
next_boundary(streaming: streaming).each(&) while pending?
|
|
89
142
|
end
|
|
90
143
|
end
|
|
91
144
|
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
|
data/lib/ruact/html_converter.rb
CHANGED
|
@@ -8,7 +8,7 @@ module Ruact
|
|
|
8
8
|
# Rules:
|
|
9
9
|
# - HTML attributes → React equivalents (class→className, for→htmlFor, etc.)
|
|
10
10
|
# - data-react-key="x" → becomes the React key on the element
|
|
11
|
-
# - HTML comments matching
|
|
11
|
+
# - HTML comments matching __RUACT_<render>_<n>__ tokens → replaced by client component refs
|
|
12
12
|
# - Text nodes → plain Ruby strings
|
|
13
13
|
# - Multiple root nodes → wrapped in a Fragment (array)
|
|
14
14
|
# rubocop:disable Metrics/ClassLength
|
|
@@ -35,6 +35,11 @@ module Ruact
|
|
|
35
35
|
"spellcheck" => "spellCheck"
|
|
36
36
|
}.freeze
|
|
37
37
|
|
|
38
|
+
# A client component's marker (`__RUACT_<render>_<n>__`, the render's id
|
|
39
|
+
# being eight hex digits, {RenderContext}), or one 0.0.20 and before wrote
|
|
40
|
+
# (`__RUACT_<n>__`), which a `cache` block may still hold.
|
|
41
|
+
TOKEN = /\A__RUACT_(?:\h{8}_)?\d+__\z/
|
|
42
|
+
|
|
38
43
|
# Tags whose `value` attribute maps to React's `defaultValue` (uncontrolled).
|
|
39
44
|
DEFAULT_VALUE_TAGS = %w[textarea select].freeze
|
|
40
45
|
|
|
@@ -52,7 +57,9 @@ module Ruact
|
|
|
52
57
|
end
|
|
53
58
|
|
|
54
59
|
def initialize(component_registry)
|
|
55
|
-
|
|
60
|
+
# Indexed once by token: each marker in the HTML is one lookup. Built
|
|
61
|
+
# without a pair array per entry (the render allocation guard counts them).
|
|
62
|
+
@registry = component_registry.each_with_object({}) { |entry, by_token| by_token[entry[:token]] = entry } # rubocop:disable Style/ReduceToHash
|
|
56
63
|
end
|
|
57
64
|
|
|
58
65
|
# Convert an HTML string into a ReactElement tree (instance form).
|
|
@@ -163,9 +170,13 @@ module Ruact
|
|
|
163
170
|
|
|
164
171
|
when Nokogiri::XML::Node::COMMENT_NODE
|
|
165
172
|
# Check if this is a Ruact component placeholder
|
|
166
|
-
token = node
|
|
167
|
-
entry = @registry
|
|
168
|
-
|
|
173
|
+
token = Markers.comment_token(node)
|
|
174
|
+
entry = @registry[token]
|
|
175
|
+
unless entry
|
|
176
|
+
raise Ruact::HtmlConverterError, stale_component_message(token) if token.match?(TOKEN)
|
|
177
|
+
|
|
178
|
+
return nil
|
|
179
|
+
end
|
|
169
180
|
|
|
170
181
|
Flight::ReactElement.new(
|
|
171
182
|
type: entry[:ref],
|
|
@@ -181,8 +192,8 @@ module Ruact
|
|
|
181
192
|
|
|
182
193
|
def convert_element(node)
|
|
183
194
|
tag = node.name.downcase
|
|
184
|
-
return convert_suspense_element(node) if tag ==
|
|
185
|
-
return convert_component_with_children(node) if tag ==
|
|
195
|
+
return convert_suspense_element(node) if tag == Markers::SUSPENSE_TAG
|
|
196
|
+
return convert_component_with_children(node) if tag == Markers::WRAPPER_TAG
|
|
186
197
|
|
|
187
198
|
props = build_props(node, tag)
|
|
188
199
|
children = convert_children(node)
|
|
@@ -196,9 +207,9 @@ module Ruact
|
|
|
196
207
|
# component's `children` prop, converted like any element's; an empty pair
|
|
197
208
|
# passes none, like `<Card />`.
|
|
198
209
|
def convert_component_with_children(node)
|
|
199
|
-
token = node
|
|
200
|
-
entry = @registry
|
|
201
|
-
raise Ruact::HtmlConverterError,
|
|
210
|
+
token = Markers.wrapper_token(node)
|
|
211
|
+
entry = @registry[token]
|
|
212
|
+
raise Ruact::HtmlConverterError, stale_component_message(token) unless entry
|
|
202
213
|
|
|
203
214
|
props = entry[:props]
|
|
204
215
|
children = convert_children(node)
|
|
@@ -208,39 +219,38 @@ module Ruact
|
|
|
208
219
|
Flight::ReactElement.new(type: entry[:ref], key: nil, props: props)
|
|
209
220
|
end
|
|
210
221
|
|
|
222
|
+
# Story 19-2 — a marker this render did not write: it came out of a
|
|
223
|
+
# `cache` block, which stores a component's marker and not the component.
|
|
224
|
+
def stale_component_message(token)
|
|
225
|
+
"ruact: the page holds the marker of a client component (#{token}) that this render did not " \
|
|
226
|
+
"register — it came out of a `cache` block, which stores the component's marker but not the " \
|
|
227
|
+
"component. Move the client component outside the `cache` block (cache the HTML around it, or " \
|
|
228
|
+
"the data it is given)."
|
|
229
|
+
end
|
|
230
|
+
|
|
211
231
|
def convert_suspense_element(node)
|
|
212
|
-
fallback_text = node
|
|
232
|
+
fallback_text = Markers.suspense_fallback(node)
|
|
213
233
|
fallback = if fallback_text.empty?
|
|
214
234
|
nil
|
|
215
235
|
else
|
|
216
236
|
Flight::ReactElement.new(type: "p", key: nil, props: { "children" => fallback_text })
|
|
217
237
|
end
|
|
218
238
|
|
|
219
|
-
child_nodes = convert_children(node)
|
|
220
|
-
children = child_nodes.length == 1 ? child_nodes.first : child_nodes
|
|
221
|
-
|
|
222
239
|
# 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
|
|
240
|
+
# server-side wait, in seconds, before the deferred row streams); nil
|
|
241
|
+
# when absent or unusable.
|
|
242
|
+
delay = Markers.suspense_delay(node)
|
|
243
|
+
Flight::SuspenseElement.new(fallback: fallback, children: suspense_children(node), delay: delay)
|
|
244
|
+
end
|
|
238
245
|
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
246
|
+
# The content of a Suspense wrapper: its converted children, or — when its
|
|
247
|
+
# block is rendered later — a reference to that block.
|
|
248
|
+
def suspense_children(node)
|
|
249
|
+
block = Markers.suspense_block(node)
|
|
250
|
+
return Flight::DeferredContent.new(block) if block
|
|
251
|
+
|
|
252
|
+
child_nodes = convert_children(node)
|
|
253
|
+
child_nodes.length == 1 ? child_nodes.first : child_nodes
|
|
244
254
|
end
|
|
245
255
|
|
|
246
256
|
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,98 @@
|
|
|
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_a1b2c3d4_0__ -->`;
|
|
12
|
+
# - a client component with children: an element wrapped around the
|
|
13
|
+
# children's HTML — `<ruact-component data-ruact-token="__RUACT_a1b2c3d4_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 key
|
|
18
|
+
# of its block in the {RenderContext} instead (`data-ruact-block="a1b2c3d4-0"`).
|
|
19
|
+
#
|
|
20
|
+
# Every token and key carries the id of the render that wrote it, so a
|
|
21
|
+
# marker a `cache` block stored in another render is never taken for one of
|
|
22
|
+
# this render's.
|
|
23
|
+
#
|
|
24
|
+
# {ErbPreprocessor} writes the ERB that produces them, the view helpers
|
|
25
|
+
# ({ViewHelper}, and {RenderPipeline} for a plain-ERB render) write the
|
|
26
|
+
# component markers, {HtmlConverter} reads all three. None of them spells
|
|
27
|
+
# these strings itself.
|
|
28
|
+
module Markers
|
|
29
|
+
# The element a paired component's content is wrapped in.
|
|
30
|
+
WRAPPER_TAG = "ruact-component"
|
|
31
|
+
WRAPPER_CLOSE = "</#{WRAPPER_TAG}>".freeze
|
|
32
|
+
|
|
33
|
+
# The element a Suspense boundary's content is wrapped in.
|
|
34
|
+
SUSPENSE_TAG = "ruact-suspense"
|
|
35
|
+
SUSPENSE_CLOSE = "</#{SUSPENSE_TAG}>".freeze
|
|
36
|
+
|
|
37
|
+
TOKEN_ATTR = "data-ruact-token"
|
|
38
|
+
FALLBACK_ATTR = "data-ruact-fallback"
|
|
39
|
+
DELAY_ATTR = "data-ruact-delay"
|
|
40
|
+
BLOCK_ATTR = "data-ruact-block"
|
|
41
|
+
|
|
42
|
+
# The marker for a client component, by its render-context token.
|
|
43
|
+
def self.component(token)
|
|
44
|
+
"<!-- #{token} -->"
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# The opening of a client component with children; {WRAPPER_CLOSE} ends it.
|
|
48
|
+
def self.component_open(token)
|
|
49
|
+
%(<#{WRAPPER_TAG} #{TOKEN_ATTR}="#{token}">)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# The opening of a Suspense boundary, from the `fallback` and `delay`
|
|
53
|
+
# attribute values as written in the template. Only `"` is escaped: the
|
|
54
|
+
# rest of the text stays as the template wrote it (entities included, which
|
|
55
|
+
# the HTML parser then decodes). The preprocessor refuses ERB in either.
|
|
56
|
+
# +block+ is the key of the boundary's content in the render context,
|
|
57
|
+
# when that content is rendered later.
|
|
58
|
+
def self.suspense_open(fallback, delay, block: nil)
|
|
59
|
+
delay_attr = delay ? %( #{DELAY_ATTR}="#{delay.gsub('"', '"')}") : ""
|
|
60
|
+
block_attr = block ? %( #{BLOCK_ATTR}="#{block}") : ""
|
|
61
|
+
%(<#{SUSPENSE_TAG} #{FALLBACK_ATTR}="#{fallback.gsub('"', '"')}"#{delay_attr}#{block_attr}>)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# The token a component marker (an HTML comment node) carries.
|
|
65
|
+
def self.comment_token(node)
|
|
66
|
+
node.content.strip
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# The token a component-with-children wrapper carries.
|
|
70
|
+
def self.wrapper_token(node)
|
|
71
|
+
node[TOKEN_ATTR]
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# The fallback text of a Suspense wrapper ("" when there is none).
|
|
75
|
+
def self.suspense_fallback(node)
|
|
76
|
+
node[FALLBACK_ATTR] || ""
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# The key of the block a Suspense wrapper's content is rendered from
|
|
80
|
+
# later, or nil when the content is in the wrapper.
|
|
81
|
+
def self.suspense_block(node)
|
|
82
|
+
node[BLOCK_ATTR]
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# The delay of a Suspense wrapper, in seconds, or nil when it is absent,
|
|
86
|
+
# blank, unparseable or not finite (`"1e309"` parses to Infinity without
|
|
87
|
+
# raising, and must not reach a `sleep`).
|
|
88
|
+
def self.suspense_delay(node)
|
|
89
|
+
value = node[DELAY_ATTR]
|
|
90
|
+
return nil if value.nil? || value.to_s.strip.empty?
|
|
91
|
+
|
|
92
|
+
parsed = Float(value)
|
|
93
|
+
parsed.finite? ? parsed : nil
|
|
94
|
+
rescue ArgumentError, TypeError
|
|
95
|
+
nil
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
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
|