phlex-reactive 0.13.4 → 0.14.0

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 (65) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +1050 -630
  3. data/README.md +561 -14
  4. data/app/controllers/phlex/reactive/actions_controller.rb +85 -16
  5. data/app/controllers/phlex/reactive/fragments_controller.rb +310 -0
  6. data/app/javascript/phlex/reactive/core.js +181 -0
  7. data/app/javascript/phlex/reactive/core.min.js +4 -0
  8. data/app/javascript/phlex/reactive/core.min.js.map +11 -0
  9. data/app/javascript/phlex/reactive/early.js +175 -0
  10. data/app/javascript/phlex/reactive/early.min.js +4 -0
  11. data/app/javascript/phlex/reactive/early.min.js.map +10 -0
  12. data/app/javascript/phlex/reactive/features/bindings.js +1511 -0
  13. data/app/javascript/phlex/reactive/features/bindings.min.js +4 -0
  14. data/app/javascript/phlex/reactive/features/bindings.min.js.map +10 -0
  15. data/app/javascript/phlex/reactive/features/compute.js +541 -0
  16. data/app/javascript/phlex/reactive/features/compute.min.js +4 -0
  17. data/app/javascript/phlex/reactive/features/compute.min.js.map +10 -0
  18. data/app/javascript/phlex/reactive/features/defer.js +726 -0
  19. data/app/javascript/phlex/reactive/features/defer.min.js +4 -0
  20. data/app/javascript/phlex/reactive/features/defer.min.js.map +10 -0
  21. data/app/javascript/phlex/reactive/features/devtools.js +242 -0
  22. data/app/javascript/phlex/reactive/features/devtools.min.js +4 -0
  23. data/app/javascript/phlex/reactive/features/devtools.min.js.map +10 -0
  24. data/app/javascript/phlex/reactive/features/effects.js +362 -0
  25. data/app/javascript/phlex/reactive/features/effects.min.js +4 -0
  26. data/app/javascript/phlex/reactive/features/effects.min.js.map +10 -0
  27. data/app/javascript/phlex/reactive/features/form.js +216 -0
  28. data/app/javascript/phlex/reactive/features/form.min.js +4 -0
  29. data/app/javascript/phlex/reactive/features/form.min.js.map +10 -0
  30. data/app/javascript/phlex/reactive/features/hints.js +193 -0
  31. data/app/javascript/phlex/reactive/features/hints.min.js +4 -0
  32. data/app/javascript/phlex/reactive/features/hints.min.js.map +10 -0
  33. data/app/javascript/phlex/reactive/features/persist.js +663 -0
  34. data/app/javascript/phlex/reactive/features/persist.min.js +4 -0
  35. data/app/javascript/phlex/reactive/features/persist.min.js.map +10 -0
  36. data/app/javascript/phlex/reactive/reactive_controller.js +85 -5821
  37. data/app/javascript/phlex/reactive/reactive_controller.min.js +2 -2
  38. data/app/javascript/phlex/reactive/reactive_controller.min.js.map +13 -4
  39. data/app/javascript/phlex/reactive/runtime.js +3690 -0
  40. data/lib/generators/phlex/reactive/install/templates/phlex_reactive.rb.erb +18 -0
  41. data/lib/phlex/reactive/claude/skills/phlex-reactive-debugging/SKILL.md +5 -3
  42. data/lib/phlex/reactive/component/dsl.rb +74 -2
  43. data/lib/phlex/reactive/component/helpers.rb +39 -9
  44. data/lib/phlex/reactive/component/lazy.rb +138 -2
  45. data/lib/phlex/reactive/component/registry.rb +2 -0
  46. data/lib/phlex/reactive/component/selection.rb +71 -0
  47. data/lib/phlex/reactive/doctor/fiber_isolation.rb +86 -0
  48. data/lib/phlex/reactive/doctor.rb +166 -8
  49. data/lib/phlex/reactive/dormant.rb +66 -0
  50. data/lib/phlex/reactive/engine.rb +63 -3
  51. data/lib/phlex/reactive/fragment.rb +233 -0
  52. data/lib/phlex/reactive/inspector/report.rb +24 -3
  53. data/lib/phlex/reactive/inspector.rb +23 -2
  54. data/lib/phlex/reactive/js.rb +17 -2
  55. data/lib/phlex/reactive/mcp/tools/actions_tool.rb +3 -1
  56. data/lib/phlex/reactive/mcp/tools/find_tool.rb +4 -1
  57. data/lib/phlex/reactive/param_schema.rb +68 -54
  58. data/lib/phlex/reactive/show_conditions.rb +84 -56
  59. data/lib/phlex/reactive/streamable.rb +20 -2
  60. data/lib/phlex/reactive/test_helpers/system/reactive_requests_matcher.rb +120 -0
  61. data/lib/phlex/reactive/test_helpers/system.rb +62 -0
  62. data/lib/phlex/reactive/version.rb +1 -1
  63. data/lib/phlex/reactive.rb +84 -0
  64. data/lib/tasks/phlex_reactive.rake +6 -2
  65. metadata +38 -1
