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.
Files changed (46) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +57 -1
  3. data/CONTRIBUTING.md +10 -3
  4. data/README.md +1 -1
  5. data/lib/generators/ruact/install/templates/AGENTS.md.tt +13 -13
  6. data/lib/generators/ruact/install/templates/initializer.rb.tt +4 -1
  7. data/lib/ruact/cached_blocks.rb +30 -0
  8. data/lib/ruact/configuration.rb +6 -1
  9. data/lib/ruact/controller/delivery.rb +262 -0
  10. data/lib/ruact/controller/document_rendering.rb +15 -12
  11. data/lib/ruact/controller/streaming.rb +305 -0
  12. data/lib/ruact/controller.rb +34 -51
  13. data/lib/ruact/declared_content.rb +33 -0
  14. data/lib/ruact/deferred_render.rb +254 -0
  15. data/lib/ruact/doctor.rb +42 -2
  16. data/lib/ruact/erb_preprocessor.rb +211 -24
  17. data/lib/ruact/flight/react_element.rb +22 -2
  18. data/lib/ruact/flight/renderer.rb +94 -41
  19. data/lib/ruact/flight/request.rb +71 -27
  20. data/lib/ruact/flight/serializer.rb +3 -10
  21. data/lib/ruact/html_converter.rb +44 -34
  22. data/lib/ruact/inline_script.rb +24 -0
  23. data/lib/ruact/load_async_note.rb +51 -0
  24. data/lib/ruact/markers.rb +98 -0
  25. data/lib/ruact/navigation_boundary.rb +2 -2
  26. data/lib/ruact/query_watch.rb +106 -0
  27. data/lib/ruact/railtie.rb +12 -0
  28. data/lib/ruact/render_context.rb +73 -6
  29. data/lib/ruact/render_pipeline.rb +32 -22
  30. data/lib/ruact/request_kind.rb +36 -0
  31. data/lib/ruact/server.rb +3 -7
  32. data/lib/ruact/testing/flight_extractor.rb +21 -4
  33. data/lib/ruact/version.rb +1 -1
  34. data/lib/ruact/view_helper/suspense.rb +70 -0
  35. data/lib/ruact/view_helper.rb +35 -33
  36. data/lib/ruact.rb +7 -0
  37. data/vendor/javascript/vite-plugin-ruact/bootstrap.test.mjs +16 -3
  38. data/vendor/javascript/vite-plugin-ruact/page-mount.test.mjs +75 -0
  39. data/vendor/javascript/vite-plugin-ruact/page-stream.test.mjs +132 -0
  40. data/vendor/javascript/vite-plugin-ruact/ruact-router.test.mjs +185 -0
  41. data/vendor/javascript/vite-plugin-ruact/runtime/bootstrap.jsx +23 -18
  42. data/vendor/javascript/vite-plugin-ruact/runtime/dev-overlay.js +53 -0
  43. data/vendor/javascript/vite-plugin-ruact/runtime/page-stream.js +108 -0
  44. data/vendor/javascript/vite-plugin-ruact/runtime/ruact-router.js +74 -20
  45. data/vendor/javascript/vite-plugin-ruact/runtime/suspense-boundary.js +1 -0
  46. 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 a Flight wire format string.
7
+ # Renders a React element tree to Flight rows, in the order React's client
8
+ # must read them.
8
9
  #
9
- # Usage:
10
- # output = Renderer.render(root_element, bundler_config)
10
+ # A render has two parts. The **shell** is every row the page needs to show
11
+ # with its Suspense fallbacks: the imports and model rows the tree
12
+ # references, then the root row. Each **deferred boundary** — the content of
13
+ # a `<Suspense>` — then gets its own rows, one boundary at a time, each
14
+ # preceded by the import and text rows it brings.
15
+ #
16
+ # renderer = Renderer.new(root, manifest)
17
+ # renderer.shell # => ["1:I[...]\n", "0:[...]\n"]
18
+ # renderer.next_boundary(streaming: true) while renderer.pending?
19
+ #
20
+ # A boundary whose content is not rendered yet ({DeferredContent}) is taken
21
+ # with {#take_boundary}, rendered by the caller, and handed back to
22
+ # {#boundary_rows} — or to {#error_rows} when rendering it failed.
23
+ #
24
+ # {.each} and {.render} walk all of it:
25
+ #
26
+ # Renderer.render(root_element, bundler_config)
11
27
  # # => "1:I[...]\n0:[\"$\",\"div\",...]\n"
