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.
Files changed (65) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +1079 -630
  3. data/README.md +612 -17
  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 +1582 -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 +86 -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 +3706 -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 +188 -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 +47 -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 +27 -7
  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
@@ -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 (`authorized*` =
41
- an authorization call was detected in the body; `unverified` = none — a helper
42
- may still authorize indirectly).
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
- def reactive_lazy(tag: :div)
217
- Registry.write_scalar(self, :lazy, { tag: tag.to_sym })
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
- def reactive_attrs
76
- data = { controller: "reactive" }
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
- def reactive_root(**overrides)
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 attrs if provided.nil?
1608
+ return nil if provided.nil?
1580
1609
 
1581
1610
  referenced = Phlex::Reactive::ShowConditions.fields(groups)
1582
- return attrs unless referenced.all? { provided.key?(it) }
1611
+ return nil unless referenced.all? { provided.key?(it) }
1583
1612
 
1584
- visible = Phlex::Reactive::ShowConditions.match?(groups, provided)
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
- merged.to_h { |name, value| [name.to_s, show_value_string(value)] }
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