phlex-reactive 0.13.3 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +1097 -630
  3. data/README.md +638 -16
  4. data/app/controllers/phlex/reactive/actions_controller.rb +85 -16
  5. data/app/controllers/phlex/reactive/fragments_controller.rb +310 -0
  6. data/app/javascript/phlex/reactive/core.js +181 -0
  7. data/app/javascript/phlex/reactive/core.min.js +4 -0
  8. data/app/javascript/phlex/reactive/core.min.js.map +11 -0
  9. data/app/javascript/phlex/reactive/early.js +175 -0
  10. data/app/javascript/phlex/reactive/early.min.js +4 -0
  11. data/app/javascript/phlex/reactive/early.min.js.map +10 -0
  12. data/app/javascript/phlex/reactive/features/bindings.js +1511 -0
  13. data/app/javascript/phlex/reactive/features/bindings.min.js +4 -0
  14. data/app/javascript/phlex/reactive/features/bindings.min.js.map +10 -0
  15. data/app/javascript/phlex/reactive/features/compute.js +541 -0
  16. data/app/javascript/phlex/reactive/features/compute.min.js +4 -0
  17. data/app/javascript/phlex/reactive/features/compute.min.js.map +10 -0
  18. data/app/javascript/phlex/reactive/features/defer.js +726 -0
  19. data/app/javascript/phlex/reactive/features/defer.min.js +4 -0
  20. data/app/javascript/phlex/reactive/features/defer.min.js.map +10 -0
  21. data/app/javascript/phlex/reactive/features/devtools.js +242 -0
  22. data/app/javascript/phlex/reactive/features/devtools.min.js +4 -0
  23. data/app/javascript/phlex/reactive/features/devtools.min.js.map +10 -0
  24. data/app/javascript/phlex/reactive/features/effects.js +362 -0
  25. data/app/javascript/phlex/reactive/features/effects.min.js +4 -0
  26. data/app/javascript/phlex/reactive/features/effects.min.js.map +10 -0
  27. data/app/javascript/phlex/reactive/features/form.js +216 -0
  28. data/app/javascript/phlex/reactive/features/form.min.js +4 -0
  29. data/app/javascript/phlex/reactive/features/form.min.js.map +10 -0
  30. data/app/javascript/phlex/reactive/features/hints.js +193 -0
  31. data/app/javascript/phlex/reactive/features/hints.min.js +4 -0
  32. data/app/javascript/phlex/reactive/features/hints.min.js.map +10 -0
  33. data/app/javascript/phlex/reactive/features/persist.js +663 -0
  34. data/app/javascript/phlex/reactive/features/persist.min.js +4 -0
  35. data/app/javascript/phlex/reactive/features/persist.min.js.map +10 -0
  36. data/app/javascript/phlex/reactive/inspect.js +21 -9
  37. data/app/javascript/phlex/reactive/inspect.min.js +2 -2
  38. data/app/javascript/phlex/reactive/inspect.min.js.map +3 -3
  39. data/app/javascript/phlex/reactive/reactive_controller.js +85 -5580
  40. data/app/javascript/phlex/reactive/reactive_controller.min.js +2 -2
  41. data/app/javascript/phlex/reactive/reactive_controller.min.js.map +13 -4
  42. data/app/javascript/phlex/reactive/runtime.js +3690 -0
  43. data/lib/generators/phlex/reactive/install/templates/phlex_reactive.rb.erb +18 -0
  44. data/lib/phlex/reactive/claude/skills/phlex-reactive-debugging/SKILL.md +5 -3
  45. data/lib/phlex/reactive/component/dsl.rb +74 -2
  46. data/lib/phlex/reactive/component/helpers.rb +104 -28
  47. data/lib/phlex/reactive/component/lazy.rb +138 -2
  48. data/lib/phlex/reactive/component/registry.rb +2 -0
  49. data/lib/phlex/reactive/component/selection.rb +71 -0
  50. data/lib/phlex/reactive/doctor/fiber_isolation.rb +86 -0
  51. data/lib/phlex/reactive/doctor.rb +166 -8
  52. data/lib/phlex/reactive/dormant.rb +66 -0
  53. data/lib/phlex/reactive/engine.rb +64 -3
  54. data/lib/phlex/reactive/fragment.rb +233 -0
  55. data/lib/phlex/reactive/inspector/report.rb +24 -3
  56. data/lib/phlex/reactive/inspector.rb +23 -2
  57. data/lib/phlex/reactive/js.rb +61 -12
  58. data/lib/phlex/reactive/mcp/tools/actions_tool.rb +3 -1
  59. data/lib/phlex/reactive/mcp/tools/find_tool.rb +4 -1
  60. data/lib/phlex/reactive/param_schema.rb +68 -54
  61. data/lib/phlex/reactive/pending/markup.rb +128 -0
  62. data/lib/phlex/reactive/pending.rb +25 -21
  63. data/lib/phlex/reactive/show_conditions.rb +84 -56
  64. data/lib/phlex/reactive/streamable.rb +20 -2
  65. data/lib/phlex/reactive/test_helpers/system/reactive_requests_matcher.rb +120 -0
  66. data/lib/phlex/reactive/test_helpers/system.rb +62 -0
  67. data/lib/phlex/reactive/version.rb +1 -1
  68. data/lib/phlex/reactive.rb +84 -0
  69. data/lib/tasks/phlex_reactive.rake +6 -2
  70. metadata +39 -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).
