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,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).
@@ -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. One instance is allocated by the controller per render, passed
6
- # explicitly through the pipeline, and discarded when the response is sent.
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
- def initialize
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
- token = "__RUACT_#{@components.length}__"
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
- def by_token(token)
27
- @components.find { |c| c[:token] == token }
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
- # pipeline.render({ html: "<!-- __RUACT_0__ -->", render_context: ctx }, mode: :stream)
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.components.map do |entry|
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
- registry = render_context.components.map do |entry|
213
- ref = @manifest.reference_for(entry[:name], controller_path: @controller_path)
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
- if request.headers["Accept"].to_s.include?("text/x-component")
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
- literal = match[:literal]
137
- # The literal is JSON (ruact >= 0.0.15). The Ruby-literal decoder stays
138
- # for documents captured from earlier versions.
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Ruact
4
- VERSION = "0.0.19"
4
+ VERSION = "0.0.21"
5
5
  end
@@ -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
@@ -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
- token = ctx.register(name, props)
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
- token = ctx.register(name, props)
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
- # The payload carries application data (props, record content), so it is
233
- # embedded as a JSON string literal with `<`, `>`, `&`, U+2028 and U+2029
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(#{__ruact_script_safe_json(flight_payload)});
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
- react_preamble = <<~JS
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