@@ -27,6 +27,8 @@ module Phlex
27
27
  wrap_parameters false if respond_to?(:wrap_parameters)
28
28
 
29
29
  def create
30
+ return materialize if reactive_action_name == Phlex::Reactive::Component::Lazy::MATERIALIZE_ACTION
31
+
30
32
  # ONE action.phlex_reactive event per request (issue #107). The event
31
33
  # payload carries the component/action NAMES + outcome ONLY (never the
32
34
  # token, params, or state); we fill it as those become known and set
@@ -48,7 +50,11 @@ module Phlex
48
50
  Phlex::Reactive.with_url_options(Phlex::Reactive.url_options_for(request)) do
49
51
  Phlex::Reactive.with_defer_binding(Phlex::Reactive.defer_binding_for(request)) do
50
52
  Phlex::Reactive.instrument("action", event) do
51
- create_action(event)
53
+ # The actor's reply renders dormant roots AWAKE (issue #274): this
54
+ # page's controller is loaded, so a dormant replacement would only
55
+ # cost one more wake. Broadcasts fired inside the action are
56
+ # exempted at their render, like the url_options above.
57
+ Phlex::Reactive::Dormant.awake { create_action(event) }
52
58
  end
53
59
  end
54
60
  end
@@ -84,22 +90,62 @@ module Phlex
84
90
 
85
91
  private
86
92
 
93
+ # `__materialize` (issue #276): a reactive_lazy(on:) shell's trigger. It
94
+ # arrives on the ACTION endpoint with the identity token (no TTL — the
95
+ # shell may sit in a page far longer than defer_token_ttl), but it is a
96
+ # READ: no action runs, no transaction opens, no params are read. It
97
+ # renders exactly what the defer endpoint renders, through the same
98
+ # authorization step, and is instrumented as a defer. Routed here before
99
+ # the action registry is consulted; `action :__materialize` is refused at
100
+ # declaration, so it can't be shadowed. CSRF/auth come from the base
101
+ # controller exactly as for any action.
102
+ def materialize
103
+ event = { component: nil, outcome: nil }
104
+ Phlex::Reactive.with_url_options(Phlex::Reactive.url_options_for(request)) do
105
+ Phlex::Reactive.instrument("defer", event) do
106
+ render_real_component(event, permit: :reactive_lazy_trigger, empty: :stream) { verified_payload }
107
+ end
108
+ end
109
+ end
110
+
87
111
  # The defer body, mirroring create_action's shape: fill the event payload
88
112
  # on every exit path, reuse the shared rescue → reactive_error plumbing.
89
113
  def deferred_action(event)
90
- payload = verified_defer_payload
114
+ render_real_component(event) { verified_defer_payload }
115
+ end
116
+
117
+ # The lazy/defer READ leg, shared by the defer endpoint and __materialize
118
+ # (and the seam for any other signed-identity → real-render route). The
119
+ # block verifies and returns the payload INSIDE this method's rescues, so
120
+ # every token failure maps the same way. `permit:` names a class-level
121
+ # predicate the component must answer truthy (default-deny for an opt-in
122
+ # route); `empty:` picks the render? false reply — :no_content for the
123
+ # defer client (it clears pending on 204), :stream (an empty turbo-stream)
124
+ # for the action client, which treats a non-stream body as an error.
125
+ # `denied:` is the status for a class that fails `permit:` — :forbidden for
126
+ # an undeclared act, :not_found for a GET route the class never opted into.
127
+ def render_real_component(event, permit: nil, empty: :no_content, denied: :forbidden)
128
+ payload = yield
91
129
  component_class = resolve_component(payload["c"])
92
130
  event[:component] = component_class.name
131
+
132
+ if permit && !(component_class.respond_to?(permit) && component_class.public_send(permit))
133
+ event[:outcome] = :denied_undeclared
134
+ return reactive_error(denied, unpermitted_render_message(component_class), kind: denied)
135
+ end
136
+
93
137
  component = component_class.from_identity(payload)
94
138
 
95
139
  if component.respond_to?(:render?) && !component.render?
96
140
  event[:outcome] = :no_content
97
- return head :no_content
141
+ return empty == :stream ? render(turbo_stream: "") : head(:no_content)
98
142
  end
99
143
 
100
144
  event[:outcome] = :ok
101
- stream = component.to_stream_replace(morph: payload["m"] == "morph")
102
- render turbo_stream: stream
145
+ # Awake (issue #274): the client that asked for this render is loaded
146
+ # and connected, so a dormant root would only cost one more wake.
147
+ stream = Phlex::Reactive::Dormant.awake { component.to_stream_replace(morph: payload["m"] == "morph") }
148
+ render_real_stream(stream, component)
103
149
  rescue Phlex::Reactive::InvalidToken => e
104
150
  event[:outcome] = :invalid_token
105
151
  reactive_error(:bad_request, e.message, kind: e.diagnostic || :tampered)
@@ -118,6 +164,12 @@ module Phlex
118
164
  raise
119
165
  end
120
166
 