@@ -225,6 +245,16 @@ module Phlex
225
245
  "keydown.esc->reactive#listnavClose"
226
246
  ].freeze
227
247
 
248
+ # Roving-focus mode (issue #271): Enter/Space are the focused item's
249
+ # native activation and Escape is the caller's own on_client binding,
250
+ # so only the movement keys are bound.
251
+ LISTNAV_FOCUS_ACTIONS = [
252
+ "keydown.down->reactive#listnavNext",
253
+ "keydown.up->reactive#listnavPrev",
254
+ "keydown.home->reactive#listnavFirst",
255
+ "keydown.end->reactive#listnavLast"
256
+ ].freeze
257
+
228
258
  # Event modifiers (issue #80) — window:, once:, outside:, throttle: are
229
259
  # RESERVED keyword names on on() (no longer usable as free action params):
230
260
  #
@@ -376,6 +406,17 @@ module Phlex
376
406
  # Window-bound triggers are never preventDefault-ed by the client and skip
377
407
  # the forced type="button".
378
408
  #
409
+ # Issue #271: each call emits ONE self-describing binding record —
410
+ # data-reactive-ops-param='{"on":"click","window":true,"outside":true,"ops":[…]}'
411
+ # carrying its own descriptor, flags and confirm, so several bindings on
412
+ # one element compose through mix: the data-action descriptors token-join,
413
+ # the records space-join (every literal space inside a record is written
414
+ # as \u0020, so the join is unambiguous), and the client runs only the
415
+ # record whose descriptor matches the firing event:
416
+ # div(**mix(reactive_root,
417
+ # on_client(:click, js.hide("#menu"), outside: true),
418
+ # on_client("keydown.esc", js.hide("#menu"))))
419
+ #
379
420
  # Ops are EPHEMERAL UI: any server re-render of the component (an action
380
421
  # reply, a broadcast, a morph) rebuilds from server state and resets
381
422
  # whatever they toggled — by design (the LiveView JS-commands caveat). Use
@@ -402,29 +443,21 @@ module Phlex
402
443
  "(change/input), or gate it behind a reducer's $ops / reactive_on_complete."
403
444
  end
404
445
  window_bound = window || outside
446
+ record = client_binding_record(event, ops, window_bound:, outside:, once:, confirm:)
405
447
  attrs = {
406
448
  data: {
407
449
  action: "#{event}#{"@window" if window_bound}->reactive#runOps#{":once" if once}",
408
- reactive_ops_param: ops.to_json
450
+ # Space-free (issue #271): compact JSON has no structural
451
+ # whitespace, so every space is inside a string, where \u0020
452
+ # parses to the same string — and mix's space-join stays splittable.
453
+ reactive_ops_param: record.to_json.gsub(" ", '\u0020')
409
454
  }
410
455
  }
411
- # STRING "true", not boolean — same Phlex valueless-attribute trap as
412
- # on()'s flags above.
413
- attrs[:data][:reactive_outside_param] = "true" if outside
414
- attrs[:data][:reactive_window_param] = "true" if window_bound
415
456
  # Issue #228: mark a clipboard-reading trigger so the client can gate
416
457
  # its visibility on availability — on connect the controller sets
417
458
  # hidden = !navigator.clipboard.readText. Render the trigger `hidden`
418
459
  # for reveal-when-available (a dead paste button never shows).
419
460
  attrs[:data][:reactive_clipboard] = "true" if ops.ops.any? { |name, _| name == "paste_into" }
420
- # Issue #178: confirm: gates the client-op chain behind the SAME
421
- # overridable confirmResolver on(:action, confirm:) uses (#52/#55). Emits
422
- # the identical data-reactive-confirm-param; the client's runOps prompts
423
- # via confirmResolver BEFORE applying the ops (a falsy resolve cancels
424
- # the chain), so a destructive client op gets the themed dialog with no
425
- # round trip. Issue #179: a Hash confirm: is CONDITIONAL — same shared
426
- # apply_confirm! branches String vs Hash for both on and on_client.
427
- apply_confirm!(attrs[:data], confirm) if confirm
428
461
  attrs[:type] = "button" if event == "click" && !window_bound
429
462
  attrs
430
463
  end
@@ -678,13 +711,23 @@ module Phlex
678
711
  # Enter picks the highlighted one by clicking its own reactive trigger
679
712
  # (selection stays a signed action), Escape clears. Combine with other