12
28
  class Renderer
13
29
  def self.render(model, bundler_config, **)
@@ -38,54 +54,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
- # Yields Flight rows one at a time.
44
- # Flush order: imports → regular → root → deferred (with optional delay) → errors.
45
- # When streaming: false (initial HTML shell), deferred delays are skipped.
46
- def each(streaming: true, &block)
47
- return enum_for(:each, streaming: streaming) unless block_given?
60
+ # The shell's rows: the imports and model rows the tree references, then
61
+ # the root row (id 0). Call once, before {#next_boundary}.
62
+ def shell
63
+ raise Ruact::Error, "ruact: Renderer#shell was already taken" if @shell_taken
48
64
 
65
+ @shell_taken = true
49
66
  root_id = @request.allocate_id # => 0
67
+ root_json = JSON.generate(@serializer.serialize_model(@request.root_model))
68
+ @request.take_rows << RowEmitter.model(root_id, root_json)
69
+ end
50
70
 
51
- serializer = Serializer.new(@request)
52
- root_value = serializer.serialize_model(@request.root_model)
53
- root_json = JSON.generate(root_value)
54
- root_row = RowEmitter.model(root_id, root_json)
55
-
56
- @request.completed_import_chunks.each(&block)
57
- @request.completed_regular_chunks.each(&block)
58
- yield root_row
59
-
60
- # Deferred chunks: emitted after root, optionally delayed (Suspense streaming).
61
- # When the chunk delay exceeds suspense_timeout, an E-type error row is emitted instead.
62
- @request.deferred_chunks.each do |deferred|
63
- if streaming && deferred[:delay]&.positive?
64
- timeout = Ruact.config.suspense_timeout
65
- if timeout&.positive? && deferred[:delay] > timeout
66
- error = RowEmitter.error_payload("Suspense timeout exceeded", digest: "ruact:suspense-timeout")
67
- yield RowEmitter.error(deferred[:id], JSON.generate(error))
68
- next
69
- end
70
- sleep(deferred[:delay])
71
- end
71
+ # Whether a deferred boundary is still to be rendered.
72
+ def pending?
73
+ @request.deferred?
74
+ end
72
75
 
73
- # Serialize deferred content — may produce new import rows, and new
74
- # regular rows (a string of 1024+ bytes becomes a `T` row the deferred
75
- # model row references as `$<id>`).
76
- import_count_before = @request.completed_import_chunks.length
77
- regular_count_before = @request.completed_regular_chunks.length
78
- deferred_value = serializer.serialize_model(deferred[:element])
76
+ # How many deferred boundaries are still to be rendered.
77
+ def pending_count
78
+ @request.deferred_left
79
+ end
79
80
 
80
- # Yield the rows discovered during deferred serialization BEFORE the
81
- # row that references them.
82
- @request.completed_import_chunks[import_count_before..].each(&block)
83
- @request.completed_regular_chunks[regular_count_before..].each(&block)
81
+ # The rows of the next deferred boundary: the import and text rows its
82
+ # content brings, then its model row. When +streaming+, a boundary with a
83
+ # `delay` waits it first — or, when the delay exceeds `suspense_timeout`,
84
+ # gets an error row instead. For content that is already an element tree;
85
+ # a {DeferredContent} needs {#take_boundary}.
86
+ def next_boundary(streaming:)
87
+ boundary = take_boundary
88
+ # 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
- yield RowEmitter.model(deferred[:id], JSON.generate(deferred_value))
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
- @request.completed_error_chunks.each(&block)
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
@@ -2,21 +2,17 @@
2
2
 
3
3
  module Ruact
4
4
  module Flight
5
- # Central state for a single Flight render.
6
- # Owns the ID allocator, chunk queues, and dedup tracker.
5
+ # Central state for a single Flight render: the id allocator, the rows the
6
+ # serializer has written and not yet handed out, the deferred boundaries
7
+ # still to render, and the client-reference de-duplication table.
8
+ #
9
+ # The serializer writes rows here ({#add_import}, {#add_row}) and defers
10
+ # Suspense content ({#defer}); {Renderer} takes them in the order the
11
+ # client must read them ({#take_rows}, {#next_deferred}).
7
12
  class Request