167
+ # The read leg's success reply. A seam: the fragment endpoint (issue #277)
168
+ # overrides it to add its cache headers and conditional GET.
169
+ def render_real_stream(stream, _component)
170
+ render turbo_stream: stream
171
+ end
172
+
121
173
  def verified_defer_payload
122
174
  token = params.require(:token)
123
175
  Phlex::Reactive.verify_defer(token) || raise(Phlex::Reactive::InvalidToken.new(
@@ -127,6 +179,11 @@ module Phlex
127
179
  ))
128
180
  end
129
181
 
182
+ def unpermitted_render_message(component_class)
183
+ "#{component_class.name} is not reactive_lazy(on:) — __materialize is the lazy shell's " \
184
+ "trigger, not a declarable action"
185
+ end
186
+
130
187
  def deferred_authorization_message(error, component_class)
131
188
  "#{error.class.name} raised rebuilding/rendering #{component_class&.name} for a deferred " \
132
189
  "segment — the signature proves identity, not permission"
@@ -563,22 +620,32 @@ module Phlex
563
620
  coerced
564
621
  end
565
622
 
566
- # Issue #184: a scoped component's fields POST bracketed (todo[title]), which
567
- # Rails expands to { "todo" => { "title" => … } } before the action runs. Peel
623
+ # Issue #184: a scoped component's fields POST bracketed (todo[title]); peel
568
624
  # exactly ONE scope level so the FLAT schema { title: :string } matches (the
569
625
  # #67 bracket-drop footgun). Only when the component declares reactive_scope
570
- # AND the raw params carry that single key mapping to a Hash — otherwise the
571
- # raw params pass through untouched (unscoped components + nested_attributes
572
- # shapes are unaffected).
626
+ # AND the scope key maps to a Hash — otherwise the params pass through
627
+ # (unscoped components + nested_attributes shapes are unaffected).
628
+ #
629
+ # Issue #337: expand the bracketed keys FIRST. A multipart body arrives
630
+ # expanded (Rails parses the brackets), but the client's default JSON body
631
+ # keeps "todo[title]" as one literal key, so a peel that looked for "todo"
632
+ # before expanding found nothing and the flat schema dropped every field.
633
+ #
634
+ # Bare siblings of the scope key (a trigger's on(:save, note: "x") param,
635
+ # an unscoped file input) stay beside the peeled fields on BOTH encodings;
636
+ # a bare param wins a collision, as an explicit on() param wins over a
637
+ # collected field on the client (runtime.js allParams). Still only the declared schema reaches
638
+ # the action — the peel moves keys, coerce decides what survives.
573
639
  def unwrap_scope(raw, component_class)
574
640
  scope = reactive_scope_of(component_class)
575
641
  return raw unless scope
576
642
 
577
- # At the endpoint `raw` is ActionController::Parameters, so `raw[scope]` is
578
- # too — NOT a Hash. Accept either shape (both answer to the schema's
579
- # coerce): unwrap only when the scope key maps to a nested params/hash.
580
- nested = raw[scope.to_s]
581
- nested.is_a?(Hash) || nested.is_a?(ActionController::Parameters) ? nested : raw
643
+ expanded = Phlex::Reactive::ParamSchema.expand(raw)
644
+ nested = expanded[scope.to_s]
645
+ return expanded unless nested.is_a?(Hash)
646
+
647
+ expanded.delete(scope.to_s)
648
+ nested.merge(expanded)
582
649
  end
583
650
 
584
651
  # Issue #258: a form body cannot carry an empty array, so the client
@@ -609,7 +676,9 @@ module Phlex
609
676
  # nesting the group under it. `dropped` is nil unless verbose_errors.
610
677
  next dropped&.<<(["empty_groups #{it}", ANNOUNCED_UNDECLARED]) unless key
611
678
 
612
- raw[key] = [] unless raw.key?(key)
679
+ # String-keyed: a scoped `raw` is the expanded plain Hash (no
680
+ # indifferent access), and `key` is the declaration's (often a Symbol).
681
+ raw[key.to_s] = [] unless raw.key?(key.to_s)
613
682
  end
614
683
  raw
615
684
  end