680
713
  # attrs via mix so a caller's data-action token-joins, not clobbers.
681
- def reactive_listnav(option_selector = "[role=option]")
682
- {
683
- data: {
684
- action: LISTNAV_ACTIONS.join(" "),
685
- reactive_listnav_option_param: filter_selector!(:selector, option_selector)
686
- }
714
+ #
715
+ # `focus: true` (issue #271) is ROVING-FOCUS mode for a menu container:
716
+ # Arrow Down/Up move real focus among the items (wrapping), Home/End jump
717
+ # to the first/last; no highlight attr is written. The item selector
718
+ # defaults to [role=menuitem]. Enter/Space stay native activation and
719
+ # Escape is yours to bind (on_client("keydown.esc", ...) composes via mix):
720
+ #
721
+ # ul(role: "menu", **mix(reactive_listnav(focus: true), data: { testid: "menu" }))
722
+ def reactive_listnav(option_selector = nil, focus: false)
723
+ option_selector ||= focus ? "[role=menuitem]" : "[role=option]"
724
+ data = {
725
+ action: (focus ? LISTNAV_FOCUS_ACTIONS : LISTNAV_ACTIONS).join(" "),
726
+ reactive_listnav_option_param: filter_selector!(:selector, option_selector)
687
727
  }
728
+ # STRING "true" — Phlex renders a boolean true as a valueless attribute.
729
+ data[:reactive_listnav_focus_param] = "true" if focus
730
+ { data: }
688
731
  end
689
732
 
690
733
  # Tag-chip input (issue #203) — the composed combobox/tags primitive.
@@ -1391,6 +1434,29 @@ module Phlex
1391
1434
  # Shared by on and on_client so both paths speak the same three forms. The
1392
1435
  # predicate is soft-validation UX, NOT authorization — a user can bypass it;
1393
1436
  # the action still hits the endpoint's real authorize/default-deny.
1437
+ # One on_client binding record (issue #271). Flags and confirm live IN
1438
+ # the record — never as element-wide params, which a sibling binding
1439
+ # would inherit. Issue #178: confirm: gates the chain behind the SAME
1440
+ # overridable confirmResolver on(:action, confirm:) uses (#52/#55); the
1441
+ # client prompts BEFORE applying the ops. Issue #179: a Hash confirm: is
1442
+ # CONDITIONAL, compiled by the shared compile_conditional_confirm.
1443
+ def client_binding_record(event, ops, window_bound:, outside:, once:, confirm:)
1444
+ record = { "on" => event }
1445
+ record["window"] = true if window_bound
1446
+ record["outside"] = true if outside
1447
+ # A mix-ed regular sibling keeps calling runOps after Stimulus drops
1448
+ # this binding's :once listener — the client skips a spent once record.
1449
+ record["once"] = true if once
1450
+ case confirm
1451
+ when nil, false then nil
1452
+ when String then record["confirm"] = confirm
1453
+ when Hash then record["confirmWhen"] = compile_conditional_confirm(confirm)
1454
+ else apply_confirm!({}, confirm) # raises the shared guided error
1455
+ end
1456
+ record["ops"] = ops.ops
1457
+ record
1458
+ end
1459
+
1394
1460
  def apply_confirm!(data, confirm)
1395
1461
  case confirm
1396
1462
  when String
@@ -1529,14 +1595,22 @@ module Phlex
1529
1595
  def apply_first_paint_hidden(attrs, groups, values_override)
1530
1596
  return attrs if attrs.key?(:hidden)
1531
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)
1532
1607
  provided = show_values(values_override)
1533
- return attrs if provided.nil?
1608
+ return nil if provided.nil?
1534
1609
 
1535
1610
  referenced = Phlex::Reactive::ShowConditions.fields(groups)
1536
- return attrs unless referenced.all? { provided.key?(it) }
1611
+ return nil unless referenced.all? { provided.key?(it) }
1537
1612
 
1538
- visible = Phlex::Reactive::ShowConditions.match?(groups, provided)
1539
- attrs.merge(hidden: !visible)
1613
+ Phlex::Reactive::ShowConditions.match?(groups, provided)
1540
1614
  end
1541
1615
 
1542
1616
  # The { field => current-string-value } map the first-paint evaluator
@@ -1552,7 +1626,9 @@ module Phlex
1552
1626
  merged = {}
1553
1627
  merged.merge!(base) if base
1554
1628
  merged.merge!(values_override) if values_override
1555
- 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)] }
1556
1632
  end
1557
1633
 
1558
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,71 @@
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
+ private
61
+
62
+ def selection_group!(helper, group)
63
+ name = group.to_s
64
+ return name unless name.strip.empty?
65
+
66
+ raise ArgumentError, "#{helper} needs a checkbox group name (e.g. \"ids[]\"), got #{group.inspect}"
67
+ end
68
+ end
69
+ end
70
+ end
71
+ end