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,106 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ruact
|
|
4
|
+
# Story 19-2 (AC14) — when a `<Suspense>` runs past `suspense_timeout` with a
|
|
5
|
+
# query in flight, that query is cancelled on the database server.
|
|
6
|
+
#
|
|
7
|
+
# `Timeout` gives the block back at the limit, but a query the driver is
|
|
8
|
+
# waiting on keeps running on the server, and the connection stays busy with
|
|
9
|
+
# it: the next query on it — this response's, or the next request's once the
|
|
10
|
+
# connection is back in the pool — waits for it to end (measured with
|
|
11
|
+
# PostgreSQL: seconds). Inside a transaction even the unwinding waits, at its
|
|
12
|
+
# ROLLBACK. So at the limit the queries in flight on the connections the
|
|
13
|
+
# block's thread holds — or that it already gave back — are cancelled, from
|
|
14
|
+
# another thread if the block is stuck; the connection is free again within
|
|
15
|
+
# milliseconds.
|
|
16
|
+
#
|
|
17
|
+
# PostgreSQL only: `PG::Connection#cancel` is libpq's PQcancel, made to be
|
|
18
|
+
# called from another thread. Other adapters are left as they are; the
|
|
19
|
+
# database's own statement timeout is the limit there.
|
|
20
|
+
#
|
|
21
|
+
# Not cancelled: a query `load_async` runs. The async executor's thread holds
|
|
22
|
+
# its connection, not the block's, and nothing ties that query to the block
|
|
23
|
+
# that waits for it; it runs to its end. The statement timeout is the limit
|
|
24
|
+
# for those.
|
|
25
|
+
#
|
|
26
|
+
# Internal API: not part of the public compatibility contract.
|
|
27
|
+
class QueryWatch
|
|
28
|
+
# Who holds a connection the way Active Record records it: the thread, or
|
|
29
|
+
# the fiber under `isolation_level = :fiber`.
|
|
30
|
+
def self.current_owner
|
|
31
|
+
::ActiveSupport::IsolatedExecutionState.context
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Watches +owner+'s connections for +limit+ seconds; nil when there is
|
|
35
|
+
# nothing to watch (no Active Record).
|
|
36
|
+
def self.start(owner, limit)
|
|
37
|
+
return nil unless defined?(::ActiveRecord::Base) && defined?(::PG::Connection)
|
|
38
|
+
|
|
39
|
+
new(owner, limit)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def initialize(owner, limit)
|
|
43
|
+
@lock = Mutex.new
|
|
44
|
+
@done = false
|
|
45
|
+
@cancelled = false
|
|
46
|
+
@watcher = Thread.new do
|
|
47
|
+
sleep(limit)
|
|
48
|
+
@lock.synchronize { cancel_queries_of(owner) unless @done }
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# The block ran past the limit and was given back: cancel what its owner
|
|
53
|
+
# still has in flight now, whether or not the watcher got there first.
|
|
54
|
+
def cancel_now(owner)
|
|
55
|
+
@lock.synchronize { cancel_queries_of(owner) }
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Whether a query was cancelled: the error the block then raises
|
|
59
|
+
# (`ActiveRecord::QueryCanceled`) is the timeout's, not the content's. The
|
|
60
|
+
# watcher can get there before `Timeout` does.
|
|
61
|
+
def cancelled?
|
|
62
|
+
@cancelled
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# The block ended (or was ended): nothing to cancel any more. Does not wait
|
|
66
|
+
# for a cancel under way, which opens a connection to the database.
|
|
67
|
+
def stop
|
|
68
|
+
@done = true
|
|
69
|
+
@watcher.kill
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
private
|
|
73
|
+
|
|
74
|
+
# The queries are found under each pool's lock and cancelled outside it: a
|
|
75
|
+
# cancel opens a connection to the database, and the pool's lock is every
|
|
76
|
+
# thread's checkout. A connection nobody holds could be handed to another
|
|
77
|
+
# request in between and have that request's query cancelled; the window
|
|
78
|
+
# is the time a cancel takes to reach the server, on the timeout path only.
|
|
79
|
+
def cancel_queries_of(owner)
|
|
80
|
+
in_flight(owner).each do |raw|
|
|
81
|
+
raw.cancel
|
|
82
|
+
@cancelled = true
|
|
83
|
+
rescue StandardError
|
|
84
|
+
nil
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def in_flight(owner)
|
|
89
|
+
::ActiveRecord::Base.connection_handler.connection_pool_list(:all).flat_map do |pool|
|
|
90
|
+
pool.synchronize { pool.connections.filter_map { |connection| query_of(connection, owner) } }
|
|
91
|
+
rescue StandardError
|
|
92
|
+
[]
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# The owner's own connection, or one already back in the pool: unwinding a
|
|
97
|
+
# `with_connection` gives the connection back with its query still running.
|
|
98
|
+
# A query in flight on a connection nobody holds is one nobody waits for.
|
|
99
|
+
def query_of(connection, owner)
|
|
100
|
+
return unless connection.owner.nil? || connection.owner.equal?(owner)
|
|
101
|
+
|
|
102
|
+
raw = connection.instance_variable_get(:@raw_connection)
|
|
103
|
+
raw if raw.is_a?(::PG::Connection) && raw.transaction_status == ::PG::PQTRANS_ACTIVE
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
data/lib/ruact/railtie.rb
CHANGED
|
@@ -46,6 +46,18 @@ module Ruact
|
|
|
46
46
|
app.config.middleware.use Ruact::NavigationBoundary::Middleware
|
|
47
47
|
end
|
|
48
48
|
|
|
49
|
+
# Story 19-1 (ADR D1) — in development, a `load_async` that cannot run in
|
|
50
|
+
# the background says so (Ruact::LoadAsyncNote). Nothing in production, and
|
|
51
|
+
# nothing at all without Active Record.
|
|
52
|
+
initializer "ruact.load_async_note" do
|
|
53
|
+
next unless Rails.env.development?
|
|
54
|
+
|
|
55
|
+
ActiveSupport.on_load(:active_record) do
|
|
56
|
+
require_relative "load_async_note"
|
|
57
|
+
ActiveRecord::Relation.prepend(Ruact::LoadAsyncNote::Relation)
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
49
61
|
rake_tasks { load File.expand_path("../tasks/ruact.rake", __dir__) }
|
|
50
62
|
|
|
51
63
|
# Load the client manifest at boot (and on each code reload in development).
|
data/lib/ruact/render_context.rb
CHANGED
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "securerandom"
|
|
4
|
+
|
|
3
5
|
module Ruact
|
|
4
6
|
# Per-render mutable state holding the components encountered during ERB
|
|
5
|
-
# evaluation
|
|
6
|
-
#
|
|
7
|
+
# evaluation, and — in an action that streams — the `<Suspense>` blocks whose
|
|
8
|
+
# content is rendered after the shell is sent. One instance is allocated by
|
|
9
|
+
# the controller per render, passed explicitly through the pipeline, and
|
|
10
|
+
# discarded when the response is sent.
|
|
7
11
|
#
|
|
8
12
|
# No shared state, no thread-local lookup — the instance lives only as long
|
|
9
13
|
# as the render call. Satisfies NFR8 and the `Ruact/NoSharedState` cop with
|
|
@@ -11,20 +15,83 @@ module Ruact
|
|
|
11
15
|
#
|
|
12
16
|
# Internal API: not part of the public compatibility contract.
|
|
13
17
|
class RenderContext
|
|
14
|
-
|
|
18
|
+
# A `<Suspense>` block waiting to be rendered: the view it was written in,
|
|
19
|
+
# the block, the template Rails was rendering when it met the tag
|
|
20
|
+
# (`t(".key")` and error messages read it), and the local variables the
|
|
21
|
+
# block can see, as they were then (Story 19-2: a `for` loop reuses one
|
|
22
|
+
# variable, a local can be reassigned below the tag).
|
|
23
|
+
Block = Struct.new(:view, :block, :virtual_path, :template, :location, :locals)
|
|
24
|
+
|
|
25
|
+
# @param deferring [Boolean] whether a `<Suspense>`'s content is rendered
|
|
26
|
+
# after the shell (an action that streams) instead of in place
|
|
27
|
+
def initialize(deferring: false)
|
|
28
|
+
# Story 19-2 — every marker this render writes carries it, so a marker a
|
|
29
|
+
# `cache` block stored in another render never passes for one of this
|
|
30
|
+
# render's: it would name another component, or another boundary's block.
|
|
31
|
+
@id = SecureRandom.hex(4)
|
|
15
32
|
@components = []
|
|
33
|
+
@deferring = deferring
|
|
34
|
+
@blocks = []
|
|
16
35
|
end
|
|
17
36
|
|
|
18
37
|
attr_reader :components
|
|
19
38
|
|
|
39
|
+
# Whether a `<Suspense>`'s content is rendered after the shell.
|
|
40
|
+
def deferring?
|
|
41
|
+
@deferring
|
|
42
|
+
end
|
|
43
|
+
|
|
20
44
|
def register(name, props)
|
|
21
|
-
|
|
45
|
+
# Frozen: the converter keys a Hash by it, which would copy an unfrozen one.
|
|
46
|
+
token = "__RUACT_#{@id}_#{@components.length}__".freeze
|
|
22
47
|
@components << { token: token, name: name, props: props }
|
|
23
48
|
token
|
|
24
49
|
end
|
|
25
50
|
|
|
26
|
-
|
|
27
|
-
|
|
51
|
+
# Keeps a `<Suspense>` block for later and returns its key.
|
|
52
|
+
#
|
|
53
|
+
# @param block [Block]
|
|
54
|
+
# @return [String]
|
|
55
|
+
def defer_block(block)
|
|
56
|
+
@blocks << block
|
|
57
|
+
"#{@id}-#{@blocks.length - 1}".freeze
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# The block registered under +key+, or nil when the key is not this
|
|
61
|
+
# render's — a marker that came out of a `cache` block another render wrote.
|
|
62
|
+
#
|
|
63
|
+
# @return [Block, nil]
|
|
64
|
+
def block(key)
|
|
65
|
+
id, index = key.to_s.split("-", 2)
|
|
66
|
+
return nil unless id == @id && index.to_s.match?(/\A\d+\z/)
|
|
67
|
+
|
|
68
|
+
@blocks[Integer(index, 10)]
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Whether the development message for a `<Suspense>` in an action that
|
|
72
|
+
# does not stream was given in this render: once per response.
|
|
73
|
+
def unstreamed_suspense_noted?
|
|
74
|
+
@unstreamed_suspense_noted == true
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def note_unstreamed_suspense!
|
|
78
|
+
@unstreamed_suspense_noted = true
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# The registered components with the client reference +manifest+ gives
|
|
82
|
+
# each, in the shape {HtmlConverter} reads:
|
|
83
|
+
# `[{ token:, name:, ref:, props: }, ...]`. Raises what the manifest raises
|
|
84
|
+
# for a component it does not have. +from+ skips the first components (the
|
|
85
|
+
# ones a previous call resolved).
|
|
86
|
+
def resolve(manifest, controller_path: nil, from: 0)
|
|
87
|
+
entries = from.zero? ? @components : @components.drop(from)
|
|
88
|
+
entries.map { |entry| resolve_entry(entry, manifest, controller_path: controller_path) }
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# One registered component, resolved as {#resolve} does.
|
|
92
|
+
def resolve_entry(entry, manifest, controller_path: nil)
|
|
93
|
+
ref = manifest.reference_for(entry[:name], controller_path: controller_path)
|
|
94
|
+
{ token: entry[:token], name: entry[:name], ref: ref, props: entry[:props] }
|
|
28
95
|
end
|
|
29
96
|
end
|
|
30
97
|
end
|
|
@@ -55,6 +55,8 @@ module Ruact
|
|
|
55
55
|
# - `:erb` or `:html` is not a `String`,
|
|
56
56
|
# - `input` contains extra keys beyond the documented shapes,
|
|
57
57
|
# - `mode` is not in {VALID_MODES}.
|
|
58
|
+
# - (`:erb` input) the template holds a `<Suspense>`: its content is a block only ActionView
|
|
59
|
+
# renders. Raised when the Enumerator is consumed.
|
|
58
60
|
# All `ArgumentError` messages name the offending input and reference
|
|
59
61
|
# `RenderPipeline#render` for the canonical contract.
|
|
60
62
|
#
|
|
@@ -64,7 +66,8 @@ module Ruact
|
|
|
64
66
|
#
|
|
65
67
|
# @example Pre-rendered HTML, streaming output
|
|
66
68
|
# ctx = Ruact::RenderContext.new
|
|
67
|
-
#
|
|
69
|
+
# html = Ruact::Markers.component(ctx.register("NavBar", {}))
|
|
70
|
+
# pipeline.render({ html: html, render_context: ctx }, mode: :stream)
|
|
68
71
|
# # => #<Enumerator: ...> (yields Flight rows lazily)
|
|
69
72
|
def render(input, mode: :string)
|
|
70
73
|
validate_mode!(mode)
|
|
@@ -72,6 +75,20 @@ module Ruact
|
|
|
72
75
|
mode == :string ? enum.to_a.join : enum
|
|
73
76
|
end
|
|
74
77
|
|
|
78
|
+
# What the render reads — for a {DeferredRender} built on it.
|
|
79
|
+
attr_reader :manifest, :controller_path, :logger, :development
|
|
80
|
+
|
|
81
|
+
# The warning for a value serialized through `as_json`, logged; nil without
|
|
82
|
+
# a logger.
|
|
83
|
+
def as_json_warning_callback
|
|
84
|
+
return nil if @logger.nil?
|
|
85
|
+
|
|
86
|
+
lambda do |class_name, attrs|
|
|
87
|
+
@logger.warn("[ruact] WARNING: #{class_name} serialized via as_json — ALL attributes exposed to client: " \
|
|
88
|
+
"#{attrs}. Use `include Ruact::Serializable` with `ruact_props` for explicit control")
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
75
92
|
private
|
|
76
93
|
|
|
77
94
|
ALLOWED_INPUT_KEYS = %i[erb binding html render_context].freeze
|
|
@@ -142,6 +159,16 @@ module Ruact
|
|
|
142
159
|
"ruact: render input :binding must be a Binding; got #{bnd.class} (#{DOCSTRING_REF})"
|
|
143
160
|
end
|
|
144
161
|
|
|
162
|
+
# A `<Suspense>`'s content is the body of a helper block, and only
|
|
163
|
+
# ActionView's `capture` renders one.
|
|
164
|
+
def refuse_suspense!(transformed)
|
|
165
|
+
return unless transformed.include?("__ruact_suspense__(")
|
|
166
|
+
|
|
167
|
+
raise ArgumentError,
|
|
168
|
+
"ruact: render input :erb cannot hold a <Suspense>: its content is a block, which only " \
|
|
169
|
+
"ActionView's `capture` can render. Render the template through ActionView (#{DOCSTRING_REF})"
|
|
170
|
+
end
|
|
171
|
+
|
|
145
172
|
def validate_html_shape!(input)
|
|
146
173
|
html = input[:html]
|
|
147
174
|
unless html.is_a?(String)
|
|
@@ -179,6 +206,7 @@ module Ruact
|
|
|
179
206
|
# so passing nil is byte-for-byte equivalent while avoiding any dev HTTP
|
|
180
207
|
# fetch / test-double dispatch inside callers that measure this path.
|
|
181
208
|
transformed = ErbPreprocessor.transform(erb_source, registry: nil)
|
|
209
|
+
refuse_suspense!(transformed)
|
|
182
210
|
receiver = binding_context.eval("self")
|
|
183
211
|
prev_ctx = receiver.instance_variable_get(:@__ruact_render_context__)
|
|
184
212
|
inject_helper!(binding_context, render_context)
|
|
@@ -189,11 +217,7 @@ module Ruact
|
|
|
189
217
|
receiver.instance_variable_set(:@__ruact_render_context__, prev_ctx)
|
|
190
218
|
end
|
|
191
219
|
|
|
192
|
-
registry = render_context.
|
|
193
|
-
ref = @manifest.reference_for(entry[:name], controller_path: @controller_path)
|
|
194
|
-
{ token: entry[:token], name: entry[:name], ref: ref, props: entry[:props] }
|
|
195
|
-
end
|
|
196
|
-
|
|
220
|
+
registry = render_context.resolve(@manifest, controller_path: @controller_path)
|
|
197
221
|
root_element = HtmlConverter.convert(html, registry)
|
|
198
222
|
|
|
199
223
|
Flight::Renderer.each(root_element, @manifest,
|
|
@@ -209,10 +233,8 @@ module Ruact
|
|
|
209
233
|
# the caller may discard the RenderContext immediately after `render` returns — the
|
|
210
234
|
# returned Enumerator does not reference the RenderContext.
|
|
211
235
|
def render_html_enum(html, render_context, streaming:)
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
{ token: entry[:token], name: entry[:name], ref: ref, props: entry[:props] }
|
|
215
|
-
end
|
|
236
|
+
CachedBlocks.report(html, @logger) unless render_context.deferring?
|
|
237
|
+
registry = render_context.resolve(@manifest, controller_path: @controller_path)
|
|
216
238
|
strict = Ruact.config.strict_serialization
|
|
217
239
|
warning_cb = as_json_warning_callback
|
|
218
240
|
|
|
@@ -226,18 +248,6 @@ module Ruact
|
|
|
226
248
|
end
|
|
227
249
|
end
|
|
228
250
|
|
|
229
|
-
def as_json_warning_callback
|
|
230
|
-
return nil if @logger.nil?
|
|
231
|
-
|
|
232
|
-
lambda do |class_name, attrs|
|
|
233
|
-
@logger.warn(
|
|
234
|
-
"[ruact] WARNING: #{class_name} serialized via as_json — " \
|
|
235
|
-
"ALL attributes exposed to client: #{attrs}. " \
|
|
236
|
-
"Use `include Ruact::Serializable` with `ruact_props` for explicit control"
|
|
237
|
-
)
|
|
238
|
-
end
|
|
239
|
-
end
|
|
240
|
-
|
|
241
251
|
# Install __ruact_component__ on the binding's receiver and stash the active
|
|
242
252
|
# render context on the receiver as @__ruact_render_context__. The singleton
|
|
243
253
|
# method reads the ivar dynamically, so nested renders sharing the same
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ruact
|
|
4
|
+
# Internal — how a request says what it wants from ruact. Two questions, with
|
|
5
|
+
# different answers on purpose:
|
|
6
|
+
#
|
|
7
|
+
# - {.flight?} — does the client want a Flight body back rather than an HTML
|
|
8
|
+
# document? Either header says so: `Accept: text/x-component` (any Flight
|
|
9
|
+
# client) or `Ruact-Request: 1` (ruact's router, which sends both).
|
|
10
|
+
# - {.from_router?} — did ruact's own router send it? Only `Ruact-Request: 1`
|
|
11
|
+
# says that; {NavigationBoundary} answers router navigations to pages that
|
|
12
|
+
# are not ruact's, and must not answer anyone else.
|
|
13
|
+
module RequestKind
|
|
14
|
+
# The Flight media type, as requested and as answered.
|
|
15
|
+
FLIGHT_MIME = "text/x-component"
|
|
16
|
+
# The header ruact's router adds to every request it makes.
|
|
17
|
+
ROUTER_HEADER = "Ruact-Request"
|
|
18
|
+
ROUTER_ENV = "HTTP_RUACT_REQUEST"
|
|
19
|
+
|
|
20
|
+
# @param request [ActionDispatch::Request]
|
|
21
|
+
def self.flight?(request)
|
|
22
|
+
request.headers["Accept"]&.include?(FLIGHT_MIME) || request.headers[ROUTER_HEADER] == "1"
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# @param env [Hash] the Rack env (it runs before Rails builds a request)
|
|
26
|
+
def self.from_router?(env)
|
|
27
|
+
env[ROUTER_ENV] == "1"
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Which header made {.flight?} true, as the request sent it — for a log
|
|
31
|
+
# line that names it.
|
|
32
|
+
def self.flight_signal(request)
|
|
33
|
+
request.headers["Accept"].to_s.include?(FLIGHT_MIME) ? "Accept: #{FLIGHT_MIME}" : "#{ROUTER_HEADER}: 1"
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
data/lib/ruact/server.rb
CHANGED
|
@@ -407,7 +407,7 @@ module Ruact
|
|
|
407
407
|
# Classify the negotiated response shape from the request signals
|
|
408
408
|
# (`__ruact_function_call?`, Accept, verb) and the ruact-owned outcome flags
|
|
409
409
|
# (`@__ruact_function_redirect` set by the Bucket-2 {#redirect_to};
|
|
410
|
-
# `@__ruact_negotiated_page` set by {Ruact::Controller#emit_ruact_response} /
|
|
410
|
+
# `@__ruact_negotiated_page` set by {Ruact::Controller::Delivery#emit_ruact_response} /
|
|
411
411
|
# its Flight `redirect_to`). Returns nil when ruact did not negotiate the
|
|
412
412
|
# response, so the log stays silent on plain Rails actions.
|
|
413
413
|
#
|
|
@@ -441,7 +441,7 @@ module Ruact
|
|
|
441
441
|
end
|
|
442
442
|
|
|
443
443
|
# The page-render (Bucket-1 / HTML-shell) shape, keyed on the flag
|
|
444
|
-
# {Ruact::Controller#emit_ruact_response} set when ruact rendered the page.
|
|
444
|
+
# {Ruact::Controller::Delivery#emit_ruact_response} set when ruact rendered the page.
|
|
445
445
|
# nil for a plain Rails render (the flag was never set) → the log stays
|
|
446
446
|
# silent on a non-negotiated response.
|
|
447
447
|
def __ruact_page_shape_and_signal
|
|
@@ -458,11 +458,7 @@ module Ruact
|
|
|
458
458
|
# The Flight deciding signal, faithful to how the request announced itself:
|
|
459
459
|
# the `Accept: text/x-component` header, or the `Ruact-Request: 1` header.
|
|
460
460
|
def __ruact_flight_signal
|
|
461
|
-
|
|
462
|
-
"Accept: text/x-component"
|
|
463
|
-
else
|
|
464
|
-
"Ruact-Request: 1"
|
|
465
|
-
end
|
|
461
|
+
RequestKind.flight_signal(request)
|
|
466
462
|
end
|
|
467
463
|
|
|
468
464
|
# @return [Boolean] true only in a real Rails development environment
|
|
@@ -15,7 +15,8 @@ module Ruact
|
|
|
15
15
|
#
|
|
16
16
|
# 1. a raw `text/x-component` body (RSC/navigation request), OR
|
|
17
17
|
# 2. a full HTML document embedding the payload in the `__FLIGHT_DATA`
|
|
18
|
-
# bootstrap `<script>` (a plain browser GET)
|
|
18
|
+
# bootstrap `<script>` (a plain browser GET) — and, in a page that
|
|
19
|
+
# streams, in the script each `<Suspense>`'s rows are pushed in.
|
|
19
20
|
#
|
|
20
21
|
# A `Ruact::Server` function-call/query answer is plain JSON, not Flight —
|
|
21
22
|
# feeding it here raises {NotAFlightResponseError} pointing at `JSON.parse`,
|
|
@@ -36,6 +37,16 @@ module Ruact
|
|
|
36
37
|
\)\s*;
|
|
37
38
|
/mx
|
|
38
39
|
|
|
40
|
+
# Story 19-1 — in a page that streams, each `<Suspense>`'s rows arrive
|
|
41
|
+
# later, in a script of their own pushed onto the same queue
|
|
42
|
+
# (`Ruact::Controller::Delivery#__ruact_flight_push`). Read after the
|
|
43
|
+
# bootstrap script, in document order.
|
|
44
|
+
STREAMED_PUSH = /
|
|
45
|
+
\(self\.__FLIGHT_DATA=self\.__FLIGHT_DATA\|\|\[\]\)\.push\(
|
|
46
|
+
(?<literal>"(?:\\.|[^"\\])*")
|
|
47
|
+
\)
|
|
48
|
+
/x
|
|
49
|
+
|
|
39
50
|
# A Flight wire body always starts with a row header: `<hex>:` (id + colon)
|
|
40
51
|
# or a hint row `:H`. JSON bodies start with `{`/`[`/`"`; HTML with `<`.
|
|
41
52
|
FLIGHT_ROW_START = /\A\s*(?:\h+:|:H)/
|
|
@@ -133,9 +144,15 @@ module Ruact
|
|
|
133
144
|
"This response did not render a ruact component (no Flight data was inlined)."
|
|
134
145
|
end
|
|
135
146
|
|
|
136
|
-
|
|
137
|
-
#
|
|
138
|
-
|
|
147
|
+
# The streamed pushes are JSON literals ({InlineScript}), written
|
|
148
|
+
# after the bootstrap script.
|
|
149
|
+
later = html[match.end(0)..].scan(STREAMED_PUSH).map { |(streamed)| JSON.parse(streamed) }
|
|
150
|
+
decode_literal(match[:literal]) + later.join
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# The literal is JSON (ruact >= 0.0.15). The Ruby-literal decoder stays
|
|
154
|
+
# for documents captured from earlier versions.
|
|
155
|
+
def decode_literal(literal)
|
|
139
156
|
JSON.parse(literal)
|
|
140
157
|
rescue JSON::ParserError
|
|
141
158
|
decode_ruby_string_literal(literal)
|
data/lib/ruact/version.rb
CHANGED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ruact
|
|
4
|
+
module ViewHelper
|
|
5
|
+
# The helper a `<Suspense>` becomes ({ErbPreprocessor}). Part of
|
|
6
|
+
# {ViewHelper}.
|
|
7
|
+
module Suspense
|
|
8
|
+
# A `<Suspense>`: the preprocessor makes the ERB between its tags this
|
|
9
|
+
# block's body, and passes the `fallback` and `delay` attributes as the
|
|
10
|
+
# template wrote them.
|
|
11
|
+
#
|
|
12
|
+
# In an action that streams (`ruact_streaming`), the block is kept, not
|
|
13
|
+
# run: the marker ({Markers}) carries its index, and the response renders
|
|
14
|
+
# it after the shell is sent ({DeferredRender}). Anywhere else the content
|
|
15
|
+
# is evaluated here, in place, so the page renders as one piece — and, in
|
|
16
|
+
# development, the log says the action could stream.
|
|
17
|
+
#
|
|
18
|
+
# Only what the block writes is the content: a value it ends on is not
|
|
19
|
+
# (`capture` would print a block's value when it writes nothing).
|
|
20
|
+
def __ruact_suspense__(fallback, delay = nil, &block)
|
|
21
|
+
ctx = @ruact_render_context
|
|
22
|
+
if ctx&.deferring?
|
|
23
|
+
index = ctx.defer_block(RenderContext::Block.new(self, block, @virtual_path, @current_template,
|
|
24
|
+
caller_locations(1, 1).first, __ruact_locals(block)))
|
|
25
|
+
return "#{Markers.suspense_open(fallback, delay, block: index)}#{Markers::SUSPENSE_CLOSE}".html_safe
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
__ruact_note_unstreamed_suspense(ctx, caller_locations(1, 1).first) if ctx
|
|
29
|
+
content = capture do
|
|
30
|
+
yield
|
|
31
|
+
nil
|
|
32
|
+
end
|
|
33
|
+
"#{Markers.suspense_open(fallback, delay)}#{content}#{Markers::SUSPENSE_CLOSE}".html_safe
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Story 19-2 (AC8.3) — the local variables +block+ sees, as they are when
|
|
37
|
+
# the tag is met. The block runs later; without these it would see a local
|
|
38
|
+
# as it is then — the last item of a `for` loop, a value reassigned below.
|
|
39
|
+
def __ruact_locals(block)
|
|
40
|
+
scope = block.binding
|
|
41
|
+
scope.local_variables.to_h { |name| [name, scope.local_variable_get(name)] }
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Story 19-1 — a `<Suspense>` in an action that does not stream renders its
|
|
45
|
+
# content before the page is sent, as it always did. Development says so,
|
|
46
|
+
# once per response, with the line that makes the action stream.
|
|
47
|
+
def __ruact_note_unstreamed_suspense(ctx, location)
|
|
48
|
+
return unless defined?(Rails) && Rails.env.development?
|
|
49
|
+
return if ctx.unstreamed_suspense_noted?
|
|
50
|
+
|
|
51
|
+
ctx.note_unstreamed_suspense!
|
|
52
|
+
|
|
53
|
+
owner = respond_to?(:controller) ? controller : nil
|
|
54
|
+
return unless owner.respond_to?(:action_name) && owner.respond_to?(:logger)
|
|
55
|
+
# Declared, and answering a request it cannot stream to (HTTP/1.0).
|
|
56
|
+
return if owner.class.respond_to?(:ruact_streaming?) && owner.class.ruact_streaming?(owner.action_name)
|
|
57
|
+
|
|
58
|
+
where = location ? " (#{location.path.delete_prefix("#{Rails.root}/")}:#{location.lineno})" : ""
|
|
59
|
+
owner.logger&.warn(
|
|
60
|
+
"[ruact] #{owner.class.name}##{owner.action_name} renders a <Suspense>#{where} in an action " \
|
|
61
|
+
"that does not stream, so its content ran before the page was sent. To send the page first " \
|
|
62
|
+
"and the content when it is ready, add `ruact_streaming only: :#{owner.action_name}` to " \
|
|
63
|
+
"#{owner.class.name}."
|
|
64
|
+
)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
private :__ruact_note_unstreamed_suspense, :__ruact_locals
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
data/lib/ruact/view_helper.rb
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
require "erb"
|
|
4
4
|
require "json"
|
|
5
5
|
require "socket"
|
|
6
|
+
require_relative "view_helper/suspense"
|
|
6
7
|
|
|
7
8
|
module Ruact
|
|
8
9
|
# ActionView helper module included in ActionView::Base via Railtie.
|
|
@@ -14,6 +15,9 @@ module Ruact
|
|
|
14
15
|
# +@ruact_render_context+ and copied to the view by Rails's view_assigns
|
|
15
16
|
# plumbing — see Story 7.9 / Bug 7.8-B) is per-request — no shared state.
|
|
16
17
|
module ViewHelper
|
|
18
|
+
# `<Suspense>`: the content in place, or kept for after the shell.
|
|
19
|
+
include Suspense
|
|
20
|
+
|
|
17
21
|
# Registers +name+ with +props+ in the per-render RenderContext (set by
|
|
18
22
|
# Ruact::Controller#ruact_render on the controller as +@ruact_render_context+;
|
|
19
23
|
# Rails copies it to the view via +_assigns_for_view_context+ because the
|
|
@@ -28,8 +32,7 @@ module Ruact
|
|
|
28
32
|
ctx = @ruact_render_context
|
|
29
33
|
raise Ruact::Error, __ruact_outside_render_message(name) if ctx.nil?
|
|
30
34
|
|
|
31
|
-
|
|
32
|
-
"<!-- #{token} -->".html_safe
|
|
35
|
+
Markers.component(ctx.register(name, props)).html_safe
|
|
33
36
|
end
|
|
34
37
|
|
|
35
38
|
# Story 18-2 — the opening of a component with children. Registers it like
|
|
@@ -41,8 +44,7 @@ module Ruact
|
|
|
41
44
|
ctx = @ruact_render_context
|
|
42
45
|
raise Ruact::Error, __ruact_outside_render_message(name) if ctx.nil?
|
|
43
46
|
|
|
44
|
-
|
|
45
|
-
%(<ruact-component data-ruact-token="#{token}">).html_safe
|
|
47
|
+
Markers.component_open(ctx.register(name, props)).html_safe
|
|
46
48
|
end
|
|
47
49
|
|
|
48
50
|
# Story 17.0i — a client component in a template Rails is rendering on its
|
|
@@ -228,66 +230,66 @@ module Ruact
|
|
|
228
230
|
|
|
229
231
|
# The `__FLIGHT_DATA` inline bootstrap `<script>` — pushes the per-render
|
|
230
232
|
# Flight payload onto the global queue the bootstrap entry drains on boot.
|
|
231
|
-
#
|
|
232
|
-
#
|
|
233
|
-
#
|
|
234
|
-
# escaped as `\uXXXX`. No byte of the payload can then close the `<script>`
|
|
235
|
-
# — `</SCRIPT>`, `</script >` and `<!--` included — and every character
|
|
236
|
-
# reaches JavaScript intact (a Ruby `String#inspect` literal did neither:
|
|
237
|
-
# it escaped only a lowercase `</script>`, and its `\e`-style escapes mean
|
|
238
|
-
# something else in JS).
|
|
239
|
-
SCRIPT_UNSAFE = /[<>&\u2028\u2029]/
|
|
240
|
-
private_constant :SCRIPT_UNSAFE
|
|
241
|
-
|
|
242
|
-
# A JSON string literal that is also safe inside a `<script>` element.
|
|
243
|
-
# The payload is UTF-8 by construction (the serializer normalizes every
|
|
244
|
-
# string); a binary-tagged buffer is retagged, not transcoded.
|
|
245
|
-
def __ruact_script_safe_json(string)
|
|
246
|
-
utf8 = string.encoding == Encoding::UTF_8 ? string : string.dup.force_encoding(Encoding::UTF_8)
|
|
247
|
-
JSON.generate(utf8).gsub(SCRIPT_UNSAFE) { |char| format("\\u%04x", char.ord) }
|
|
248
|
-
end
|
|
249
|
-
|
|
233
|
+
# The payload is written as {InlineScript.string_literal} writes it (a
|
|
234
|
+
# Ruby `String#inspect` literal, used before, escaped only a lowercase
|
|
235
|
+
# `</script>`, and its `\e`-style escapes mean something else in JS).
|
|
250
236
|
def ruact_flight_data_script(flight_payload)
|
|
251
237
|
<<~HTML.strip
|
|
252
|
-
<script>
|
|
238
|
+
<script#{__ruact_nonce_attr}>
|
|
253
239
|
(function() {
|
|
254
240
|
var d = (self.__FLIGHT_DATA = self.__FLIGHT_DATA || []);
|
|
255
|
-
d.push(#{
|
|
241
|
+
d.push(#{InlineScript.string_literal(flight_payload)});
|
|
256
242
|
})();
|
|
257
243
|
</script>
|
|
258
244
|
HTML
|
|
259
245
|
end
|
|
260
246
|
|
|
247
|
+
# Story 19-2 (AC21) — the request's CSP nonce as a `<script>` attribute, so
|
|
248
|
+
# an app whose policy allows scripts by nonce (no `'unsafe-inline'`) runs
|
|
249
|
+
# the ones ruact writes. Empty without a nonce generator: the bytes are
|
|
250
|
+
# those of before.
|
|
251
|
+
def __ruact_nonce_attr
|
|
252
|
+
source = respond_to?(:request, true) ? request : nil
|
|
253
|
+
nonce = source.content_security_policy_nonce if source.respond_to?(:content_security_policy_nonce)
|
|
254
|
+
nonce ? %( nonce="#{ERB::Util.html_escape(nonce)}") : ""
|
|
255
|
+
end
|
|
256
|
+
|
|
261
257
|
# The bootstrap entry `<script>` tags — dev serves the virtual module from
|
|
262
258
|
# the Vite dev server (with the react-refresh preamble + `@vite/client`);
|
|
263
259
|
# prod reads the hashed URL from the Vite manifest. Both target the SAME
|
|
264
260
|
# virtual entry id (`Ruact.bootstrap_virtual_id`) so dev/prod stay in lockstep.
|
|
261
|
+
#
|
|
262
|
+
# Story 19-1 — the entry is `async`: it runs as soon as it has loaded. A
|
|
263
|
+
# module script is otherwise deferred to the end of the document, and a
|
|
264
|
+
# page that streams ends after its last `<Suspense>` — its fallback would
|
|
265
|
+
# never show. The bootstrap then reads the rows the document pushes while
|
|
266
|
+
# it is still open.
|
|
265
267
|
def ruact_vite_tags
|
|
266
268
|
if Rails.env.development? && vite_dev_running?
|
|
267
269
|
# @vitejs/plugin-react normally injects this preamble by processing index.html.
|
|
268
270
|
# Since our HTML is generated by Rails (not Vite), we inject it manually.
|
|
269
271
|
# Without it, every JSX file throws "can't detect preamble" at runtime.
|
|
272
|
+
# One async module, so the preamble still runs before any component
|
|
273
|
+
# module: it installs the preamble, then imports the client and the
|
|
274
|
+
# bootstrap, in that order.
|
|
270
275
|
dev_server = Ruact.config.vite_dev_server.chomp("/")
|
|
271
|
-
|
|
272
|
-
<script type="module">
|
|
276
|
+
<<~HTML
|
|
277
|
+
<script type="module" async#{__ruact_nonce_attr}>
|
|
273
278
|
import RefreshRuntime from '#{dev_server}/@react-refresh';
|
|
274
279
|
RefreshRuntime.injectIntoGlobalHook(window);
|
|
275
280
|
window.$RefreshReg$ = () => {};
|
|
276
281
|
window.$RefreshSig$ = () => (type) => type;
|
|
277
282
|
window.__vite_plugin_react_preamble_installed__ = true;
|
|
283
|
+
await import('#{dev_server}/@vite/client');
|
|
284
|
+
await import('#{ruact_bootstrap_dev_url}');
|
|
278
285
|
</script>
|
|
279
|
-
JS
|
|
280
|
-
|
|
281
|
-
react_preamble + <<~HTML
|
|
282
|
-
<script type="module" src="#{dev_server}/@vite/client"></script>
|
|
283
|
-
<script type="module" src="#{ruact_bootstrap_dev_url}"></script>
|
|
284
286
|
HTML
|
|
285
287
|
else
|
|
286
288
|
# Production: read the hashed URL from the Vite manifest, keyed on the
|
|
287
289
|
# virtual entry id (the manifest key Vite emits for the virtual input).
|
|
288
290
|
entry = vite_manifest_entry(Ruact.bootstrap_virtual_id)
|
|
289
291
|
src = entry ? "/assets/#{entry['file']}" : "/assets/application.js"
|
|
290
|
-
tag = %(<script type="module" src="#{src}"></script>)
|
|
292
|
+
tag = %(<script type="module" async src="#{src}"#{__ruact_nonce_attr}></script>)
|
|
291
293
|
# Story 17-0k — in development with neither the Vite dev server nor a
|
|
292
294
|
# build, that script 404s and the page stays empty with no error.
|
|
293
295
|
Rails.env.development? && entry.nil? ? "#{ruact_vite_down_warning}\n#{tag}" : tag
|