@@ -0,0 +1,310 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phlex
4
+ module Reactive
5
+ # The privately cacheable lazy render (issue #277):
6
+ #
7
+ # GET <Phlex::Reactive.fragment_path>/<signed id>[?v=<version>][&u=<viewer>]
8
+ #
9
+ # renders a `reactive_lazy cache: { max_age: }` component's REAL template as
10
+ # its replace stream, with `Cache-Control: private, max-age=<n>` and an
11
+ # ETag, so the browser reuses it instead of asking again.
12
+ #
13
+ # It is a READ, the defer endpoint's twin: the id is verified, the component
14
+ # is rebuilt from its signed identity and rendered through the same
15
+ # authorization step (the base controller's auth, then whatever
16
+ # from_identity / the render raise). No action runs, no around_action
17
+ # wrapper, no transaction, no params reach the render — `v` and `u` only
18
+ # shape the browser's cache key. Subclassing ActionsController shares that
19
+ # read leg and its error plumbing; only the token, the opt-in gate and the
20
+ # reply's headers differ.
21
+ #
22
+ # What keeps a private cache safe here:
23
+ # * the id names no viewer — who it renders for always comes from the session;
24
+ # * only a component that declared `cache:` is reachable (404 otherwise);
25
+ # * a cached copy is never replayed to another viewer: by default the
26
+ # reply says `Vary: Cookie`; a component that names its viewer
27
+ # (reactive_cache_viewer) is keyed on it in the URL instead, and the
28
+ # reply is cacheable only when that URL names the viewer of THIS session;
29
+ # * the reply is cacheable ONLY on the success path; every other response
30
+ # this controller produces — including one a base-controller filter
31
+ # rendered before the action ran — is `no-store`;
32
+ # * a cacheable reply carries no Set-Cookie: an unchanged session is not
33
+ # re-issued, and a request that changed the session or wrote any cookie
34
+ # — in any callback, before or after the action, or in one of the
35
+ # writes Rails defers until the controller has returned (see
36
+ # commit_deferred_writes) — is not cacheable;
37
+ # * a render whose markup has a field or meta NAMED like a CSRF token
38
+ # (`name="authenticity_token"`, `name="csrf-token"`, or the controller's
39
+ # request_forgery_protection_token) is not made cacheable. A name match
40
+ # only: a token placed anywhere else is not detected.
41
+ class FragmentsController < ActionsController
42
+ # The names a CSRF token is rendered under (a form's hidden field, or
43
+ # csrf_meta_tags), besides the controller's own
44
+ # request_forgery_protection_token. Cached, such a token would outlive
45
+ # the session it was minted for.
46
+ CSRF_TOKEN_NAMES = %w[authenticity_token csrf-token].freeze
47
+ # One compiled pattern per forgery-protection field name (it is a
48
+ # class-level setting, so in practice one entry), not one per request.
49
+ CSRF_TOKEN_PATTERNS = Concurrent::Map.new
50
+
51
+ # Stands in for the response when asking the cookie jar what it WOULD
52
+ # write (CookieJar#write calls set_cookie / delete_cookie per pending
53
+ # write), so pending writes are read through the jar's own public method.
54
+ class CookieWrites < ::Hash
55
+ def set_cookie(name, *) = store(name.to_s, :set)
56
+ def delete_cookie(name, *) = store(name.to_s, :delete)
57
+ end
58
+
59
+ def show
60
+ event = { component: nil, outcome: nil }
61
+ Phlex::Reactive.with_url_options(Phlex::Reactive.url_options_for(request)) do
62
+ Phlex::Reactive.instrument("defer", event) do
63
+ render_real_component(event, permit: :reactive_lazy_cache, denied: :not_found) do
64
+ verified_fragment_payload
65
+ end
66
+ end
67
+ end
68
+ end
69
+
70
+ private
71
+
72
+ # Wraps the WHOLE action — every before/around/after callback included —
73
+ # so the cacheability decision is made last: a response the base
74
+ # controller's filters produced (a 401, a redirect to sign-in) is
75
+ # no-store, and so is a reply during which ANY callback wrote the session
76
+ # or a cookie, however late.
77
+ def process_action(*)
78
+ @session_before = begin
79
+ session_data
80
+ rescue StandardError
81
+ Object.new # unreadable: equal to nothing, so the reply stays no-store
82
+ end
83
+ super
84
+ ensure
85
+ settle_caching
86
+ end
87
+
88
+ # The single place a reply becomes cacheable: the success path asked for
89
+ # it AND this request leaves the browser's cookies exactly as they were.
90
+ def settle_caching
91
+ return forbid_caching unless @fragment_cacheable && !caching_forbidden? && cookies_untouched?
92
+
93
+ # Re-assert the policy: an inherited after_action may have LOOSENED it
94
+ # (expires_in …, public: true would let a shared cache keep a viewer's
95
+ # fragment) or overwritten the Vary. Tightening was honoured above.
96
+ apply_cache_policy
97
+ # Nothing changed, so don't re-issue the session: the cookie store
98
+ # would otherwise send a freshly encrypted Set-Cookie with this very
99
+ # reply — on a stored response, and changing the Cookie the next
100
+ # request sends (defeating `Vary: Cookie` within the page).
101
+ request.session_options[:skip] = true
102
+ end
103
+
104
+ def forbid_caching
105
+ response.cache_control.clear
106
+ response.headers["Cache-Control"] = "no-store"
107
+ response.headers.delete("ETag")
108
+ end
109
+
110
+ # Did the app itself forbid caching this reply — `no_store`, `expires_now`
111
+ # or a max-age of zero in a base-controller filter, or the header set by
112
+ # hand? The app may always tighten the policy; this endpoint only refuses
113
+ # to let it be loosened. (This endpoint's OWN max-age can be zero — a
114
+ # fragment_cache_max_age_limit of 0 — which is revalidate-always, not this.)
115
+ def caching_forbidden?
116
+ control = response.cache_control
117
+ return true if control[:no_store] || control[:no_cache]
118
+ return true if response.headers["Cache-Control"].to_s.match?(/\bno-(?:store|cache)\b/i)
119
+
120
+ app_max_age = app_cache_directives[:max_age]
121
+ !app_max_age.nil? && app_max_age.zero?
122
+ end
123
+
124
+ # What the app's own filters asked for, from expires_in (the response's
125
+ # cache_control hash) or a header set by hand: the smallest max-age, and
126
+ # whether must-revalidate was requested. Empty while the policy on the
127
+ # response is still exactly the one this endpoint applied — so its OWN
128
+ # max-age (which may be 0) is never mistaken for the app's.
129
+ def app_cache_directives
130
+ control = response.cache_control
131
+ header = response.headers["Cache-Control"].to_s
132
+ return {} if @applied_policy && control == @applied_policy && header.empty?
133
+
134
+ # EVERY max-age in a hand-set header counts (the smallest wins — never
135
+ # looser than what the app wrote), and a negative one means zero.
136
+ ages = [control[:max_age], *header.scan(/\bmax-age=(-?\d+)/i).flatten].compact.map { [it.to_i, 0].max }
137
+ { max_age: ages.min, must_revalidate: control[:must_revalidate] || header.match?(/\bmust-revalidate\b/i) }
138
+ end
139
+
140
+ # True only when it is KNOWN that this request writes no cookie: the
141
+ # session's data is what it was, it is not being renewed or dropped, and
142
+ # the cookie jar has no pending write or delete. Anything unreadable
143
+ # counts as touched — the reply fails closed.
144
+ def cookies_untouched?
145
+ options = request.session_options
146
+ return false if options[:renew] || options[:drop]
147
+ # A cookie written straight onto the response (response.set_cookie),
148
+ # which never passes through the jar.
149
+ return false if response.headers["Set-Cookie"].present?
150
+
151
+ commit_deferred_writes
152
+
153
+ session_data == @session_before && pending_cookie_writes.empty?
154
+ rescue StandardError
155
+ false
156
+ end
157
+
158
+ # Rails performs some session/cookie writes only AFTER the controller has
159
+ # returned — too late for the comparison in cookies_untouched?, and (the
160
+ # session being skipped on a cacheable reply) they would then be LOST.
161
+ # Each is pulled forward here, so it shows up in the comparison: the write
162
+ # is kept and the reply is not stored. All of them are idempotent — Rails
163
+ # running them again afterwards changes nothing.
164
+ #
165
+ # THIS LIST IS THE AUDIT of actionpack 7.1 – 8.1 (the versions the gemspec
166
+ # supports; identical in all of them) for writes made after process_action:
167
+ #
168
+ # * ActionController::Metal#dispatch → request.commit_flash
169
+ # writes session["flash"]: a flash set this request, or the sweep of
170
+ # one that was read. A no-op when nothing loaded the flash.
171
+ # * ActionDispatch::Session::AbstractStore#commit_session
172
+ # → request.commit_csrf_token → the csrf_token_storage_strategy's
173
+ # #store: a CSRF token handed out this request (form_authenticity_
174
+ # token in a filter). SessionStore writes session[:_csrf_token];
175
+ # CookieStore writes an encrypted cookie to the cookie JAR (so it
176
+ # shows up as a pending cookie write). A no-op when no token was
177
+ # asked for.
178
+ # → then Rack's own commit: only persists the session, and honours
179
+ # :renew / :drop — both checked in cookies_untouched?.
180
+ # * ActionDispatch::Cookies (middleware) → cookie_jar.write(response)
181
+ # nothing new: it flushes the jar's pending writes, which
182
+ # pending_cookie_writes reads through that same method.
183
+ # * Response#before_committed, Rack::ETag / ConditionalGet, the
184
+ # permissions-policy middleware, request id, runtime, server timing
185
+ # → response headers only; none touches the session or cookies.
186
+ # * ActionDispatch::ContentSecurityPolicy::Middleware
187
+ # → response headers, but it calls the APP's nonce generator after
188
+ # the controller returned. The stock generator
189
+ # (request.session.id.to_s) only reads. A generator that WRITES the
190
+ # session is app code running outside the controller — see below.
191
+ #
192
+ # Not covered, and cannot be from here: a session write made outside the
193
+ # controller — third-party Rack middleware, or a CSP nonce generator that
194
+ # writes the session. Such a write is dropped on a cacheable reply.
195
+ def commit_deferred_writes
196
+ request.commit_flash
197
+ request.commit_csrf_token if request.respond_to?(:commit_csrf_token)
198
+ end
199
+
200
+ # The session's data without creating a session: {} when the request has
201
+ # none. Deep-copied, so an in-place change shows up.
202
+ def session_data
203
+ session = request.session
204
+ session.respond_to?(:exists?) && !session.exists? && !session.loaded? ? {} : session.to_h.deep_dup
205
+ end
206
+
207
+ # What the cookie jar will write when the response leaves — including a
208
+ # cookie re-set to the same value with a new expiry, and deletes.
209
+ def pending_cookie_writes
210
+ CookieWrites.new.tap { request.cookie_jar.write(it) }
211
+ end
212
+
213
+ def verified_fragment_payload
214
+ Phlex::Reactive.verify_fragment(params.require(:id)) || raise(Phlex::Reactive::InvalidToken.new(
215
+ "fragment id invalid — tampered, or a token of another purpose (identity and defer tokens " \
216
+ "do not resolve at the fragment endpoint)",
217
+ diagnostic: :tampered
218
+ ))
219
+ end
220
+
221
+ # The success reply: private, bounded, revalidatable — provisionally; see
222
+ # settle_caching. The ETag is derived from the rendered body (never from
223
+ # `v`/`u`), so a 304 is only ever answered for the exact render this
224
+ # viewer would get.
225
+ def render_real_stream(stream, component)
226
+ return render_uncacheable(stream, component.class) if stream.match?(csrf_token_markup)
227
+
228
+ viewer = component.send(:fragment_viewer_param)
229
+ # The URL must name exactly the viewer of THIS session — or nobody, when
230
+ # the session has none. Anything else (a page rendered before the viewer
231
+ # changed, a hand-built URL) still renders for this session, but must
232
+ # never be stored under another viewer's key.
233
+ return super unless ActiveSupport::SecurityUtils.secure_compare(viewer.to_s, params[:u].to_s)
234
+
235
+ # With no viewer named, the cookie is the only thing that tells two
236
+ # viewers apart; with one, the URL does (and was just checked).
237
+ @fragment_policy = { max_age: Phlex::Reactive::Fragment.max_age_for(component.class), vary: viewer.nil? }
238
+ # A filter that already forbade caching (an app-wide no_store) wins.
239
+ return super if caching_forbidden?
240
+
241
+ apply_cache_policy
242
+ render_conditional(stream)
243
+ # Only now: anything that raised above leaves the reply no-store.
244
+ @fragment_cacheable = true
245
+ end
246
+
247
+ # The conditional GET, with the ETag set from the BODY ALONE. Not
248
+ # stale?/fresh_when: those run the controller's etaggers, and Rails' own
249
+ # flash etagger LOADS the flash — which marks a pending flash as used, so
250
+ # a fragment GET would eat a notice meant for the next page view. (An
251
+ # app's `etag { }` blocks are skipped for the same reason; vary a cached
252
+ # fragment through reactive_cache_version / reactive_cache_viewer.)
253
+ def render_conditional(stream)
254
+ response.weak_etag = stream
255
+ request.fresh?(response) ? head(:not_modified) : render(turbo_stream: stream)
256
+ end
257
+
258
+ # A `name=` attribute, in any quoting, that names a CSRF token — the
259
+ # well-known names plus this controller's forgery-protection field.
260
+ def csrf_token_markup
261
+ # An API base controller has no forgery protection (and so no such
262
+ # setting): the built-in names alone are checked then.
263
+ token_name = respond_to?(:request_forgery_protection_token, true) ? request_forgery_protection_token.to_s : ""
264
+ CSRF_TOKEN_PATTERNS.compute_if_absent(token_name) do
265
+ names = (CSRF_TOKEN_NAMES | [token_name]).reject(&:empty?).map { Regexp.escape(it) }
266
+ /\bname\s*=\s*["']?(?:#{names.join("|")})(?![\w-])/i
267
+ end
268
+ end
269
+
270
+ # `private, max-age=<n>` plus Vary: Cookie in the default mode — tightened
271
+ # by whatever the app's filters asked for (a SHORTER max-age,
272
+ # must-revalidate), never loosened (public, s-maxage,
273
+ # stale-while-revalidate and a longer max-age are dropped). Applied before
274
+ # the render (the 304 carries it) and again after every callback ran
275
+ # (settle_caching).
276
+ def apply_cache_policy
277
+ app = app_cache_directives
278
+ @fragment_policy[:max_age] = [@fragment_policy[:max_age], app[:max_age]].compact.min
279
+ @fragment_policy[:must_revalidate] ||= app[:must_revalidate]
280
+ policy = { max_age: @fragment_policy[:max_age], public: false }
281
+ policy[:must_revalidate] = true if @fragment_policy[:must_revalidate]
282
+ response.headers.delete("Cache-Control")
283
+ response.cache_control.replace(policy)
284
+ @applied_policy = policy.dup
285
+ vary_on_cookie if @fragment_policy[:vary]
286
+ end
287
+
288
+ # Add Cookie to whatever the base controller or a middleware already
289
+ # varies on — replacing it could let the browser reuse the wrong variant.
290
+ def vary_on_cookie
291
+ fields = response.headers["Vary"].to_s.split(",").map(&:strip).reject(&:empty?)
292
+ response.headers["Vary"] = (fields | ["Cookie"]).join(", ")
293
+ end
294
+
295
+ def render_uncacheable(stream, component_class)
296
+ ::Rails.logger&.warn(
297
+ "[phlex-reactive] #{component_class.name} is reactive_lazy(cache:) but its render embeds a CSRF " \
298
+ "token (a form authenticity token or csrf_meta_tags) — served no-store: a cached token outlives its " \
299
+ "session. Read the token from the page's csrf-token meta tag at submit time, or drop cache:."
300
+ )
301
+ render turbo_stream: stream
302
+ end
303
+
304
+ def unpermitted_render_message(component_class)
305
+ "#{component_class.name} is not reactive_lazy(cache:) — only a component that opted in is " \
306
+ "reachable at the fragment endpoint"
307
+ end
308
+ end
309
+ end
310
+ end
@@ -0,0 +1,181 @@
1
+ // phlex/reactive/core — the OPT-IN client: the reactive controller without
2
+ // its feature modules, which it imports when a root on the page needs one
3
+ // (issue #275). The default is phlex/reactive/reactive_controller: one file
4
+ // with every feature inside, nothing imported later. An app imports ONE of
5
+ // the two, never both.
6
+ //
7
+ // The controller itself is the shared runtime (runtime.js); the shipped
8
+ // core.min.js is this file BUNDLED with it. All this entry adds is where each
9
+ // feature module lives: one LITERAL import() per feature, by the bare
10
+ // specifier the engine pins (phlex/reactive/features/<name>) — literal so a
11
+ // bundler can see it and split the feature into its own chunk, bare so the
12
+ // import map resolves it to that module's own digested file (issue #57).
13
+ //
14
+ // What the opt-in costs a page is documented in the README ("The split
15
+ // client"): the first root that needs a feature waits for its import.
16
+ import ReactiveController, {
17
+ loadReactiveFeature,
18
+ onReactiveStreamWithoutEffects,
19
+ registerReactiveDev,
20
+ registerReactiveFeatureLoader,
21
+ unloadedFeaturesFor,
22
+ } from "phlex/reactive/runtime"
23
+
24
+ registerReactiveFeatureLoader("persist", () => import("phlex/reactive/features/persist"), waitingForPersist)
25
+ registerReactiveFeatureLoader("defer", () => import("phlex/reactive/features/defer"))
26
+ registerReactiveFeatureLoader("form", () => import("phlex/reactive/features/form"))
27
+ registerReactiveFeatureLoader("bindings", () => import("phlex/reactive/features/bindings"))
28
+ registerReactiveFeatureLoader("compute", () => import("phlex/reactive/features/compute"), waitingForEdit)
29
+ registerReactiveFeatureLoader("effects", () => import("phlex/reactive/features/effects"))
30
+ registerReactiveFeatureLoader("hints", () => import("phlex/reactive/features/hints"))
31
+ registerReactiveFeatureLoader("devtools", () => import("phlex/reactive/features/devtools"))
32
+ // The runtime registered with Turbo before this file ran: now it knows where
33
+ // the dev module is, a development page can have it.
34
+ registerReactiveDev()
35
+
36
+ // --- While the persist module is on its way ---------------------------------------
37
+ // What the draft code must not miss while it is on its way: that the
38
+ // user edited a field (the edit must be drafted once the module is here)
39
+ // and that the form around the root was submitted successfully (the
40
+ // draft must be forgotten, not restored). The feature listens for both
41
+ // itself once it has connected.
42
+ function waitingForPersist(root, pending) {
43
+ const edited = () => {
44
+ pending.edited = true
45
+ }
46
+ const submitted = (event) => {
47
+ const form = event.target
48
+ if (event.detail?.success && form?.tagName === "FORM" && form.contains?.(root)) pending.submitted = true
49
+ }
50
+ // (Lexical and Trix swallow the native `input` of their editor; their
51
+ // own bubbling change events are the keystroke signal.)
52
+ const edits = ["input", "change", "lexxy:change", "trix-change"]
53
+ for (const type of edits) root.addEventListener?.(type, edited)
54
+ document.addEventListener("turbo:submit-end", submitted)
55
+ return () => {
56
+ for (const type of edits) root.removeEventListener?.(type, edited)
57
+ document.removeEventListener("turbo:submit-end", submitted)
58
+ }
59
+ }
60
+
61
+ // --- While the compute module is on its way --------------------------------------
62
+ // An edit the user made before the module could recompute: it runs one pass
63
+ // when it connects, over the fields as the user left them.
64
+ function waitingForEdit(root, pending) {
65
+ const edited = () => {
66
+ pending.edited = true
67
+ }
68
+ root.addEventListener?.("input", edited)
69
+ root.addEventListener?.("change", edited)
70
+ return () => {
71
+ root.removeEventListener?.("input", edited)
72
+ root.removeEventListener?.("change", edited)
73
+ }
74
+ }
75
+
76
+ // --- A stream that needs a module before it is loaded ------------------------------
77
+ // A stream can bring the page's first use of a feature: a broadcast appends
78
+ // the first dismissing flash, a reply removes a row with an exit effect or
79
+ // swaps in a form with show bindings and tag chips. Stream effects are
80
+ // document-level (features/effects.js) and an exit effect runs BEFORE the
81
+ // render; a swapped-in root connects AFTER it and would start its import
82
+ // then, showing the server's rendering until the module arrives. Both are
83
+ // avoided the same way: the render WAITS for the imports (`hold`), so the
84
+ // stream is animated and the new root connects with its modules present.
85
+ // Which modules a stream needs is read off the runtime's feature table
86
+ // (unloadedFeaturesFor: every incoming reactive root's marker checks), plus
87
+ // the effects module's own stream-level checks — no per-feature copy here.
88
+ //
89
+ // Turbo renders each <turbo-stream> on its own, so one waiting render would
90
+ // let a later stream to the same target overtake it: while the hold is open
91
+ // EVERY stream's render waits on the same promise, and so they resume in
92
+ // arrival order. The hold is an animation's worth of patience, never the
93
+ // import timeout: after STREAM_HOLD_MS the streams render plain, a root
94
+ // connects and imports as usual, and the effects module, when it does arrive,
95
+ // sweeps up the flashes it missed (their timers then start at arrival, not at
96
+ // render). A module already loaded is never waited for.
97
+ const STREAM_HOLD_MS = 1000
98
+ let hold = null
99
+ let effects
100
+ const holding = new Set() // the modules the open hold is waiting for
101
+
102
+ function loadIntoHold(name) {
103
+ return loadReactiveFeature(name).then((feature) => {
104
+ if (name !== "effects") return
105
+ effects = feature
106
+ feature?.sweep()
107
+ })
108
+ }
109
+
110
+ onReactiveStreamWithoutEffects((event, loadedEffects) => {
111
+ const detail = event.detail
112
+ const render = detail?.render
113
+ if (typeof render !== "function") return
114
+ const streamEl = detail.newStream ?? event.target
115
+ const names = unloadedFeaturesFor(incomingRoots(streamEl)).filter((name) => !holding.has(name))
116
+ if (!loadedEffects && !holding.has("effects") && streamNeedsEffects(streamEl)) names.push("effects")
117
+ // Nothing to wait for, and no hold open: a loaded effects module wraps the
118
+ // stream now, as it would without this entry.
119
+ if (names.length === 0 && !hold) return loadedEffects?.wrap(event)
120
+ // A hold that is open grows: a later stream's modules join it (the hold
121
+ // then lasts up to another STREAM_HOLD_MS from now), so a second stream is
122
+ // never left to render without the module it asked for.
123
+ if (names.length > 0) {
124
+ for (const name of names) holding.add(name)
125
+ const opened = (hold = Promise.race([
126
+ Promise.all([hold, ...names.map(loadIntoHold)]),
127
+ new Promise((resolve) => setTimeout(resolve, STREAM_HOLD_MS)),
128
+ ]).then(() => {
129
+ if (hold !== opened) return
130
+ hold = null
131
+ holding.clear()
132
+ }))
133
+ }
134
+ const waiting = hold
135
+ detail.render = async (streamElement) => {
136
+ await waiting
137
+ // Wrapped now, not when the event fired: nothing has rendered meanwhile.
138
+ // On a copy: the event's detail.render is not ours to set back — a defer
139
+ // reply has already rendered it, and leaves Turbo a no-op (issue #336).
140
+ const held = { target: event.target, detail: { newStream: detail.newStream, render } }
141
+ ;(loadedEffects ?? effects)?.wrap(held)
142
+ await held.detail.render(streamElement)
143
+ }
144
+ })
145
+
146
+ // The reactive roots a stream is about to put on the page: the roots in its
147
+ // template (the template's own root element included).
148
+ function incomingRoots(streamEl) {
149
+ const content = streamEl?.querySelector?.("template")?.content
150
+ if (!content) return []
151
+ const roots = [...(content.querySelectorAll?.('[data-controller~="reactive"]') ?? [])]
152
+ for (const child of content.children ?? []) {
153
+ if (child.matches?.('[data-controller~="reactive"]') && !roots.includes(child)) roots.unshift(child)
154
+ }
155
+ return roots
156
+ }
157
+
158
+ // Does this stream need the effects module? A cheap superset of what the
159
+ // module itself resolves: a per-call effect, an effect declared on the target
160
+ // or on incoming content, a dismissing flash arriving or already on the page.
161
+ function streamNeedsEffects(streamEl) {
162
+ return Boolean(
163
+ streamEl?.hasAttribute?.("data-reactive-effect") ||
164
+ document
165
+ .getElementById?.(streamEl?.getAttribute?.("target"))
166
+ ?.matches?.("[data-reactive-effect-update],[data-reactive-effect-exit]") ||
167
+ streamEl
168
+ ?.querySelector?.("template")
169
+ ?.content?.querySelector?.("[data-reactive-effect-enter],[data-reactive-dismiss-after]") ||
170
+ document.querySelector?.("[data-reactive-dismiss-after]:not([data-reactive-dismiss-scheduled])"),
171
+ )
172
+ }
173
+
174
+ export function __resetReactiveStreamHoldForTest() {
175
+ hold = null
176
+ effects = undefined
177
+ holding.clear()
178
+ }
179
+
180
+ export * from "phlex/reactive/runtime"
181
+ export default ReactiveController