8
- # I rows — flushed first
9
- attr_reader :completed_import_chunks
10
- # model rows
11
- attr_reader :completed_regular_chunks
12
- # E rows — flushed last
13
- attr_reader :completed_error_chunks
14
- # { id:, element:, delay: } — emitted after root row
15
- attr_reader :deferred_chunks
16
13
  # object_id => "$L<hex>" reference (dedup)
17
14
  attr_reader :written_objects
18
- attr_reader :next_chunk_id, :pending_chunks, :bundler_config, :root_model,
19
- :strict_serialization, :on_as_json_warning, :development
15
+ attr_reader :bundler_config, :root_model, :strict_serialization, :on_as_json_warning, :development
20
16
 
21
17
  def initialize(model, bundler_config, strict_serialization: false, on_as_json_warning: nil,
22
18
  development: false)
@@ -26,13 +22,12 @@ module Ruact
26
22
  @suspense_symbol_ref = nil
27
23
  @boundary_ref = nil
28
24
  @next_chunk_id = 0
29
- @pending_chunks = 0
30
25
  @bundler_config = bundler_config
31
26
 
32
- @completed_import_chunks = []
33
- @completed_regular_chunks = []
34
- @completed_error_chunks = []
35
- @deferred_chunks = []
27
+ @imports = [] # I rows
28
+ @rows = [] # model and text rows other than the root
29
+ @deferred = [] # { id:, element:, delay: }, in the order they were met
30
+ @next_deferred = 0
36
31
 
37
32
  @written_objects = {}.compare_by_identity
38
33
 
@@ -46,12 +41,69 @@ module Ruact
46
41
  id
47
42
  end
48
43
 
44
+ # An import row. Imports are handed out before any model row taken with them.
45
+ def add_import(row)
46
+ @imports << row
47
+ end
48
+
49
+ # A model or text row that a later row references.
50
+ def add_row(row)
51
+ @rows << row
52
+ end
53
+
54
+ # The rows written since the last call: imports first, then the rest, in
55
+ # the order they were written. Whatever references them comes after.
56
+ def take_rows
57
+ taken = @imports + @rows
58
+ @imports.clear
59
+ @rows.clear
60
+ taken
61
+ end
62
+
63
+ # Defers Suspense content: its row is rendered by {Renderer} after the
64
+ # shell. Returns the id the row will have.
65
+ def defer(element, delay)
66
+ id = allocate_id
67
+ @deferred << { id: id, element: element, delay: delay }
68
+ id
69
+ end
70
+
71
+ # Whether deferred content is still to be rendered (content deferred while
72
+ # rendering a deferred row counts too).
73
+ def deferred?
74
+ @next_deferred < @deferred.length
75
+ end
76
+
77
+ # The next deferred content, in the order it was met.
78
+ def next_deferred
79
+ deferred = @deferred[@next_deferred]
80
+ @next_deferred += 1
81
+ deferred
82
+ end
83
+
84
+ # How many deferred contents were met so far: a mark for
85
+ # {#drop_deferred_since}.
86
+ def deferred_count
87
+ @deferred.length
88
+ end
89
+
90
+ # Forgets the contents deferred since +mark+ — the Suspense boundaries
91
+ # inside a boundary whose row is an error, which no row references.
92
+ def drop_deferred_since(mark)
93
+ @deferred.slice!(mark..)
94
+ end
95
+
96
+ # How many deferred contents are still to be rendered.
97
+ def deferred_left
98
+ @deferred.length - @next_deferred
99
+ end
100
+
49
101
  # The `"$<hex>"` reference to this response's `"$Sreact.suspense"` symbol
50
102
  # row, emitted once, before the first row that uses it.
51
103
  def suspense_symbol_ref
52
104
  @suspense_symbol_ref ||= begin
53
105
  id = allocate_id
54
- @completed_regular_chunks << RowEmitter.model(id, JSON.generate("$Sreact.suspense"))
106
+ add_row(RowEmitter.model(id, JSON.generate("$Sreact.suspense")))
55
107
  "$#{id.to_s(16)}"
56
108
  end
57
109
  end
@@ -66,18 +118,10 @@ module Ruact
66
118
  def boundary_ref
67
119
  @boundary_ref ||= begin
68
120
  id = allocate_id
69
- @completed_import_chunks << RowEmitter.import(id, JSON.generate(BOUNDARY_METADATA))
121
+ add_import(RowEmitter.import(id, JSON.generate(BOUNDARY_METADATA)))
70
122
  "$L#{id.to_s(16)}"
