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
|
@@ -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
|
-
|
|
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
|
-
# <!--
|
|
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
|
-
|
|
57
|
+
# `<%%` is ERB's escape for a literal `<%`: text, not an island.
|
|
58
|
+
ERB_ISLAND_RE = /<%(?!%).*?%>/m
|
|
52
59
|
|
|
53
60
|
# An ERB comment, which renders nothing.
|
|
54
61
|
ERB_COMMENT_RE = /<%#.*?%>/m
|
|
55
62
|
|
|
56
|
-
#
|
|
57
|
-
#
|
|
58
|
-
|
|
63
|
+
# What ends a `<Suspense>`: the {ViewHelper#__ruact_suspense__} block its
|
|
64
|
+
# content is the body of. The empty output tag after it keeps Rails' ERB
|
|
65
|
+
# from trimming a `</Suspense>` that sits alone on its line, which would eat
|
|
66
|
+
# the line break after it (text the page shows).
|
|
67
|
+
SUSPENSE_BLOCK_CLOSE = %(<% end %><%= "" %>)
|
|
68
|
+
|
|
69
|
+
# Ripper node types inside which `break`, `next`, `redo`, `return`, `it`
|
|
70
|
+
# and `_1` belong to a block of their own, not to the Suspense's block.
|
|
71
|
+
OWN_BLOCK_NODES = %i[brace_block do_block lambda def defs class module sclass].freeze
|
|
72
|
+
# Loops give `break`, `next` and `redo` a scope of their own, but not `it`
|
|
73
|
+
# or `_1`, which still name the enclosing block's parameter.
|
|
74
|
+
LOOP_NODES = %i[while until while_mod until_mod for].freeze
|
|
75
|
+
LOOP_WORDS = %w[break next redo].freeze
|
|
76
|
+
|
|
77
|
+
# One ERB tag's flag (`=`, `==`, `-`, or `#` for a comment) and its Ruby code.
|
|
78
|
+
ERB_CODE_RE = /<%(?!%)(==|=|-|#)?(.*?)-?%>/m
|
|
59
79
|
|
|
60
80
|
# What each helper the transformed template calls leaves in the HTML, by
|
|
61
81
|
# its render-context token: a component, or the opening of one with
|
|
62
|
-
# children. {RenderPipeline} installs these for a plain-ERB
|
|
63
|
-
# ViewHelper writes the same
|
|
82
|
+
# children ({Markers}). {RenderPipeline} installs these for a plain-ERB
|
|
83
|
+
# render; ViewHelper writes the same markers in ActionView.
|
|
64
84
|
PLACEHOLDERS = {
|
|
65
|
-
__ruact_component__: ->(token) {
|
|
66
|
-
__ruact_component_open__: ->(token) {
|
|
85
|
+
__ruact_component__: ->(token) { Markers.component(token) },
|
|
86
|
+
__ruact_component_open__: ->(token) { Markers.component_open(token) }
|
|
67
87
|
}.freeze
|
|
68
88
|
|
|
69
89
|
# Transform ERB source, replacing component tags with ERB placeholders.
|
|
@@ -90,29 +110,188 @@ module Ruact
|
|
|
90
110
|
# Step 1: component tags → ERB placeholders. Story 18-2: a tag with a
|
|
91
111
|
# matching closing tag is a component WITH children; its content stays
|
|
92
112
|
# ERB, evaluated in place, inside a wrapper element.
|
|
113
|
+
# Suspense pairs are checked on the template as written, so an error names
|
|
114
|
+
# the template's own line.
|
|
115
|
+
check_suspense!(source) if source.include?("Suspense")
|
|
116
|
+
|
|
93
117
|
result = if source.match?(CLOSING_TAG_PROBE_RE)
|
|
94
118
|
rewrite_paired(source)
|
|
95
119
|
else
|
|
96
120
|
rewrite_self_closing(source)
|
|
97
121
|
end
|
|
98
122
|
|
|
99
|
-
|
|
100
|
-
result
|
|
101
|
-
.gsub(SUSPENSE_OPEN_RE) do
|
|
102
|
-
attrs = ::Regexp.last_match(1)
|
|
103
|
-
fallback = extract_string_attr(attrs, "fallback") || ""
|
|
104
|
-
escaped = fallback.gsub('"', """)
|
|
105
|
-
# Optional `delay="2.5"` — the server-side wait (seconds) before
|
|
106
|
-
# the deferred chunk streams. Forwarded to SuspenseElement#delay.
|
|
107
|
-
delay = extract_string_attr(attrs, "delay")
|
|
108
|
-
delay_attr = delay ? %( data-ruact-delay="#{delay.gsub('"', '"')}") : ""
|
|
109
|
-
%(<ruact-suspense data-ruact-fallback="#{escaped}"#{delay_attr}>)
|
|
110
|
-
end
|
|
111
|
-
.gsub(SUSPENSE_CLOSE_RE, "</ruact-suspense>")
|
|
123
|
+
rewrite_suspense(result)
|
|
112
124
|
end
|
|
113
125
|
|
|
114
126
|
private
|
|
115
127
|
|
|
128
|
+
# Step 2: `<Suspense>` pairs. Each pair becomes a
|
|
129
|
+
# {ViewHelper#__ruact_suspense__} call whose block is what sits between the
|
|
130
|
+
# tags; the helper writes the Suspense marker around it ({Markers}), and
|
|
131
|
+
# decides when the block is evaluated. `delay="2.5"` is optional: the
|
|
132
|
+
# server-side wait (seconds) before the deferred chunk streams, forwarded to
|
|
133
|
+
# SuspenseElement#delay. `<Suspense ... />` is a pair with nothing in it. A
|
|
134
|
+
# tag inside an ERB island is Ruby text and is left alone. The generated ERB
|
|
135
|
+
# keeps the tag's line breaks, so later lines keep their numbers.
|
|
136
|
+
def rewrite_suspense(source)
|
|
137
|
+
return source unless source.include?("Suspense")
|
|
138
|
+
|
|
139
|
+
out = +""
|
|
140
|
+
cursor = 0
|
|
141
|
+
suspense_tags(source).each do |tag|
|
|
142
|
+
out << source[cursor...tag[:from]]
|
|
143
|
+
out << rewrite_suspense_tag(tag, source[tag[:from]...tag[:to]].count("\n"))
|
|
144
|
+
cursor = tag[:to]
|
|
145
|
+
end
|
|
146
|
+
out << source[cursor..]
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# Every Suspense tag outside an ERB island, in order.
|
|
150
|
+
def suspense_tags(source)
|
|
151
|
+
tags = []
|
|
152
|
+
mask_erb(source).scan(SUSPENSE_TAG_RE) do
|
|
153
|
+
m = ::Regexp.last_match
|
|
154
|
+
kind = if m[1] then :close
|
|
155
|
+
elsif m[3] then :empty
|
|
156
|
+
else :open
|
|
157
|
+
end
|
|
158
|
+
tags << { kind: kind, from: m.begin(0), to: m.end(0), attrs: source[m.begin(2)...m.end(2)] }
|
|
159
|
+
end
|
|
160
|
+
tags
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# Every `<Suspense>` has its `</Suspense>` (as a component tag does, Story
|
|
164
|
+
# 18-2), in the same ERB block — the helper call it becomes opens a Ruby
|
|
165
|
+
# block that the closing tag ends — and takes literal attribute values.
|
|
166
|
+
# Raises {ComponentTagError} / {PreprocessorError} with the line.
|
|
167
|
+
def check_suspense!(source)
|
|
168
|
+
open = []
|
|
169
|
+
suspense_tags(source).each do |tag|
|
|
170
|
+
case tag[:kind]
|
|
171
|
+
when :open then open << tag
|
|
172
|
+
when :close
|
|
173
|
+
opening = open.pop
|
|
174
|
+
raise_stray("Suspense", line_at(source, tag[:from])) if opening.nil?
|
|
175
|
+
check_same_block!(source, opening, tag)
|
|
176
|
+
end
|
|
177
|
+
check_literal_attributes!(source, tag) unless tag[:kind] == :close
|
|
178
|
+
end
|
|
179
|
+
return if open.empty?
|
|
180
|
+
|
|
181
|
+
raise ComponentTagError,
|
|
182
|
+
"ruact: <Suspense> at #{location(line_at(source, open.first[:from]))} is never closed — close it with " \
|
|
183
|
+
"`</Suspense>`."
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
# The Ruby of the ERB tags between a pair has to stand on its own: an `if`
|
|
187
|
+
# or a block opened or closed between them would take the pair's `end`.
|
|
188
|
+
# Parsed inside parentheses, which no `end` in it can close, within a
|
|
189
|
+
# method and a block, where `yield`, `next` and `return` are Ruby. When the
|
|
190
|
+
# template's Ruby does not parse at all, Rails reports that, with its line.
|
|
191
|
+
#
|
|
192
|
+
# The content is then the body of a block, so `break`, `next`, `return`,
|
|
193
|
+
# `it` and `_1` written at its own level would apply to that block, not to
|
|
194
|
+
# the loop or block around the Suspense: refused, with what to do instead.
|
|
195
|
+
def check_same_block!(source, opening, closing)
|
|
196
|
+
tree = parse_erb_code(source[opening[:to]...closing[:from]])
|
|
197
|
+
if tree.nil?
|
|
198
|
+
return if parse_erb_code(source).nil?
|
|
199
|
+
|
|
200
|
+
raise ComponentTagError,
|
|
201
|
+
"ruact: <Suspense> at #{location(line_at(source, opening[:from]))} and its </Suspense> at line " \
|
|
202
|
+
"#{line_at(source, closing[:from])} are not in the same ERB block — an `if`, a loop or a block opens " \
|
|
203
|
+
"or closes between them. Put both tags inside that block, or both outside it."
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
word = escaping_word(first_node(tree, :paren))
|
|
207
|
+
return unless word
|
|
208
|
+
return if word == "it" && it_is_a_local?(source[0...opening[:from]])
|
|
209
|
+
|
|
210
|
+
advice = word.start_with?("_") || word == "it" ? "Name the block's parameter." : "Move it outside the Suspense."
|
|
211
|
+
raise ComponentTagError,
|
|
212
|
+
"ruact: <Suspense> at #{location(line_at(source, opening[:from]))} — `#{word}` between its tags would " \
|
|
213
|
+
"apply to the Suspense's own block, not to the loop or block around it. #{advice}"
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
# The Ripper tree of the Ruby in +erb+'s tags (comments left out), as the
|
|
217
|
+
# body of a block in a method, in parentheses; nil when it does not parse.
|
|
218
|
+
def parse_erb_code(erb)
|
|
219
|
+
code = erb.to_enum(:scan, ERB_CODE_RE).filter_map do
|
|
220
|
+
m = ::Regexp.last_match
|
|
221
|
+
m[2] unless m[1] == "#"
|
|
222
|
+
end
|
|
223
|
+
Ripper.sexp("def __ruact_check\nproc do\n(\n#{code.join("\n")}\n)\nend\nend")
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
# The first node of +type+ in +tree+, depth first: for {#parse_erb_code}'s
|
|
227
|
+
# tree, the parentheses the template's code sits in.
|
|
228
|
+
def first_node(tree, type)
|
|
229
|
+
return nil unless tree.is_a?(Array)
|
|
230
|
+
return tree if tree.first == type
|
|
231
|
+
|
|
232
|
+
tree.each do |child|
|
|
233
|
+
found = first_node(child, type)
|
|
234
|
+
return found if found
|
|
235
|
+
end
|
|
236
|
+
nil
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
# The first `break`, `next`, `redo`, `return`, implicit `it` or numbered
|
|
240
|
+
# parameter that would apply to the Suspense's block, if any. +in_loop+:
|
|
241
|
+
# inside a loop of the content's own, where only `it` and `_1` still do.
|
|
242
|
+
def escaping_word(node, in_loop: false)
|
|
243
|
+
return nil unless node.is_a?(Array)
|
|
244
|
+
return nil if OWN_BLOCK_NODES.include?(node.first)
|
|
245
|
+
|
|
246
|
+
word = escaping_word_at(node)
|
|
247
|
+
return word if word && !(in_loop && LOOP_WORDS.include?(word))
|
|
248
|
+
|
|
249
|
+
in_loop ||= LOOP_NODES.include?(node.first)
|
|
250
|
+
node.each do |child|
|
|
251
|
+
found = escaping_word(child, in_loop: in_loop)
|
|
252
|
+
return found if found
|
|
253
|
+
end
|
|
254
|
+
nil
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
def escaping_word_at(node)
|
|
258
|
+
case node.first
|
|
259
|
+
when :break, :next, :redo, :return then node.first.to_s
|
|
260
|
+
when :return0 then "return"
|
|
261
|
+
when :vcall then "it" if node.dig(1, 1) == "it"
|
|
262
|
+
when :var_ref then node.dig(1, 1) if node.dig(1, 1).to_s.match?(/\A_[1-9]\z/)
|
|
263
|
+
end
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
# Whether the template before a `<Suspense>` makes `it` a local variable —
|
|
267
|
+
# a block parameter (`|it|`) or an assignment — in which case `it` between
|
|
268
|
+
# the tags is that variable, as Ruby would read it. (A partial's local
|
|
269
|
+
# named `it` is not seen here.)
|
|
270
|
+
def it_is_a_local?(before)
|
|
271
|
+
before.to_enum(:scan, ERB_CODE_RE).any? do
|
|
272
|
+
code = ::Regexp.last_match(2)
|
|
273
|
+
code.match?(/\|[^|]*\bit\b[^|]*\|/) || code.match?(/\bit\s*(?:=(?![=~>])|\|\|=|&&=)/)
|
|
274
|
+
end
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
def check_literal_attributes!(source, tag)
|
|
278
|
+
return unless tag[:attrs].include?("<%") || tag[:attrs].include?("%>")
|
|
279
|
+
|
|
280
|
+
raise PreprocessorError,
|
|
281
|
+
"ruact: <Suspense> at #{location(line_at(source, tag[:from]))} — `fallback` and `delay` take literal " \
|
|
282
|
+
"text, not ERB."
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
def rewrite_suspense_tag(tag, line_breaks)
|
|
286
|
+
breaks = "\n" * line_breaks
|
|
287
|
+
return %(<% end #{breaks}%><%= "" %>) if tag[:kind] == :close
|
|
288
|
+
|
|
289
|
+
fallback = extract_string_attr(tag[:attrs], "fallback") || ""
|
|
290
|
+
delay = extract_string_attr(tag[:attrs], "delay")
|
|
291
|
+
opening = %(<%= __ruact_suspense__(#{fallback.inspect}, #{delay.inspect}) do #{breaks}%>)
|
|
292
|
+
tag[:kind] == :empty ? opening + SUSPENSE_BLOCK_CLOSE : opening
|
|
293
|
+
end
|
|
294
|
+
|
|
116
295
|
# The common path: no PascalCase closing tag anywhere, so every component
|
|
117
296
|
# tag should be self-closing. One gsub, no masking copies — the render
|
|
118
297
|
# benchmark's allocation profile depends on it. An opening tag that is not
|
|
@@ -188,7 +367,7 @@ module Ruact
|
|
|
188
367
|
end
|
|
189
368
|
|
|
190
369
|
def rewrite_tag(source, tag, paired)
|
|
191
|
-
return
|
|
370
|
+
return Markers::WRAPPER_CLOSE if tag[:closing]
|
|
192
371
|
|
|
193
372
|
kind = if !paired.key?(tag[:from]) then :self_closing
|
|
194
373
|
elsif paired[tag[:from]] then :with_children
|
|
@@ -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
|
-
|
|
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
|
|
19
|
+
# `children` is the content, emitted as a deferred row after the shell: an
|
|
20
|
+
# element tree, or a {DeferredContent} rendered when its row is due.
|
|
21
|
+
# `delay` is the wait, in seconds, before that row when the response
|
|
22
|
+
# streams; nil waits nothing.
|
|
20
23
|
class SuspenseElement
|
|
21
24
|
attr_reader :fallback, :children, :delay
|
|
22
25
|
|
|
23
|
-
def initialize(fallback:, children:, delay:
|
|
26
|
+
def initialize(fallback:, children:, delay: nil)
|
|
24
27
|
@fallback = fallback
|
|
25
28
|
@children = children
|
|
26
29
|
@delay = delay
|
|
27
30
|
end
|
|
28
31
|
end
|
|
29
32
|
|
|
33
|
+
# The content of a Suspense boundary that has not been rendered yet: the
|
|
34
|
+
# 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
|