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
@@ -0,0 +1,254 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "timeout"
5
+
6
+ module Ruact
7
+ # Story 19-1 — the render of a page whose `<Suspense>` content runs after the
8
+ # shell is sent: the shell first, then each boundary, one at a time, in page
9
+ # order, each block evaluated only when its row is due.
10
+ #
11
+ # run = DeferredRender.new(html, render_context, pipeline)
12
+ # write(run.shell)
13
+ # write(run.next_rows) while run.pending?
14
+ #
15
+ # A block runs in the view it was written in, with the template path Rails
16
+ # had when it met the tag, under `Ruact.config.suspense_timeout`. A block
17
+ # that raises, or runs past the timeout, becomes an error row for its
18
+ # boundary — reported through `Rails.error` and logged — and the next block
19
+ # runs. {Flight::Renderer} only ever serializes elements: evaluating, logging
20
+ # and reporting are here, outside `lib/ruact/flight/` (NFR10).
21
+ #
22
+ # Internal API: not part of the public compatibility contract.
23
+ class DeferredRender
24
+ TIMEOUT_DIGEST = "ruact:suspense-timeout"
25
+ ERROR_DIGEST = "ruact:suspense-error"
26
+
27
+ # The content ran past `suspense_timeout`. Its own: a `Timeout::Error` the
28
+ # content raised itself (`Net::ReadTimeout` is one) is the content's error.
29
+ class Expired < StandardError; end
30
+
31
+ # What production says about a block that raised: nothing of the error.
32
+ PRODUCTION_MESSAGE = "An error occurred in a <Suspense>"
33
+
34
+ # React's development client reads an error's `stack` as frames; a few
35
+ # name the template and the gem without burying the overlay.
36
+ STACK_FRAMES = 12
37
+
38
+ # @param html [String] the page ActionView rendered with its blocks kept
39
+ # (`RenderContext.new(deferring: true)`)
40
+ # @param pipeline [RenderPipeline] the manifest, controller path, logger
41
+ # and wire flavour the render uses
42
+ # @param guard [#call, nil] wraps each block's evaluation, inside the
43
+ # timeout: called with a block, returns its value or raises (the
44
+ # controller's development check for writes the response can no longer
45
+ # carry)
46
+ def initialize(html, render_context, pipeline, guard: nil)
47
+ @render_context = render_context
48
+ @manifest = pipeline.manifest
49
+ @controller_path = pipeline.controller_path
50
+ @development = pipeline.development
51
+ @logger = pipeline.logger
52
+ @guard = guard
53
+ @registry = []
54
+ @renderer = Flight::Renderer.new(convert(html), @manifest,
55
+ strict_serialization: Ruact.config.strict_serialization,
56
+ on_as_json_warning: pipeline.as_json_warning_callback,
57
+ development: @development)
58
+ end
59
+
60
+ # The shell's rows, as one string.
61
+ def shell
62
+ @renderer.shell.join
63
+ end
64
+
65
+ # Whether a boundary is still to be rendered.
66
+ def pending?
67
+ @renderer.pending?
68
+ end
69
+
70
+ # How many boundaries are still to be rendered.
71
+ def pending_count
72
+ @renderer.pending_count
73
+ end
74
+
75
+ # Renders the next boundary and returns its rows, as one string: the rows
76
+ # its content brings and its model row, or an error row.
77
+ def next_rows
78
+ boundary = @renderer.take_boundary
79
+ within_timeout(boundary[:delay]) do
80
+ @renderer.boundary_rows(boundary[:id], content(boundary[:element]))
81
+ end.join
82
+ rescue StandardError => e
83
+ raise if boundary.nil?
84
+
85
+ skip_unconverted_components
86
+ report(e, boundary)
87
+ @renderer.error_rows(boundary[:id], error_payload(e), boundary: boundary).join
88
+ end
89
+
90
+ private
91
+
92
+ # A block that failed may have registered components its HTML, now thrown
93
+ # away, was going to use: the next block's conversion starts after them.
94
+ def skip_unconverted_components
95
+ @registry.fill(nil, @registry.length...@render_context.components.length)
96
+ end
97
+
98
+ # The block's HTML as elements; content that is already elements as is.
99
+ def content(element)
100
+ return element unless element.is_a?(Flight::DeferredContent)
101
+
102
+ block = @render_context.block(element.key)
103
+ raise Ruact::Error, Flight::DeferredContent::STALE if block.nil?
104
+
105
+ html = @guard ? @guard.call { evaluate(block) } : evaluate(block)
106
+ convert(html)
107
+ end
108
+
109
+ # Runs the block in its view, with the template path and its local
110
+ # variables as they were when the tag was met, and returns what it wrote.
111
+ # Only what it writes: a value it ends on is not content.
112
+ def evaluate(pending)
113
+ declared = DeclaredContent.take(pending.view) if @development
114
+ html = with_locals(pending.block, pending.locals) { run_in_view(pending) }
115
+ DeclaredContent.check!(pending.view, declared) if declared
116
+ html
117
+ end
118
+
119
+ def run_in_view(pending)
120
+ pending.view.instance_exec(pending) do |entry|
121
+ saved = [@virtual_path, @current_template]
122
+ @virtual_path = entry.virtual_path
123
+ @current_template = entry.template
124
+ begin
125
+ capture do
126
+ entry.block.call
127
+ nil
128
+ end
129
+ ensure
130
+ @virtual_path, @current_template = saved
131
+ end
132
+ end
133
+ end
134
+
135
+ # Sets the block's local variables to +locals+ while it runs, and back
136
+ # after. A block shares them with the template and with the blocks met
137
+ # after it.
138
+ def with_locals(block, locals)
139
+ return yield if locals.nil? || locals.empty?
140
+
141
+ scope = block.binding
142
+ now = locals.keys.to_h { |name| [name, scope.local_variable_get(name)] }
143
+ locals.each { |name, value| scope.local_variable_set(name, value) }
144
+ begin
145
+ yield
146
+ ensure
147
+ now.each { |name, value| scope.local_variable_set(name, value) }
148
+ end
149
+ end
150
+
151
+ # The components registered since the last conversion are resolved and
152
+ # added: a block registers its own as it runs. One the manifest cannot
153
+ # resolve fails this conversion and keeps its place (nil), so the next
154
+ # block does not meet it again.
155
+ def convert(html)
156
+ failure = nil
157
+ @render_context.components.drop(@registry.length).each do |entry|
158
+ @registry << @render_context.resolve_entry(entry, @manifest, controller_path: @controller_path)
159
+ rescue StandardError => e
160
+ @registry << nil
161
+ failure ||= e
162
+ end
163
+ raise failure if failure
164
+
165
+ HtmlConverter.convert(html.to_s, @registry.compact)
166
+ end
167
+
168
+ # The block's `delay` and the block itself, within `suspense_timeout`.
169
+ # `Timeout.timeout` with no exception class unwinds the block with an
170
+ # exception that is not a StandardError, so a `rescue` in the view, or
171
+ # Active Record wrapping what a query raised, cannot take it; it raises
172
+ # `Timeout::Error` out here. It interrupts Ruby code, not a query running
173
+ # inside a database driver that holds the VM lock (measured in 19-0).
174
+ # A `delay` longer than the limit ends in the timeout row at once, as it
175
+ # did before blocks ran later.
176
+ def within_timeout(delay, &)
177
+ limit = Ruact.config.suspense_timeout
178
+ return wait_then(delay, &) unless limit&.positive?
179
+ raise Expired, "the delay (#{delay}s) is longer than suspense_timeout" if delay && delay > limit
180
+
181
+ started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
182
+ # A query still running at the limit is cancelled on the server, so its
183
+ # connection is free again (Story 19-2, QueryWatch).
184
+ owner = QueryWatch.current_owner
185
+ watch = QueryWatch.start(owner, limit)
186
+ begin
187
+ Timeout.timeout(limit) { wait_then(delay, &) }
188
+ rescue StandardError => e
189
+ raise unless expired?(e, started, limit, watch)
190
+
191
+ watch&.cancel_now(owner)
192
+ raise Expired, "ran past suspense_timeout (#{limit}s)", cause: e
193
+ ensure
194
+ watch&.stop
195
+ end
196
+ end
197
+
198
+ # A `Timeout::Error` is ours only when the limit has passed; earlier, it is
199
+ # the content's. Any other error is ours when the watch cancelled a query
200
+ # at the limit: what the cancelled query raised.
201
+ def expired?(error, started, limit, watch)
202
+ return watch&.cancelled? || false unless error.is_a?(Timeout::Error)
203
+
204
+ Process.clock_gettime(Process::CLOCK_MONOTONIC) - started >= limit
205
+ end
206
+
207
+ def wait_then(delay)
208
+ sleep(delay) if delay&.positive?
209
+ yield
210
+ end
211
+
212
+ def timed_out?(error)
213
+ error.is_a?(Expired)
214
+ end
215
+
216
+ def error_payload(error)
217
+ return Flight::RowEmitter.error_payload("Suspense timeout exceeded", digest: TIMEOUT_DIGEST) if timed_out?(error)
218
+ return Flight::RowEmitter.error_payload(PRODUCTION_MESSAGE, digest: ERROR_DIGEST) unless @development
219
+
220
+ payload = Flight::RowEmitter.error_payload("#{error.class}: #{error.message}", digest: ERROR_DIGEST)
221
+ payload["stack"] = development_stack(error)
222
+ payload
223
+ end
224
+
225
+ # React's development client reads each frame of `stack` as
226
+ # `[function, file, line, column, enclosing line, enclosing column]` and
227
+ # rebuilds the server's call stack from it. A four-element frame (what this
228
+ # sent before Story 19-2) left the last two undefined, and every frame came
229
+ # out at line 2. Ruby has no column and no enclosing function: column 1,
230
+ # enclosing 0 (none).
231
+ def development_stack(error)
232
+ Array(error.backtrace).first(STACK_FRAMES).filter_map do |frame|
233
+ match = frame.match(/\A(.+?):(\d+)(?::in [`'](.+)')?\z/)
234
+ match && [match[3] || "(ruby)", match[1], Integer(match[2], 10), 1, 0, 0]
235
+ end
236
+ end
237
+
238
+ def report(error, boundary)
239
+ what = timed_out?(error) ? "ran past suspense_timeout (#{Ruact.config.suspense_timeout}s)" : "raised"
240
+ @logger&.error("[ruact] <Suspense> content#{where(boundary)} #{what}: #{error.class}: #{error.message}")
241
+ return unless defined?(Rails) && Rails.respond_to?(:error)
242
+
243
+ Rails.error.report(error, handled: true, severity: :error, source: "ruact.suspense")
244
+ end
245
+
246
+ def where(boundary)
247
+ element = boundary[:element]
248
+ return "" unless element.is_a?(Flight::DeferredContent)
249
+
250
+ location = @render_context.block(element.key)&.location
251
+ location ? " at #{location.path}:#{location.lineno}" : ""
252
+ end
253
+ end
254
+ end
data/lib/ruact/doctor.rb CHANGED
@@ -8,7 +8,7 @@ module Ruact
8
8
  # Runs a suite of installation health checks and prints ✓/✗ per check.
9
9
  # Extracted from the ruact:doctor Rake task for direct testability (FR27).
10
10
  class Doctor # rubocop:disable Metrics/ClassLength
11
- CHECKS = %i[manifest vite flight_client controller layout head_assets streaming legacy_constant
11
+ CHECKS = %i[manifest vite flight_client controller layout head_assets streaming load_async legacy_constant
12
12
  serialize_only flight_middleware].freeze
13
13
  # Built via Array#join so the gem-CI `name-propagation` guard does not
14
14
  # match these literals against itself (Story 5.1 review F4 — the doctor
@@ -484,7 +484,47 @@ module Ruact
484
484
  def check_streaming
485
485
  mode = Ruact.streaming_mode || :buffered
486
486
  label = mode == :enabled ? "enabled" : "buffered"
487
- [:pass, "streaming: #{label} (#{streaming_server_hint})"]
487
+ message = "streaming: #{label} (#{streaming_server_hint})"
488
+ declaring = streaming_controllers.length
489
+ return [:pass, message] if declaring.zero?
490
+
491
+ # Story 19-2 (AC20) — the doctor sees no request: what it can say is
492
+ # where a page that streams silently does not.
493
+ [:pass, "#{message}; #{pluralize_count(declaring, 'controller')} #{declaring == 1 ? 'streams' : 'stream'} " \
494
+ "(ruact_streaming or ActionController::Live) — behind a proxy, the app must be reached over " \
495
+ "HTTP/1.1 (nginx: `proxy_http_version 1.1;`), or those pages are sent whole",
496
+ PROXY_HTTP11]
497
+ end
498
+
499
+ # Story 19-2 (AC20) — what makes a proxy talk HTTP/1.1 to the app.
500
+ PROXY_HTTP11 = "Behind nginx, set `proxy_http_version 1.1;` where it proxies to the app: by default it " \
501
+ "talks HTTP/1.0 upstream, and a page declared with ruact_streaming is then sent whole " \
502
+ "(the app logs it once)."
503
+ private_constant :PROXY_HTTP11
504
+
505
+ # Controller files that declare `ruact_streaming` or include
506
+ # `ActionController::Live`. Reads source, as {#island_controllers} does.
507
+ def streaming_controllers
508
+ Dir.glob(Rails.root.join("app", "controllers", "**", "*.rb").to_s).select do |file|
509
+ File.read(file).match?(/^\s*(ruact_streaming\b|include\s+ActionController::Live\b)/)
510
+ end
511
+ end
512
+
513
+ # Story 19-1 (ADR D1) — whether `load_async` runs its query in the
514
+ # background. It is how a page starts its queries early, so they finish
515
+ # while the shell is sent; without an executor (a new Rails app's default)
516
+ # it loads where it is called. A warning, not a failure: an app that never
517
+ # calls `load_async` has nothing to fix.
518
+ def check_load_async
519
+ return [:pass, "load_async: Active Record is not loaded"] unless defined?(::ActiveRecord::Base)
520
+
521
+ executor = ::ActiveRecord.async_query_executor
522
+ return [:pass, "load_async: runs in the background (async_query_executor = #{executor.inspect})"] if executor
523
+
524
+ [:warn,
525
+ "load_async: runs its query where it is called — config.active_record.async_query_executor is not " \
526
+ "set. Set it to :global_thread_pool (config/application.rb) so a page's queries start early and " \
527
+ "finish while its shell is sent."]
488
528
  end
489
529
 
490
530
  # Detects host-app references to the legacy gem constant or require path
@@ -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
  #
@@ -12,7 +14,7 @@ module Ruact
12
14
  # <%= __ruact_component__("LikeButton", { "postId" => @post.id, "initialCount" => 5 }) %>
13
15
  #
14
16
  # The placeholder is replaced by an HTML comment with a unique token:
15
- # <!-- __RUACT_0__ -->
17
+ # <!-- __RUACT_a1b2c3d4_0__ -->
16
18
  #
17
19
  # A component with children (Story 18-2) keeps its content as ERB, inside a
18
20
  # wrapper element HtmlConverter turns into the component's `children`:
@@ -34,6 +36,10 @@ module Ruact
34
36
  SUSPENSE_OPEN_RE = /<Suspense\b([^>]*?)>/m
35
37
  SUSPENSE_CLOSE_RE = %r{</Suspense\s*>}
36
38
 
39
+ # Any Suspense tag: capture 1 is the slash of a closing tag, capture 2 the
40
+ # attributes, capture 3 the slash of a self-closing one.
41
+ SUSPENSE_TAG_RE = %r{<(/)?Suspense\b([^>]*?)(/)?>}m
42
+
37
43
  # Matches ANY PascalCase component tag: opening (`<Card>`), self-closing
38
44
  # (`<Card />`), or closing (`</Card>`). Capture 1 is the leading slash
39
45
  # (present only on a closing tag); capture 2 is the name. The pairing scan
@@ -48,22 +54,36 @@ module Ruact
48
54
  # Newline-preserving mask for ERB islands (`<% … %>`, `<%= … %>`, `<%# … %>`).
49
55
  # The pairing scan blanks these first so a `</Card>` that lives inside
50
56
  # Ruby/ERB string or comment text is never taken for a real closing tag.
51
- ERB_ISLAND_RE = /<%.*?%>/m
57
+ # `<%%` is ERB's escape for a literal `<%`: text, not an island.
58
+ ERB_ISLAND_RE = /<%(?!%).*?%>/m
52
59
 
53
60
  # An ERB comment, which renders nothing.
54
61
  ERB_COMMENT_RE = /<%#.*?%>/m
55
62
 
56
- # The element a paired component's content is wrapped in until
57
- # {HtmlConverter} turns it into the component's `children`.
58
- WRAPPER_TAG = "ruact-component"
63
+ # What ends a `<Suspense>`: the {ViewHelper#__ruact_suspense__} block its
64
+ # content is the body of. The empty output tag after it keeps Rails' ERB
65
+ # from trimming a `</Suspense>` that sits alone on its line, which would eat
66
+ # the line break after it (text the page shows).
67
+ SUSPENSE_BLOCK_CLOSE = %(<% end %><%= "" %>)
68
+
69
+ # Ripper node types inside which `break`, `next`, `redo`, `return`, `it`
70
+ # and `_1` belong to a block of their own, not to the Suspense's block.
71
+ OWN_BLOCK_NODES = %i[brace_block do_block lambda def defs class module sclass].freeze
72
+ # Loops give `break`, `next` and `redo` a scope of their own, but not `it`
73
+ # or `_1`, which still name the enclosing block's parameter.
74
+ LOOP_NODES = %i[while until while_mod until_mod for].freeze
75
+ LOOP_WORDS = %w[break next redo].freeze
76
+
77
+ # One ERB tag's flag (`=`, `==`, `-`, or `#` for a comment) and its Ruby code.
78
+ ERB_CODE_RE = /<%(?!%)(==|=|-|#)?(.*?)-?%>/m
59
79
 
60
80
  # What each helper the transformed template calls leaves in the HTML, by
61
81
  # its render-context token: a component, or the opening of one with
62
- # children. {RenderPipeline} installs these for a plain-ERB render;
63
- # ViewHelper writes the same strings in ActionView.
82
+ # children ({Markers}). {RenderPipeline} installs these for a plain-ERB
83
+ # render; ViewHelper writes the same markers in ActionView.
64
84
  PLACEHOLDERS = {
65
- __ruact_component__: ->(token) { "<!-- #{token} -->" },
66
- __ruact_component_open__: ->(token) { %(<#{WRAPPER_TAG} data-ruact-token="#{token}">) }
85
+ __ruact_component__: ->(token) { Markers.component(token) },
86
+ __ruact_component_open__: ->(token) { Markers.component_open(token) }
67
87
  }.freeze
68
88
 
69
89
  # Transform ERB source, replacing component tags with ERB placeholders.
@@ -90,29 +110,188 @@ module Ruact
90
110
  # Step 1: component tags → ERB placeholders. Story 18-2: a tag with a
91
111
  # matching closing tag is a component WITH children; its content stays
92
112
  # ERB, evaluated in place, inside a wrapper element.
113
+ # Suspense pairs are checked on the template as written, so an error names
114
+ # the template's own line.
115
+ check_suspense!(source) if source.include?("Suspense")
116
+
93
117
  result = if source.match?(CLOSING_TAG_PROBE_RE)
94
118
  rewrite_paired(source)
95
119
  else
96
120
  rewrite_self_closing(source)
97
121
  end
98
122
 
99
- # Step 2: <Suspense> paired tags → <ruact-suspense> HTML elements.
100
- result
101
- .gsub(SUSPENSE_OPEN_RE) do
102
- attrs = ::Regexp.last_match(1)
103
- fallback = extract_string_attr(attrs, "fallback") || ""
104
- escaped = fallback.gsub('"', "&quot;")
105
- # Optional `delay="2.5"` — the server-side wait (seconds) before
106
- # the deferred chunk streams. Forwarded to SuspenseElement#delay.
107
- delay = extract_string_attr(attrs, "delay")
108
- delay_attr = delay ? %( data-ruact-delay="#{delay.gsub('"', '&quot;')}") : ""
109
- %(<ruact-suspense data-ruact-fallback="#{escaped}"#{delay_attr}>)
110
- end
111
- .gsub(SUSPENSE_CLOSE_RE, "</ruact-suspense>")
123
+ rewrite_suspense(result)
112
124
  end
113
125
 
114
126
  private
115
127
 
128
+ # Step 2: `<Suspense>` pairs. Each pair becomes a
129
+ # {ViewHelper#__ruact_suspense__} call whose block is what sits between the
130
+ # tags; the helper writes the Suspense marker around it ({Markers}), and
131
+ # decides when the block is evaluated. `delay="2.5"` is optional: the
132
+ # server-side wait (seconds) before the deferred chunk streams, forwarded to
133
+ # SuspenseElement#delay. `<Suspense ... />` is a pair with nothing in it. A
134
+ # tag inside an ERB island is Ruby text and is left alone. The generated ERB
135
+ # keeps the tag's line breaks, so later lines keep their numbers.
136
+ def rewrite_suspense(source)
137
+ return source unless source.include?("Suspense")
138
+
139
+ out = +""
140
+ cursor = 0
141
+ suspense_tags(source).each do |tag|
142
+ out << source[cursor...tag[:from]]
143
+ out << rewrite_suspense_tag(tag, source[tag[:from]...tag[:to]].count("\n"))
144
+ cursor = tag[:to]
145
+ end
146
+ out << source[cursor..]
147
+ end
148
+
149
+ # Every Suspense tag outside an ERB island, in order.
150
+ def suspense_tags(source)
151
+ tags = []
152
+ mask_erb(source).scan(SUSPENSE_TAG_RE) do
153
+ m = ::Regexp.last_match
154
+ kind = if m[1] then :close
155
+ elsif m[3] then :empty
156
+ else :open
157
+ end
158
+ tags << { kind: kind, from: m.begin(0), to: m.end(0), attrs: source[m.begin(2)...m.end(2)] }
159
+ end
160
+ tags
161
+ end
162
+
163
+ # Every `<Suspense>` has its `</Suspense>` (as a component tag does, Story
164
+ # 18-2), in the same ERB block — the helper call it becomes opens a Ruby
165
+ # block that the closing tag ends — and takes literal attribute values.
166
+ # Raises {ComponentTagError} / {PreprocessorError} with the line.
167
+ def check_suspense!(source)
168
+ open = []
169
+ suspense_tags(source).each do |tag|
170
+ case tag[:kind]
171
+ when :open then open << tag
172
+ when :close
173
+ opening = open.pop
174
+ raise_stray("Suspense", line_at(source, tag[:from])) if opening.nil?
175
+ check_same_block!(source, opening, tag)
176
+ end
177
+ check_literal_attributes!(source, tag) unless tag[:kind] == :close
178
+ end
179
+ return if open.empty?
180
+
181
+ raise ComponentTagError,
182
+ "ruact: <Suspense> at #{location(line_at(source, open.first[:from]))} is never closed — close it with " \
183
+ "`</Suspense>`."
184
+ end
185
+
186
+ # The Ruby of the ERB tags between a pair has to stand on its own: an `if`
187
+ # or a block opened or closed between them would take the pair's `end`.
188
+ # Parsed inside parentheses, which no `end` in it can close, within a
189
+ # method and a block, where `yield`, `next` and `return` are Ruby. When the
190
+ # template's Ruby does not parse at all, Rails reports that, with its line.
191
+ #
192
+ # The content is then the body of a block, so `break`, `next`, `return`,
193
+ # `it` and `_1` written at its own level would apply to that block, not to
194
+ # the loop or block around the Suspense: refused, with what to do instead.
195
+ def check_same_block!(source, opening, closing)
196
+ tree = parse_erb_code(source[opening[:to]...closing[:from]])
197
+ if tree.nil?
198
+ return if parse_erb_code(source).nil?
199
+
200
+ raise ComponentTagError,
201
+ "ruact: <Suspense> at #{location(line_at(source, opening[:from]))} and its </Suspense> at line " \
202
+ "#{line_at(source, closing[:from])} are not in the same ERB block — an `if`, a loop or a block opens " \
203
+ "or closes between them. Put both tags inside that block, or both outside it."
204
+ end
205
+
206
+ word = escaping_word(first_node(tree, :paren))
207
+ return unless word
208
+ return if word == "it" && it_is_a_local?(source[0...opening[:from]])
209
+
210
+ advice = word.start_with?("_") || word == "it" ? "Name the block's parameter." : "Move it outside the Suspense."
211
+ raise ComponentTagError,
212
+ "ruact: <Suspense> at #{location(line_at(source, opening[:from]))} — `#{word}` between its tags would " \
213
+ "apply to the Suspense's own block, not to the loop or block around it. #{advice}"
214
+ end
215
+
216
+ # The Ripper tree of the Ruby in +erb+'s tags (comments left out), as the
217
+ # body of a block in a method, in parentheses; nil when it does not parse.
218
+ def parse_erb_code(erb)
219
+ code = erb.to_enum(:scan, ERB_CODE_RE).filter_map do
220
+ m = ::Regexp.last_match
221
+ m[2] unless m[1] == "#"
222
+ end
223
+ Ripper.sexp("def __ruact_check\nproc do\n(\n#{code.join("\n")}\n)\nend\nend")
224
+ end
225
+
226
+ # The first node of +type+ in +tree+, depth first: for {#parse_erb_code}'s
227
+ # tree, the parentheses the template's code sits in.
228
+ def first_node(tree, type)
229
+ return nil unless tree.is_a?(Array)
230
+ return tree if tree.first == type
231
+
232
+ tree.each do |child|
233
+ found = first_node(child, type)
234
+ return found if found
235
+ end
236
+ nil
237
+ end
238
+
239
+ # The first `break`, `next`, `redo`, `return`, implicit `it` or numbered
240
+ # parameter that would apply to the Suspense's block, if any. +in_loop+:
241
+ # inside a loop of the content's own, where only `it` and `_1` still do.
242
+ def escaping_word(node, in_loop: false)
243
+ return nil unless node.is_a?(Array)
244
+ return nil if OWN_BLOCK_NODES.include?(node.first)
245
+
246
+ word = escaping_word_at(node)
247
+ return word if word && !(in_loop && LOOP_WORDS.include?(word))
248
+
249
+ in_loop ||= LOOP_NODES.include?(node.first)
250
+ node.each do |child|
251
+ found = escaping_word(child, in_loop: in_loop)
252
+ return found if found
253
+ end
254
+ nil
255
+ end
256
+
257
+ def escaping_word_at(node)
258
+ case node.first
259
+ when :break, :next, :redo, :return then node.first.to_s
260
+ when :return0 then "return"
261
+ when :vcall then "it" if node.dig(1, 1) == "it"
262
+ when :var_ref then node.dig(1, 1) if node.dig(1, 1).to_s.match?(/\A_[1-9]\z/)
263
+ end
264
+ end
265
+
266
+ # Whether the template before a `<Suspense>` makes `it` a local variable —
267
+ # a block parameter (`|it|`) or an assignment — in which case `it` between
268
+ # the tags is that variable, as Ruby would read it. (A partial's local
269
+ # named `it` is not seen here.)
270
+ def it_is_a_local?(before)
271
+ before.to_enum(:scan, ERB_CODE_RE).any? do
272
+ code = ::Regexp.last_match(2)
273
+ code.match?(/\|[^|]*\bit\b[^|]*\|/) || code.match?(/\bit\s*(?:=(?![=~>])|\|\|=|&&=)/)
274
+ end
275
+ end
276
+
277
+ def check_literal_attributes!(source, tag)
278
+ return unless tag[:attrs].include?("<%") || tag[:attrs].include?("%>")
279
+
280
+ raise PreprocessorError,
281
+ "ruact: <Suspense> at #{location(line_at(source, tag[:from]))} — `fallback` and `delay` take literal " \
282
+ "text, not ERB."
283
+ end
284
+
285
+ def rewrite_suspense_tag(tag, line_breaks)
286
+ breaks = "\n" * line_breaks
287
+ return %(<% end #{breaks}%><%= "" %>) if tag[:kind] == :close
288
+
289
+ fallback = extract_string_attr(tag[:attrs], "fallback") || ""
290
+ delay = extract_string_attr(tag[:attrs], "delay")
291
+ opening = %(<%= __ruact_suspense__(#{fallback.inspect}, #{delay.inspect}) do #{breaks}%>)
292
+ tag[:kind] == :empty ? opening + SUSPENSE_BLOCK_CLOSE : opening
293
+ end
294
+
116
295
  # The common path: no PascalCase closing tag anywhere, so every component
117
296
  # tag should be self-closing. One gsub, no masking copies — the render
118
297
  # benchmark's allocation profile depends on it. An opening tag that is not
@@ -188,7 +367,7 @@ module Ruact
188
367
  end
189
368
 
190
369
  def rewrite_tag(source, tag, paired)
191
- return "</#{WRAPPER_TAG}>" if tag[:closing]
370
+ return Markers::WRAPPER_CLOSE if tag[:closing]
192
371
 
193
372
  kind = if !paired.key?(tag[:from]) then :self_closing
194
373
  elsif paired[tag[:from]] then :with_children
@@ -220,7 +399,13 @@ module Ruact
220
399
  props_ruby = pairs.map { |prop, expr| "#{prop.inspect} => #{expr}" }.join(", ")
221
400
  props_hash = props_ruby.empty? ? "{}" : "{ #{props_ruby} }"
222
401
  helper = kind == :self_closing ? "__ruact_component__" : "__ruact_component_open__"
223
- %(<%= #{helper}(#{name.inspect}, #{props_hash}) %>)
402
+ # Story 19-2 — a tag written over several lines keeps them, so every
403
+ # line below it is still the template's own in an error and a backtrace
404
+ # (the props' own code carries the line breaks it has). Nothing is
405
+ # allocated for a tag on one line: the guard on allocations counts
406
+ # every component.
407
+ missing = match.count("\n") - props_hash.count("\n")
408
+ %(<%= #{helper}(#{name.inspect}, #{props_hash}) #{"\n" * missing if missing.positive?}%>)
224
409
  rescue ComponentContractError, ComponentTagError, UnknownComponentError
225
410
  # Already carry file:line and the fix — re-raise AS-IS (do NOT append the
226
411
  # generic "at line N: snippet" tail).
@@ -248,6 +433,8 @@ module Ruact
248
433
  # allocation.
249
434
  def inside_erb?(source, offset)
250
435
  opened = source.rindex("<%", offset)
436
+ # `<%%` is a literal `<%`, not an island: look further back.
437
+ opened = opened.positive? ? source.rindex("<%", opened - 1) : nil while opened && source[opened + 2] == "%"
251
438
  return false unless opened
252
439
 
253
440
  closed = source.index("%>", opened + 2)
@@ -16,17 +16,37 @@ module Ruact
16
16
 
17
17
  # Represents a React Suspense boundary.
18
18
  # `fallback` is a ReactElement shown while the deferred content is loading.
19
- # `children` is the actual content (emitted as a deferred row after `delay` seconds).
19
+ # `children` is the content, emitted as a deferred row after the shell: an
20
+ # element tree, or a {DeferredContent} rendered when its row is due.
21
+ # `delay` is the wait, in seconds, before that row when the response
22
+ # streams; nil waits nothing.
20
23
  class SuspenseElement
21
24
  attr_reader :fallback, :children, :delay
22
25
 
23
- def initialize(fallback:, children:, delay: 1.5)
26
+ def initialize(fallback:, children:, delay: nil)
24
27
  @fallback = fallback
25
28
  @children = children
26
29
  @delay = delay
27
30
  end
28
31
  end
29
32
 
33
+ # The content of a Suspense boundary that has not been rendered yet: the
34
+ # key 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
+ # Story 19-2 — what a boundary says when its marker came out of a `cache`
39
+ # block another render wrote: its content was never in the fragment.
40
+ STALE = "A <Suspense> inside `cache` was read from the cache, and its content is not in the " \
41
+ "cached fragment: put the <Suspense> outside the `cache` block (cache what is inside it)."
42
+
43
+ attr_reader :key
44
+
45
+ def initialize(key)
46
+ @key = key
47
+ end
48
+ end
49
+
30
50
  # Points to a "use client" module — will become an I row + $L<id> reference.
31
51
  class ClientReference
32
52
  attr_reader :module_id, :export_name