71
123
  end
72
124
  end
73
-
74
- def increment_pending
75
- @pending_chunks += 1
76
- end
77
-
78
- def decrement_pending
79
- @pending_chunks -= 1
80
- end
81
125
  end
82
126
  end
83
127
  end
@@ -61,9 +61,7 @@ module Ruact
61
61
  # Large strings get their own T row
62
62
  if value.bytesize >= LARGE_TEXT_THRESHOLD
63
63
  id = @request.allocate_id
64
- @request.increment_pending
65
- row = RowEmitter.text(id, value)
66
- @request.completed_regular_chunks << row
64
+ @request.add_row(RowEmitter.text(id, value))
67
65
  # A plain "$<hex>" reference: React reads "$T" as a temporary
68
66
  # reference (Story 18-1).
69
67
  return "$#{id.to_s(16)}"
@@ -149,8 +147,7 @@ module Ruact
149
147
 
150
148
  def serialize_suspense(element)
151
149
  # Allocate ID for the deferred content row (emitted after root row + delay)
152
- deferred_id = @request.allocate_id
153
- @request.deferred_chunks << { id: deferred_id, element: element.children, delay: element.delay }
150
+ deferred_id = @request.defer(element.children, element.delay)
154
151
 
155
152
  fallback_value = element.fallback ? serialize_model(element.fallback) : nil
156
153
 
@@ -221,11 +218,7 @@ module Ruact
221
218
  metadata = @request.bundler_config.resolve(ref.module_id, ref.export_name)
222
219
 
223
220
  id = @request.allocate_id
224
- @request.increment_pending
225
-
226
- json = JSON.generate(metadata)
227
- row = RowEmitter.import(id, json)
228
- @request.completed_import_chunks << row
221
+ @request.add_import(RowEmitter.import(id, JSON.generate(metadata)))
229
222
 
230
223
  lazy_ref = "$L#{id.to_s(16)}"
231
224
  @request.written_objects[ref] = lazy_ref
@@ -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 __RUACT_N__ tokens → replaced by client component refs
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
- @registry = component_registry
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.content.strip
167
- entry = @registry.find { |c| c[:token] == token }
168
- return nil unless entry
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 == "ruact-suspense"
185
- return convert_component_with_children(node) if tag == ErbPreprocessor::WRAPPER_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["data-ruact-token"]
200
- entry = @registry.find { |c| c[:token] == token }
201
- raise Ruact::HtmlConverterError, "ruact: no component registered for #{token.inspect}" unless entry
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["data-ruact-fallback"] || ""
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 chunk streams). When
224
- # absent, blank, unparseable, or non-finite, SuspenseElement falls back to
225
- # its default.
226
- delay_attr = node["data-ruact-delay"]
227
- delay = begin
228
- if delay_attr && !delay_attr.to_s.strip.empty?
229
- # A string overflow like "1e309" parses to Infinity (Float() does not
230
- # raise) — guard finiteness so a non-finite delay can't reach the
231
- # renderer's sleep(delay) and raise RangeError.
232
- parsed = Float(delay_attr)
233
- parsed.finite? ? parsed : nil
234
- end
235
- rescue ArgumentError, TypeError
236
- nil
237
- end
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
- if delay
240
- Flight::SuspenseElement.new(fallback: fallback, children: children, delay: delay)
241
- else
242
- Flight::SuspenseElement.new(fallback: fallback, children: children)
243
- end
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('"', '&quot;')}") : ""
60
+ block_attr = block ? %( #{BLOCK_ATTR}="#{block}") : ""
61
+ %(<#{SUSPENSE_TAG} #{FALLBACK_ATTR}="#{fallback.gsub('"', '&quot;')}"#{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["HTTP_RUACT_REQUEST"] == "1"
40
+ return @app.call(env) unless RequestKind.from_router?(env)
41
41
  return native_response if @classifier.classify(env) == :native
42
42
 
43
43
  @app.call(env)
@@ -60,7 +60,7 @@ module Ruact
60
60
  headers[HEADER] = NATIVE
61
61
  headers["content-type"] = "text/plain; charset=utf-8"
62
62
  headers["cache-control"] = "no-store"
63
- headers["vary"] = "Ruact-Request"
63
+ headers["vary"] = RequestKind::ROUTER_HEADER
64
64
  headers
65
65
  end
66
66
  end