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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +1050 -630
- data/README.md +561 -14
- data/app/controllers/phlex/reactive/actions_controller.rb +85 -16
- data/app/controllers/phlex/reactive/fragments_controller.rb +310 -0
- data/app/javascript/phlex/reactive/core.js +181 -0
- data/app/javascript/phlex/reactive/core.min.js +4 -0
- data/app/javascript/phlex/reactive/core.min.js.map +11 -0
- data/app/javascript/phlex/reactive/early.js +175 -0
- data/app/javascript/phlex/reactive/early.min.js +4 -0
- data/app/javascript/phlex/reactive/early.min.js.map +10 -0
- data/app/javascript/phlex/reactive/features/bindings.js +1511 -0
- data/app/javascript/phlex/reactive/features/bindings.min.js +4 -0
- data/app/javascript/phlex/reactive/features/bindings.min.js.map +10 -0
- data/app/javascript/phlex/reactive/features/compute.js +541 -0
- data/app/javascript/phlex/reactive/features/compute.min.js +4 -0
- data/app/javascript/phlex/reactive/features/compute.min.js.map +10 -0
- data/app/javascript/phlex/reactive/features/defer.js +726 -0
- data/app/javascript/phlex/reactive/features/defer.min.js +4 -0
- data/app/javascript/phlex/reactive/features/defer.min.js.map +10 -0
- data/app/javascript/phlex/reactive/features/devtools.js +242 -0
- data/app/javascript/phlex/reactive/features/devtools.min.js +4 -0
- data/app/javascript/phlex/reactive/features/devtools.min.js.map +10 -0
- data/app/javascript/phlex/reactive/features/effects.js +362 -0
- data/app/javascript/phlex/reactive/features/effects.min.js +4 -0
- data/app/javascript/phlex/reactive/features/effects.min.js.map +10 -0
- data/app/javascript/phlex/reactive/features/form.js +216 -0
- data/app/javascript/phlex/reactive/features/form.min.js +4 -0
- data/app/javascript/phlex/reactive/features/form.min.js.map +10 -0
- data/app/javascript/phlex/reactive/features/hints.js +193 -0
- data/app/javascript/phlex/reactive/features/hints.min.js +4 -0
- data/app/javascript/phlex/reactive/features/hints.min.js.map +10 -0
- data/app/javascript/phlex/reactive/features/persist.js +663 -0
- data/app/javascript/phlex/reactive/features/persist.min.js +4 -0
- data/app/javascript/phlex/reactive/features/persist.min.js.map +10 -0
- data/app/javascript/phlex/reactive/reactive_controller.js +85 -5821
- data/app/javascript/phlex/reactive/reactive_controller.min.js +2 -2
- data/app/javascript/phlex/reactive/reactive_controller.min.js.map +13 -4
- data/app/javascript/phlex/reactive/runtime.js +3690 -0
- data/lib/generators/phlex/reactive/install/templates/phlex_reactive.rb.erb +18 -0
- data/lib/phlex/reactive/claude/skills/phlex-reactive-debugging/SKILL.md +5 -3
- data/lib/phlex/reactive/component/dsl.rb +74 -2
- data/lib/phlex/reactive/component/helpers.rb +39 -9
- data/lib/phlex/reactive/component/lazy.rb +138 -2
- data/lib/phlex/reactive/component/registry.rb +2 -0
- data/lib/phlex/reactive/component/selection.rb +71 -0
- data/lib/phlex/reactive/doctor/fiber_isolation.rb +86 -0
- data/lib/phlex/reactive/doctor.rb +166 -8
- data/lib/phlex/reactive/dormant.rb +66 -0
- data/lib/phlex/reactive/engine.rb +63 -3
- data/lib/phlex/reactive/fragment.rb +233 -0
- data/lib/phlex/reactive/inspector/report.rb +24 -3
- data/lib/phlex/reactive/inspector.rb +23 -2
- data/lib/phlex/reactive/js.rb +17 -2
- data/lib/phlex/reactive/mcp/tools/actions_tool.rb +3 -1
- data/lib/phlex/reactive/mcp/tools/find_tool.rb +4 -1
- data/lib/phlex/reactive/param_schema.rb +68 -54
- data/lib/phlex/reactive/show_conditions.rb +84 -56
- data/lib/phlex/reactive/streamable.rb +20 -2
- data/lib/phlex/reactive/test_helpers/system/reactive_requests_matcher.rb +120 -0
- data/lib/phlex/reactive/test_helpers/system.rb +62 -0
- data/lib/phlex/reactive/version.rb +1 -1
- data/lib/phlex/reactive.rb +84 -0
- data/lib/tasks/phlex_reactive.rake +6 -2
- 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
|
-
|
|
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
|
-
|
|
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
|
|
141
|
+
return empty == :stream ? render(turbo_stream: "") : head(:no_content)
|
|
98
142
|
end
|
|
99
143
|
|
|
100
144
|
event[:outcome] = :ok
|
|
101
|
-
|
|
102
|
-
|
|
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])
|
|
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
|
|
571
|
-
#
|
|
572
|
-
#
|
|
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
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
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
|
|
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
|