phlex-reactive 0.13.4 → 0.14.1
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 +1079 -630
- data/README.md +612 -17
- 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 +1582 -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 +86 -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 +3706 -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 +188 -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 +47 -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 +27 -7
- 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
|
@@ -117,6 +117,24 @@
|
|
|
117
117
|
# <meta name="phlex-reactive-defer-path" content="<%%= Phlex::Reactive.defer_path %>">
|
|
118
118
|
# Phlex::Reactive.defer_path = "/_r/defer"
|
|
119
119
|
|
|
120
|
+
# Cacheable lazy fragments (reactive_lazy cache: { max_age: … }): the GET
|
|
121
|
+
# endpoint's path prefix (default "/reactive/fragment") and the longest max-age
|
|
122
|
+
# a component may be answered with (default 1 hour — within it the browser does
|
|
123
|
+
# not ask the server, so it also bounds how long a revoked permission can keep
|
|
124
|
+
# showing a cached fragment). If you change the path, expose it to the client
|
|
125
|
+
# in the layout <head> (the client refuses fragment URLs outside the path it
|
|
126
|
+
# knows; phlex_reactive:doctor checks for the tag):
|
|
127
|
+
# <meta name="phlex-reactive-fragment-path" content="<%%= Phlex::Reactive.fragment_path %>">
|
|
128
|
+
# Phlex::Reactive.fragment_path = "/_r/fragment"
|
|
129
|
+
# Phlex::Reactive.fragment_cache_max_age_limit = 10.minutes
|
|
130
|
+
|
|
131
|
+
# Early triggers (issue #273): with `import "phlex/reactive/early"` in your
|
|
132
|
+
# entry point, a trigger that fires before a lazily loaded controller connects
|
|
133
|
+
# is replayed on connect — unless it is older than this (ms, default 10s).
|
|
134
|
+
# Render it into your layout head for the client to read:
|
|
135
|
+
# <meta name="phlex-reactive-early-ttl" content="<%%= Phlex::Reactive.early_event_ttl_ms %>">
|
|
136
|
+
# Phlex::Reactive.early_event_ttl_ms = 10_000
|
|
137
|
+
|
|
120
138
|
# Client request timeout (default 30s). There is NO server-side setting — a hung
|
|
121
139
|
# request aborts client-side after this window (reactive:error kind "timeout"),
|
|
122
140
|
# so the per-component queue never wedges. Override in your layout head:
|
|
@@ -33,13 +33,15 @@ Run this FIRST — most "nothing happens" reports are a red ✗ here.
|
|
|
33
33
|
```bash
|
|
34
34
|
bin/rails phlex_reactive:actions # every component × action: params, file:line, auth
|
|
35
35
|
bin/rails phlex_reactive:actions FORMAT=json
|
|
36
|
+
bin/rails phlex_reactive:actions UNVERIFIED=1 # only the unverified rows (the review queue)
|
|
36
37
|
bin/rails "phlex_reactive:find[counter]" # fuzzy-find one; prints each action's method source
|
|
37
38
|
```
|
|
38
39
|
|
|
39
40
|
Answers "what reactive actions exist, where are they defined, and is each
|
|
40
|
-
authorized?" without grepping. The `auth` column is a heuristic
|
|
41
|
-
an authorization call was detected in the body; `
|
|
42
|
-
|
|
41
|
+
authorized?" without grepping. The `auth` column is a heuristic: `authorized*` =
|
|
42
|
+
an authorization call was detected in the body; `skipped` / `skipped (class)` =
|
|
43
|
+
the author opted out with `skip_verify_authorized` (for that action / for the
|
|
44
|
+
whole class); `unverified` = neither — a helper may still authorize indirectly.
|
|
43
45
|
|
|
44
46
|
## 3. Scan the live page (browser inspector)
|
|
45
47
|
|
|
@@ -213,8 +213,29 @@ module Phlex
|
|
|
213
213
|
# root's element for content that can't legally hold a <div> — a
|
|
214
214
|
# `reactive_lazy tag: :tr` component rooting at <tr> inside <tbody>
|
|
215
215
|
# ships a <tr> shell, not a <div> the HTML parser would hoist away.
|
|
216
|
-
|
|
217
|
-
|
|
216
|
+
#
|
|
217
|
+
# `on:` (issue #276) defers the REQUEST itself, not just server time:
|
|
218
|
+
# reactive_lazy on: "panel:opened" # a DOM event on (or bubbling into) the shell
|
|
219
|
+
# reactive_lazy on: :visible # the shell first scrolls into view
|
|
220
|
+
# reactive_lazy on: { visible: "200px" } # …with an IntersectionObserver rootMargin
|
|
221
|
+
# The shell then carries the identity token (no TTL) and materializes
|
|
222
|
+
# once through the action endpoint. See Component::Lazy.
|
|
223
|
+
#
|
|
224
|
+
# `cache:` (issue #277) makes the real render a privately cacheable GET:
|
|
225
|
+
# reactive_lazy cache: { max_age: 10.minutes } # combine with on:/tag: freely
|
|
226
|
+
# The shell carries a stable signed URL instead of a per-render token,
|
|
227
|
+
# and the endpoint answers `Cache-Control: private, max-age=…`, so the
|
|
228
|
+
# browser reuses the fragment across page views. Define
|
|
229
|
+
# `reactive_cache_version` to bust the URL when the content changes,
|
|
230
|
+
# and `reactive_cache_viewer` (e.g. Current.user&.id) to key the URL
|
|
231
|
+
# on the viewer instead of `Vary: Cookie` — needed for reuse ACROSS
|
|
232
|
+
# page views when the session cookie changes on every response
|
|
233
|
+
# (Rails' cookie store does).
|
|
234
|
+
def reactive_lazy(tag: :div, on: nil, cache: nil)
|
|
235
|
+
declaration = { tag: tag.to_sym }
|
|
236
|
+
declaration[:trigger] = Lazy.normalize_trigger(on) unless on.nil?
|
|
237
|
+
declaration[:cache] = Lazy.normalize_cache(cache) unless cache.nil?
|
|
238
|
+
Registry.write_scalar(self, :lazy, declaration)
|
|
218
239
|
end
|
|
219
240
|
|
|
220
241
|
# The RAW resolved lazy declaration ({ tag: } or nil) — the reader the
|
|
@@ -238,6 +259,22 @@ module Phlex
|
|
|
238
259
|
value.is_a?(::Hash) ? value.fetch(:tag, :div) : :div
|
|
239
260
|
end
|
|
240
261
|
|
|
262
|
+
# The normalized `on:` trigger of a lazy component — `{ event: "x" }`
|
|
263
|
+
# or `{ visible: "<rootMargin>" }` — or nil (plain reactive_lazy, or
|
|
264
|
+
# not lazy at all). The endpoint's `__materialize` gate reads it.
|
|
265
|
+
def reactive_lazy_trigger
|
|
266
|
+
value = reactive_lazy_declaration
|
|
267
|
+
value[:trigger] if value.is_a?(::Hash)
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
# The normalized `cache:` of a lazy component — `{ max_age: <seconds> }`
|
|
271
|
+
# — or nil. The fragment endpoint's opt-in gate reads it: only a
|
|
272
|
+
# component that declared `cache:` is reachable over GET.
|
|
273
|
+
def reactive_lazy_cache
|
|
274
|
+
value = reactive_lazy_declaration
|
|
275
|
+
value[:cache] if value.is_a?(::Hash)
|
|
276
|
+
end
|
|
277
|
+
|
|
241
278
|
# Declare a client-invokable action with an optional param schema.
|
|
242
279
|
# action :increment
|
|
243
280
|
# action :rename, params: { title: :string }
|
|
@@ -258,6 +295,13 @@ module Phlex
|
|
|
258
295
|
# ({ "0" => ..., "1" => ... }), so a fields_for collection works either way.
|
|
259
296
|
def action(name, params: {})
|
|
260
297
|
require_server_actions!(:action)
|
|
298
|
+
# Issue #276: the endpoint routes `__materialize` to the lazy render
|
|
299
|
+
# before the action registry is consulted — a declared one would be
|
|
300
|
+
# dead code that looks invokable.
|
|
301
|
+
if name.to_sym == Lazy::MATERIALIZE_ACTION
|
|
302
|
+
raise ArgumentError, "action #{name.inspect} is reserved for reactive_lazy(on:)"
|
|
303
|
+
end
|
|
304
|
+
|
|
261
305
|
# Issue #184: params: :symbol resolves a registered named schema.
|
|
262
306
|
params = Phlex::Reactive.param_schema(params) if params.is_a?(Symbol)
|
|
263
307
|
# If a scope is already declared, reject a schema nested under the scope
|
|
@@ -293,6 +337,34 @@ module Phlex
|
|
|
293
337
|
"use reactive_actions.key?(#{name.inspect})"
|
|
294
338
|
end
|
|
295
339
|
|
|
340
|
+
# Dormant root (issue #274): every root of this component renders
|
|
341
|
+
# data-reactive-dormant="reactive" instead of data-controller=
|
|
342
|
+
# "reactive", so the client is neither mounted nor (when loaded
|
|
343
|
+
# lazily) fetched until one of the root's triggers fires — for a root
|
|
344
|
+
# that only matters after a gesture (a closed dialog, a menu that
|
|
345
|
+
# loads on open). The app must import "phlex/reactive/early", which
|
|
346
|
+
# wakes the root. Inherited; `reactive_dormant false` turns it off
|
|
347
|
+
# again in a subclass, and reactive_root(dormant:) overrides per
|
|
348
|
+
# render. See Phlex::Reactive::Dormant.
|
|
349
|
+
#
|
|
350
|
+
# Not with reactive_lazy(on: :visible): that shell has no trigger to
|
|
351
|
+
# wake on (refused here, or at render for the other declaration order).
|
|
352
|
+
# A plain reactive_lazy shell ignores it: it fetches on connect, so it
|
|
353
|
+
# always mounts. reactive_lazy(on: "event") shells do go dormant.
|
|
354
|
+
def reactive_dormant(dormant = true) # rubocop:disable Style/OptionalBooleanParameter
|
|
355
|
+
unless [true, false].include?(dormant)
|
|
356
|
+
raise ArgumentError, "#{self}: reactive_dormant takes true or false, got #{dormant.inspect}"
|
|
357
|
+
end
|
|
358
|
+
|
|
359
|
+
Phlex::Reactive::Dormant.reject_visible_lazy!(self) if dormant
|
|
360
|
+
|
|
361
|
+
Registry.write_scalar(self, :dormant, dormant)
|
|
362
|
+
end
|
|
363
|
+
|
|
364
|
+
def reactive_dormant?
|
|
365
|
+
Registry.resolve_scalar(self, :dormant, :reactive_dormant?) == true
|
|
366
|
+
end
|
|
367
|
+
|
|
296
368
|
# Opt out of the default-ON verify_authorized guard (issue #168).
|
|
297
369
|
# Bare skips the WHOLE component (a public counter, a client-only
|
|
298
370
|
# filter); with names skips just those actions:
|
|
@@ -12,6 +12,7 @@ module Phlex
|
|
|
12
12
|
# no signing (those live in DSL and Identity).
|
|
13
13
|
module Helpers
|
|
14
14
|
extend ActiveSupport::Concern
|
|
15
|
+
include Selection
|
|
15
16
|
|
|
16
17
|
# The acting client's SSE connection id during the current action (nil
|
|
17
18
|
# outside an action, or when the client isn't subscribed to a stream).
|
|
@@ -72,8 +73,22 @@ module Phlex
|
|
|
72
73
|
# Root-element attributes: marks the element reactive and carries the
|
|
73
74
|
# signed identity token. Spread onto the root:
|
|
74
75
|
# div(id:, **reactive_attrs) { ... }
|
|
75
|
-
|
|
76
|
-
|
|
76
|
+
#
|
|
77
|
+
# `dormant: true` (issue #274; default: the class's reactive_dormant)
|
|
78
|
+
# renders data-reactive-dormant="reactive" IN PLACE of the controller
|
|
79
|
+
# attribute: nothing mounts until a trigger fires and phlex/reactive/early
|
|
80
|
+
# wakes the root. The actor's own reply renders awake (Dormant.awake).
|
|
81
|
+
def reactive_attrs(dormant: nil)
|
|
82
|
+
dormant = self.class.reactive_dormant? if dormant.nil? && self.class.respond_to?(:reactive_dormant?)
|
|
83
|
+
data =
|
|
84
|
+
if dormant && !Phlex::Reactive::Dormant.awake?
|
|
85
|
+
# The lazy SHELL (not the real render) of an on: :visible component
|
|
86
|
+
# can never wake — refuse it rather than ship a dead placeholder.
|
|
87
|
+
Phlex::Reactive::Dormant.reject_visible_lazy!(self.class) unless Phlex::Reactive::Defer.real_render?
|
|
88
|
+
{ reactive_dormant: "reactive" }
|
|
89
|
+
else
|
|
90
|
+
{ controller: "reactive" }
|
|
91
|
+
end
|
|
77
92
|
# A CLIENT-ONLY component (Phlex::Reactive::ClientBindings, issue #180)
|
|
78
93
|
# has no Identity, so no token — the root is tokenless (show/filter/
|
|
79
94
|
# compute need no signed round trip). reactive_token is private, so the
|
|
@@ -137,7 +152,12 @@ module Phlex
|
|
|
137
152
|
# fields, and — for warn_unsaved: true — the navigate-away marker (STRING
|
|
138
153
|
# "true", since a boolean-true attr renders valueless → "" → falsy client-
|
|
139
154
|
# side). The removed track_dirty:/warn_unsaved: kwargs raise a guided error.
|
|
140
|
-
|
|
155
|
+
#
|
|
156
|
+
# `dormant: true` (issue #274) is handed to reactive_attrs: the root mounts
|
|
157
|
+
# on its first trigger instead of on page load. On a reactive_lazy
|
|
158
|
+
# component declare `reactive_dormant` on the class instead: the lazy
|
|
159
|
+
# SHELL is rendered by the framework and reads only the class declaration.
|
|
160
|
+
def reactive_root(dormant: nil, **overrides)
|
|
141
161
|
# A CLIENT-ONLY component (ClientBindings, issue #180) needs no #id —
|
|
142
162
|
# there's no token to self-match by id. Use an explicit override, else
|
|
143
163
|
# #id when the component defines one, else nothing (no id attr).
|
|
@@ -151,7 +171,7 @@ module Phlex
|
|
|
151
171
|
# Issue #184: dirty tracking is a class-level reactive_dirty declaration.
|
|
152
172
|
dirty = self.class.reactive_dirty_config if self.class.respond_to?(:reactive_dirty_config)
|
|
153
173
|
|
|
154
|
-
attrs = mix({ **reactive_attrs }, overrides)
|
|
174
|
+
attrs = mix({ **reactive_attrs(dormant:) }, overrides)
|
|
155
175
|
attrs = mix(attrs, { id: root_id }) unless root_id.nil?
|
|
156
176
|
# Root-level delegation tracks the whole subtree UNLESS only: scoped it to
|
|
157
177
|
# named fields (those carry their own descriptor via reactive_field).
|
|
@@ -1575,14 +1595,22 @@ module Phlex
|
|
|
1575
1595
|
def apply_first_paint_hidden(attrs, groups, values_override)
|
|
1576
1596
|
return attrs if attrs.key?(:hidden)
|
|
1577
1597
|
|
|
1598
|
+
visible = first_paint_match(groups, values_override)
|
|
1599
|
+
visible.nil? ? attrs : attrs.merge(hidden: !visible)
|
|
1600
|
+
end
|
|
1601
|
+
|
|
1602
|
+
# The server-side evaluation of a binding's conditions for first paint
|
|
1603
|
+
# (reactive_show's hidden:, reactive_enable's disabled:): true/false
|
|
1604
|
+
# when reactive_values (merged under values:) covers EVERY referenced
|
|
1605
|
+
# field, else nil — the client seeds it at connect.
|
|
1606
|
+
def first_paint_match(groups, values_override)
|
|
1578
1607
|
provided = show_values(values_override)
|
|
1579
|
-
return
|
|
1608
|
+
return nil if provided.nil?
|
|
1580
1609
|
|
|
1581
1610
|
referenced = Phlex::Reactive::ShowConditions.fields(groups)
|
|
1582
|
-
return
|
|
1611
|
+
return nil unless referenced.all? { provided.key?(it) }
|
|
1583
1612
|
|
|
1584
|
-
|
|
1585
|
-
attrs.merge(hidden: !visible)
|
|
1613
|
+
Phlex::Reactive::ShowConditions.match?(groups, provided)
|
|
1586
1614
|
end
|
|
1587
1615
|
|
|
1588
1616
|
# The { field => current-string-value } map the first-paint evaluator
|
|
@@ -1598,7 +1626,9 @@ module Phlex
|
|
|
1598
1626
|
merged = {}
|
|
1599
1627
|
merged.merge!(base) if base
|
|
1600
1628
|
merged.merge!(values_override) if values_override
|
|
1601
|
-
|
|
1629
|
+
# An Array (a checkbox group's checked values, issue #319) stays an
|
|
1630
|
+
# Array so a checked: term can count it.
|
|
1631
|
+
merged.to_h { |name, value| [name.to_s, value.is_a?(Array) ? value.map(&:to_s) : show_value_string(value)] }
|
|
1602
1632
|
end
|
|
1603
1633
|
|
|
1604
1634
|
# Stringify a reactive_values entry the way the client's #showFieldValue
|
|
@@ -14,9 +14,66 @@ module Phlex
|
|
|
14
14
|
# the defer endpoint/job, the class stream builders — runs inside
|
|
15
15
|
# Defer.with_real_render, so it renders the REAL template. Anything else
|
|
16
16
|
# would make an action on a lazy component cost two round trips.
|
|
17
|
+
#
|
|
18
|
+
# `reactive_lazy(on:)` (issue #276) defers the REQUEST, not just the
|
|
19
|
+
# server time: the shell carries the identity token (no TTL, unlike the
|
|
20
|
+
# defer token). An event shell binds a `__materialize` trigger `once` to
|
|
21
|
+
# its event; a visibility shell has no binding — the client's
|
|
22
|
+
# IntersectionObserver starts the same `__materialize` request itself.
|
|
23
|
+
# The action endpoint routes `__materialize` to the same real render the
|
|
24
|
+
# defer endpoint does. The real render never contains the shell or its
|
|
25
|
+
# trigger, so `once` cannot re-arm.
|
|
17
26
|
module Lazy
|
|
18
27
|
extend ActiveSupport::Concern
|
|
19
28
|
|
|
29
|
+
# The framework-owned act the on: shell dispatches. Reserved: `action`
|
|
30
|
+
# refuses it, and the endpoint answers it only for reactive_lazy(on:).
|
|
31
|
+
MATERIALIZE_ACTION = :__materialize
|
|
32
|
+
# A DOM event name usable in a Stimulus descriptor: no key filter (.),
|
|
33
|
+
# no target (@), no descriptor syntax (->, #) — `panel:opened` is fine.
|
|
34
|
+
EVENT_NAME = /\A[A-Za-z][\w:-]*\z/
|
|
35
|
+
# An IntersectionObserver rootMargin: one to four px/% lengths.
|
|
36
|
+
# Any CSS whitespace between lengths; normalized to single spaces.
|
|
37
|
+
ROOT_MARGIN = /\A\s*-?\d+(?:\.\d+)?(?:px|%)(?:\s+-?\d+(?:\.\d+)?(?:px|%)){0,3}\s*\z/
|
|
38
|
+
|
|
39
|
+
# `on:` → { event: "x" } | { visible: "<rootMargin>" }, or raise.
|
|
40
|
+
def self.normalize_trigger(on)
|
|
41
|
+
case on
|
|
42
|
+
when String
|
|
43
|
+
return { event: on } if on.match?(EVENT_NAME)
|
|
44
|
+
when :visible
|
|
45
|
+
return { visible: "0px" }
|
|
46
|
+
when ::Hash
|
|
47
|
+
margin = on[:visible]
|
|
48
|
+
if on.keys == [:visible] && margin.is_a?(String) && margin.match?(ROOT_MARGIN)
|
|
49
|
+
return { visible: margin.split.join(" ") }
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
raise ArgumentError,
|
|
53
|
+
"reactive_lazy on: expects a DOM event name (\"panel:opened\"), :visible, or " \
|
|
54
|
+
"{ visible: \"200px\" } (a px/% rootMargin) — got #{on.inspect}"
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# `cache:` → { max_age: <seconds> }, or raise. Only a whole, positive
|
|
58
|
+
# number of seconds (an Integer or an ActiveSupport::Duration) is a
|
|
59
|
+
# lifetime; the endpoint caps it at fragment_cache_max_age_limit.
|
|
60
|
+
def self.normalize_cache(cache)
|
|
61
|
+
unless cache.is_a?(::Hash) && cache.keys == [:max_age]
|
|
62
|
+
raise ArgumentError,
|
|
63
|
+
"reactive_lazy cache: expects { max_age: <seconds> } (e.g. { max_age: 10.minutes }) — got #{cache.inspect}"
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
max_age = cache[:max_age]
|
|
67
|
+
# Duration#to_i truncates (1.5.seconds → 1): only an exact one converts.
|
|
68
|
+
seconds = max_age.is_a?(ActiveSupport::Duration) && max_age.to_i == max_age ? max_age.to_i : max_age
|
|
69
|
+
unless seconds.is_a?(::Integer) && seconds.positive?
|
|
70
|
+
raise ArgumentError,
|
|
71
|
+
"reactive_lazy cache: max_age must be a positive whole number of seconds — got #{max_age.inspect}"
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
{ max_age: seconds }
|
|
75
|
+
end
|
|
76
|
+
|
|
20
77
|
private
|
|
21
78
|
|
|
22
79
|
# Phlex 2's render hook: the template runs inside the block. Overridden
|
|
@@ -26,18 +83,92 @@ module Phlex
|
|
|
26
83
|
def around_template(&)
|
|
27
84
|
klass = self.class
|
|
28
85
|
if klass.respond_to?(:reactive_lazy?) && klass.reactive_lazy? && !Phlex::Reactive::Defer.real_render?
|
|
29
|
-
render_defer_shell
|
|
86
|
+
(trigger = klass.reactive_lazy_trigger) ? render_trigger_shell(trigger) : render_defer_shell
|
|
30
87
|
else
|
|
31
88
|
super
|
|
32
89
|
end
|
|
33
90
|
end
|
|
34
91
|
|
|
92
|
+
# The on: shell (issue #276): the same placeholder contract as the
|
|
93
|
+
# defer shell (id, class, aria-busy, deferred_placeholder), but mounted
|
|
94
|
+
# like a reactive root — reactive_attrs' identity token. No defer token
|
|
95
|
+
# and no pending marker: the client's lazy probe gates on the token, so
|
|
96
|
+
# it skips this shell, and nothing is in flight until the trigger fires.
|
|
97
|
+
#
|
|
98
|
+
# The marker tells the client which shell this is, on connect AND after
|
|
99
|
+
# a Turbo morph re-shows it on a connected root (no Stimulus lifecycle):
|
|
100
|
+
# * data-reactive-lazy-on="<event>" — plus the once-bound Stimulus
|
|
101
|
+
# descriptor for `__materialize`, so the first event takes the
|
|
102
|
+
# ordinary action path. The client re-arms the event itself after a
|
|
103
|
+
# morph, because a spent `once` binding never fires again.
|
|
104
|
+
# * data-reactive-lazy-visible="<rootMargin>" — no descriptor at all;
|
|
105
|
+
# the client's IntersectionObserver materializes directly.
|
|
106
|
+
#
|
|
107
|
+
# A `cache:` component (issue #277) adds its fragment URL: the client
|
|
108
|
+
# then loads with a cacheable GET instead of the __materialize POST.
|
|
109
|
+
# Such a shell carries NO identity token (issue #306): every load, and
|
|
110
|
+
# the fallback for a URL the client refuses (a GET under the fragment
|
|
111
|
+
# path it knows), goes through the URL — a second signature of the same
|
|
112
|
+
# identity would only make the shell heavier than what it defers.
|
|
113
|
+
def render_trigger_shell(trigger)
|
|
114
|
+
src = fragment_src
|
|
115
|
+
root = reactive_attrs
|
|
116
|
+
root[:data].delete(:reactive_token_value) if src
|
|
117
|
+
attrs = mix(
|
|
118
|
+
{ id:, class: "reactive-defer-placeholder", aria: { busy: "true" } },
|
|
119
|
+
root,
|
|
120
|
+
{ data: trigger_shell_data(trigger) }
|
|
121
|
+
)
|
|
122
|
+
# Mixed in only when present: a non-cached shell allocates nothing extra.
|
|
123
|
+
attrs = mix(attrs, { data: { reactive_defer_src: src } }) if src
|
|
124
|
+
public_send(self.class.reactive_lazy_tag, **attrs) { render_deferred_placeholder_content }
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# The cacheable fragment URL (issue #277) for a `cache:` component, else
|
|
128
|
+
# nil (Phlex omits a nil attribute, so a non-cached shell is unchanged).
|
|
129
|
+
# Deterministic: the same identity (+ version) renders the same URL on
|
|
130
|
+
# every page view, which is what lets the browser's HTTP cache hit.
|
|
131
|
+
def fragment_src
|
|
132
|
+
return unless self.class.reactive_lazy_cache
|
|
133
|
+
|
|
134
|
+
version = respond_to?(:reactive_cache_version, true) ? send(:reactive_cache_version) : nil
|
|
135
|
+
Phlex::Reactive::Fragment.src(reactive_identity_payload, version:, viewer: fragment_viewer_param)
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# The `u` of the fragment URL: the keyed digest of reactive_cache_viewer
|
|
139
|
+
# when the component declares who its render is for AND that value names
|
|
140
|
+
# someone, else nil (the default, `Vary: Cookie` mode). The shell renders
|
|
141
|
+
# it; the endpoint recomputes it in the requesting session and compares
|
|
142
|
+
# (see FragmentsController).
|
|
143
|
+
def fragment_viewer_param
|
|
144
|
+
return unless respond_to?(:reactive_cache_viewer, true)
|
|
145
|
+
|
|
146
|
+
Phlex::Reactive::Fragment.viewer_param(send(:reactive_cache_viewer), owner: self.class)
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def trigger_shell_data(trigger)
|
|
150
|
+
return { reactive_lazy_visible: trigger[:visible] } if trigger[:visible]
|
|
151
|
+
|
|
152
|
+
event = trigger[:event]
|
|
153
|
+
{
|
|
154
|
+
action: "#{event}->reactive#dispatch:once",
|
|
155
|
+
reactive_action_param: MATERIALIZE_ACTION.to_s,
|
|
156
|
+
reactive_params_param: Helpers::EMPTY_PARAMS_JSON,
|
|
157
|
+
reactive_lazy_on: event
|
|
158
|
+
}
|
|
159
|
+
end
|
|
160
|
+
|
|
35
161
|
# The shell: owns the component's id (the arrival replaces it by that
|
|
36
162
|
# id), mounts the generic controller, and carries the defer token as a
|
|
37
163
|
# ROOT attribute — the controller's connect() probes it and enters the
|
|
38
164
|
# same module-level fetch path a reply directive uses. Pending markers
|
|
39
165
|
# + the .reactive-defer-placeholder class are the CSS hooks.
|
|
166
|
+
#
|
|
167
|
+
# A `cache:` component (issue #277) carries its stable fragment URL in
|
|
168
|
+
# data-reactive-defer-src INSTEAD of the token: the client GETs it, and
|
|
169
|
+
# the browser's private cache can answer.
|
|
40
170
|
def render_defer_shell
|
|
171
|
+
src = fragment_src
|
|
41
172
|
public_send(
|
|
42
173
|
self.class.reactive_lazy_tag,
|
|
43
174
|
id:,
|
|
@@ -46,6 +177,10 @@ module Phlex
|
|
|
46
177
|
data: {
|
|
47
178
|
controller: "reactive",
|
|
48
179
|
reactive_defer_pending: "true",
|
|
180
|
+
# The same verbose stamp reactive_attrs writes (nil is omitted):
|
|
181
|
+
# the shell may be the page's only reactive root when its mount
|
|
182
|
+
# fetch runs, and the client's verbose gate reads it (issue #279).
|
|
183
|
+
reactive_verbose: (Phlex::Reactive.verbose_errors ? "true" : nil),
|
|
49
184
|
# UNBOUND (issue #165 security): a lazy shell renders during the
|
|
50
185
|
# page render — on a fresh visit the session doesn't exist yet, so
|
|
51
186
|
# the token can't be actor-bound (it would 400 at the endpoint,
|
|
@@ -53,7 +188,8 @@ module Phlex
|
|
|
53
188
|
# small leak surface); the TTL + `authorize!` are its bound. Only
|
|
54
189
|
# reply.defer tokens (in action responses that can transit proxies)
|
|
55
190
|
# are actor-bound.
|
|
56
|
-
reactive_defer_token: Phlex::Reactive.sign_defer(reactive_identity_payload, unbound: true)
|
|
191
|
+
reactive_defer_token: (Phlex::Reactive.sign_defer(reactive_identity_payload, unbound: true) unless src),
|
|
192
|
+
reactive_defer_src: src
|
|
57
193
|
}
|
|
58
194
|
) { render_deferred_placeholder_content }
|
|
59
195
|
end
|
|
@@ -59,6 +59,8 @@ module Phlex
|
|
|
59
59
|
dirty: :@reactive_own_dirty,
|
|
60
60
|
# Deferred reply segments — reactive_lazy (issue #165).
|
|
61
61
|
lazy: :@reactive_own_lazy,
|
|
62
|
+
# Mount on first use — reactive_dormant (issue #274).
|
|
63
|
+
dormant: :@reactive_own_dormant,
|
|
62
64
|
# Enter/exit/update effects — reactive_effects (issue #215).
|
|
63
65
|
effects: :@reactive_own_effects,
|
|
64
66
|
# verify_authorized opt-out (issue #168): a scalar bare flag (skip the
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Phlex
|
|
4
|
+
module Reactive
|
|
5
|
+
module Component
|
|
6
|
+
# Bulk-selection bindings (issue #319): a list with a row checkbox per
|
|
7
|
+
# item, a "select all" header, a "Delete (n)" count, and actions that stay
|
|
8
|
+
# disabled until something is ticked — with no per-list JavaScript.
|
|
9
|
+
#
|
|
10
|
+
# form(id: "bulk", action: "/posts/bulk", method: "post") do
|
|
11
|
+
# input(type: "checkbox", **reactive_select_all("ids[]")) # header
|
|
12
|
+
# @posts.each { input(type: "checkbox", name: "ids[]", value: it.id) }
|
|
13
|
+
# span(**reactive_count("ids[]")) { "0" } # ticked count
|
|
14
|
+
# button(**reactive_enable(if: { "ids[]" => { checked: 1.. } })) { "Delete" }
|
|
15
|
+
# end
|
|
16
|
+
#
|
|
17
|
+
# The group is a checkbox NAME, resolved like any reactive_show field
|
|
18
|
+
# (owned boxes only — a nested reactive root's boxes are its own, #15;
|
|
19
|
+
# a bare name takes reactive_scope, a bracketed one like "ids[]" is used
|
|
20
|
+
# verbatim). The client re-syncs the header, the count and every
|
|
21
|
+
# binding when a box changes and when boxes are added or removed (a
|
|
22
|
+
# stream append, a morph, a removal). All client-only: no token, no
|
|
23
|
+
# round trip.
|
|
24
|
+
module Selection
|
|
25
|
+
# The sibling of reactive_show: the same if:/if_any:/unless: conditions
|
|
26
|
+
# (including the { checked: n } count term), but it flips the
|
|
27
|
+
# element's OWN `disabled` — a button, a fieldset — instead of
|
|
28
|
+
# `hidden`. First paint is computed from reactive_values exactly like
|
|
29
|
+
# reactive_show's `hidden:` (an explicit `disabled:` wins; `values:`
|
|
30
|
+
# merges over reactive_values).
|
|
31
|
+
def reactive_enable(**options)
|
|
32
|
+
conditions = options.slice(*Helpers::SHOW_CONDITION_KEYS)
|
|
33
|
+
values_override = options.delete(:values)
|
|
34
|
+
attrs = options.except(*Helpers::SHOW_CONDITION_KEYS)
|
|
35
|
+
|
|
36
|
+
groups = Phlex::Reactive::ShowConditions.normalize(**conditions)
|
|
37
|
+
result = mix({ data: { reactive_enable: { "any" => groups }.to_json } }, attrs)
|
|
38
|
+
return result if result.key?(:disabled)
|
|
39
|
+
|
|
40
|
+
match = first_paint_match(groups, values_override)
|
|
41
|
+
match.nil? ? result : result.merge(disabled: !match)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# The header box of a checkbox group. Its `change` ticks or unticks
|
|
45
|
+
# every owned box of the group (dispatching `input` + `change` on each one it
|
|
46
|
+
# flips, so computes, shows and counts re-run); its own checked /
|
|
47
|
+
# indeterminate state follows the group. Give the header NO `name` —
|
|
48
|
+
# it would post, and count as a member.
|
|
49
|
+
def reactive_select_all(group, **attrs)
|
|
50
|
+
mix({ data: { reactive_select_all: selection_group!(:reactive_select_all, group) } }, attrs)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# A text binding that shows how many owned boxes of the group are
|
|
54
|
+
# ticked (written via textContent, change-guarded). Seed the first
|
|
55
|
+
# paint in the element's own content: span(**reactive_count("ids[]")) { "0" }.
|
|
56
|
+
def reactive_count(group, **attrs)
|
|
57
|
+
mix({ data: { reactive_count: selection_group!(:reactive_count, group) } }, attrs)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# CROSS-ROOT group bindings (issue #343) — the count and enable
|
|
61
|
+
# sibling of reactive_show_targets. The group bindings above only see
|
|
62
|
+
# boxes this root owns (#15), so a list that is its own reactive root
|
|
63
|
+
# cannot drive a bulk bar the page renders outside it. The root that
|
|
64
|
+
# OWNS the boxes declares the outside ids it drives instead. Spread it
|
|
65
|
+
# on the ROOT (mix alongside reactive_root and reactive_show_targets):
|
|
66
|
+
#
|
|
67
|
+
# div(**mix(reactive_root,
|
|
68
|
+
# reactive_show_targets("#bulk-bar" => { if: { "ids[]" => { checked: 1.. } } }),
|
|
69
|
+
# reactive_group_targets("ids[]",
|
|
70
|
+
# count: "#bulk-count", # textContent = ticked count
|
|
71
|
+
# enable: { "#bulk-archive" => 1.. }))) # disabled = !(count in range)
|
|
72
|
+
#
|
|
73
|
+
# Targets are SINGLE ID SELECTORS (raised here, warn-skipped by the
|
|
74
|
+
# client) resolved document-wide; a missing one is skipped. An enable
|
|
75
|
+
# value is an Integer (an exact count), a Range (a threshold) or a full
|
|
76
|
+
# if:/if_any:/unless: conditions Hash. They re-sync with the in-root
|
|
77
|
+
# bindings: every change, a morph, rows added or removed.
|
|
78
|
+
#
|
|
79
|
+
# ONE call per root (mix space-joins a second call's JSON into an
|
|
80
|
+
# unparseable attr); several groups go in the hash form:
|
|
81
|
+
#
|
|
82
|
+
# reactive_group_targets("ids[]" => { count: "#c" }, "tags[]" => { enable: { "#t" => 1.. } })
|
|
83
|
+
#
|
|
84
|
+
# The outside markup gets its first paint from reactive_group_target_attrs.
|
|
85
|
+
def reactive_group_targets(group = nil, **options)
|
|
86
|
+
if group.nil? && (options.key?(:count) || options.key?(:enable))
|
|
87
|
+
raise ArgumentError, "reactive_group_targets needs the group first: " \
|
|
88
|
+
"reactive_group_targets(\"ids[]\", #{options.keys.map { "#{it}: ..." }.join(", ")})"
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
groups =
|
|
92
|
+
if group.nil? then options
|
|
93
|
+
elsif group.is_a?(Hash) then group
|
|
94
|
+
else { group => options }
|
|
95
|
+
end
|
|
96
|
+
if groups.empty?
|
|
97
|
+
raise ArgumentError, "reactive_group_targets needs a group and its targets: " \
|
|
98
|
+
"reactive_group_targets(\"ids[]\", count: \"#id\", enable: { \"#id\" => 1.. })"
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
wire = groups.to_h do |name, targets|
|
|
102
|
+
name = selection_group!(:reactive_group_targets, name)
|
|
103
|
+
[name, normalize_group_targets(name, targets)]
|
|
104
|
+
end
|
|
105
|
+
{ data: { reactive_group_targets: wire.to_json } }
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# First paint for an element a reactive_group_targets root drives but
|
|
109
|
+
# another component renders (computed from THIS component's
|
|
110
|
+
# reactive_values, so the outside markup never flashes):
|
|
111
|
+
#
|
|
112
|
+
# span(id: "bulk-count") { reactive_group_target_attrs("ids[]", :count).to_s } # the ticked count
|
|
113
|
+
# button(id: "bulk-archive", **reactive_group_target_attrs("ids[]", :enable, 1..)) # { disabled: }
|
|
114
|
+
#
|
|
115
|
+
# :count is the Integer (0 for an empty or absent group). :enable is
|
|
116
|
+
# { disabled: true|false }, or {} when reactive_values does not cover
|
|
117
|
+
# the group (the client seeds it at connect).
|
|
118
|
+
def reactive_group_target_attrs(group, kind, condition = nil)
|
|
119
|
+
name = selection_group!(:reactive_group_target_attrs, group)
|
|
120
|
+
case kind
|
|
121
|
+
when :count then Phlex::Reactive::ShowConditions.checked_count(show_values(nil)&.dig(name))
|
|
122
|
+
when :enable
|
|
123
|
+
match = first_paint_match(group_enable_payload(name, "reactive_group_target_attrs", condition)["any"], nil)
|
|
124
|
+
match.nil? ? {} : { disabled: !match }
|
|
125
|
+
else
|
|
126
|
+
raise ArgumentError, "reactive_group_target_attrs takes :count or :enable, got #{kind.inspect}"
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
private
|
|
131
|
+
|
|
132
|
+
def normalize_group_targets(name, targets)
|
|
133
|
+
unless targets.is_a?(Hash) && (targets.key?(:count) || targets.key?(:enable))
|
|
134
|
+
raise ArgumentError, "reactive_group_targets(#{name.inspect}) needs count: or enable:, got #{targets.inspect}"
|
|
135
|
+
end
|
|
136
|
+
if (unknown = targets.keys - %i[count enable]).any?
|
|
137
|
+
raise ArgumentError, "reactive_group_targets(#{name.inspect}): unknown option(s) " \
|
|
138
|
+
"#{unknown.map(&:inspect).join(", ")} — it takes count: and enable:"
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
wire = {}
|
|
142
|
+
wire["count"] = Array(targets[:count]).map { group_target_selector!(name, it) } if targets.key?(:count)
|
|
143
|
+
if targets.key?(:enable)
|
|
144
|
+
wire["enable"] = targets[:enable].to_h do |selector, condition|
|
|
145
|
+
[group_target_selector!(name, selector), group_enable_payload(name, "reactive_group_targets", condition)]
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
wire
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def group_target_selector!(name, selector)
|
|
152
|
+
selector = selector.to_s
|
|
153
|
+
return selector if selector.match?(DSL::MIRROR_ID_SELECTOR)
|
|
154
|
+
|
|
155
|
+
raise ArgumentError, "reactive_group_targets(#{name.inspect}) target #{selector.inspect} must be a single " \
|
|
156
|
+
"ID selector (\"#id\") — cross-root targets are id-allowlisted, like reactive_show_targets"
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# An enable condition as the { "any" => groups } DNF payload: an
|
|
160
|
+
# Integer or Range is the group's checked count, a Hash is the full
|
|
161
|
+
# reactive_show conditions language.
|
|
162
|
+
def group_enable_payload(name, helper, condition)
|
|
163
|
+
conditions =
|
|
164
|
+
case condition
|
|
165
|
+
when Integer, Range then { if: { name => { checked: condition } } }
|
|
166
|
+
when Hash
|
|
167
|
+
if (unknown = condition.keys - Helpers::SHOW_CONDITION_KEYS).any?
|
|
168
|
+
raise ArgumentError, "#{helper}(#{name.inspect}): unknown conditions key(s) " \
|
|
169
|
+
"#{unknown.map(&:inspect).join(", ")} — it takes if:/if_any:/unless:"
|
|
170
|
+
end
|
|
171
|
+
condition
|
|
172
|
+
else
|
|
173
|
+
raise ArgumentError, "#{helper}(#{name.inspect}) enable takes an Integer (exact count), a Range " \
|
|
174
|
+
"(threshold) or an if:/if_any:/unless: conditions Hash, got #{condition.inspect}"
|
|
175
|
+
end
|
|
176
|
+
{ "any" => Phlex::Reactive::ShowConditions.normalize(**conditions) }
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def selection_group!(helper, group)
|
|
180
|
+
name = group.to_s
|
|
181
|
+
return name unless name.strip.empty?
|
|
182
|
+
|
|
183
|
+
raise ArgumentError, "#{helper} needs a checkbox group name (e.g. \"ids[]\"), got #{group.inspect}"
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
end
|