phlex-reactive 0.9.3 → 0.9.4

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 (45) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +197 -0
  3. data/README.md +282 -2
  4. data/app/controllers/phlex/reactive/actions_controller.rb +120 -10
  5. data/app/javascript/phlex/reactive/inspect.js +225 -0
  6. data/app/javascript/phlex/reactive/inspect.min.js +4 -0
  7. data/app/javascript/phlex/reactive/inspect.min.js.map +10 -0
  8. data/app/javascript/phlex/reactive/reactive_controller.js +534 -6
  9. data/app/javascript/phlex/reactive/reactive_controller.min.js +2 -2
  10. data/app/javascript/phlex/reactive/reactive_controller.min.js.map +3 -3
  11. data/lib/generators/phlex/reactive/claude/USAGE +15 -0
  12. data/lib/generators/phlex/reactive/claude/claude_generator.rb +86 -0
  13. data/lib/generators/phlex/reactive/install/install_generator.rb +3 -0
  14. data/lib/generators/phlex/reactive/install/templates/phlex_reactive.rb.erb +56 -0
  15. data/lib/phlex/reactive/authorization.rb +118 -0
  16. data/lib/phlex/reactive/claude/skills/phlex-reactive-debugging/SKILL.md +105 -0
  17. data/lib/phlex/reactive/component/dsl.rb +70 -0
  18. data/lib/phlex/reactive/component/helpers.rb +193 -8
  19. data/lib/phlex/reactive/component/identity.rb +10 -1
  20. data/lib/phlex/reactive/component/lazy.rb +79 -0
  21. data/lib/phlex/reactive/component/registry.rb +7 -1
  22. data/lib/phlex/reactive/component.rb +1 -0
  23. data/lib/phlex/reactive/defer.rb +363 -0
  24. data/lib/phlex/reactive/deferred_render_job.rb +116 -0
  25. data/lib/phlex/reactive/doctor.rb +79 -11
  26. data/lib/phlex/reactive/engine.rb +16 -2
  27. data/lib/phlex/reactive/inspector/report.rb +140 -0
  28. data/lib/phlex/reactive/inspector.rb +253 -0
  29. data/lib/phlex/reactive/log_subscriber.rb +10 -0
  30. data/lib/phlex/reactive/mcp/base_tool.rb +57 -0
  31. data/lib/phlex/reactive/mcp/runner.rb +24 -0
  32. data/lib/phlex/reactive/mcp/server.rb +47 -0
  33. data/lib/phlex/reactive/mcp/tools/actions_tool.rb +43 -0
  34. data/lib/phlex/reactive/mcp/tools/components_tool.rb +39 -0
  35. data/lib/phlex/reactive/mcp/tools/config_tool.rb +74 -0
  36. data/lib/phlex/reactive/mcp/tools/doctor_tool.rb +36 -0
  37. data/lib/phlex/reactive/mcp/tools/find_tool.rb +66 -0
  38. data/lib/phlex/reactive/mcp.rb +58 -0
  39. data/lib/phlex/reactive/reply.rb +18 -0
  40. data/lib/phlex/reactive/response.rb +47 -2
  41. data/lib/phlex/reactive/streamable.rb +5 -1
  42. data/lib/phlex/reactive/version.rb +1 -1
  43. data/lib/phlex/reactive.rb +253 -3
  44. data/lib/tasks/phlex_reactive.rake +28 -0
  45. metadata +22 -1
@@ -28,6 +28,23 @@ module Phlex
28
28
  Phlex::Reactive.current_connection_id
29
29
  end
30
30
 
31
+ # Manually satisfy the verify_authorized guard (issue #168) for a bespoke
32
+ # authorization check the interceptor can't see — a hand-rolled policy, a
33
+ # feature flag, an ownership comparison that doesn't go through one of
34
+ # Phlex::Reactive.authorization_methods:
35
+ #
36
+ # def publish
37
+ # raise NotAllowed unless @post.author == Current.user
38
+ # mark_authorized!
39
+ # @post.update!(published: true)
40
+ # end
41
+ #
42
+ # Call it only AFTER your check passes — it asserts "I have authorized
43
+ # this action." A no-op when verify_authorized is off.
44
+ def mark_authorized!
45
+ Phlex::Reactive::Authorization.mark!
46
+ end
47
+
31
48
  # Subject-bound reply builder — the preferred way to control an action's
32
49
  # reply. `reply.replace.flash(:error, msg)` reads cleaner than
33
50
  # `Phlex::Reactive::Response.replace(self).flash(:error, msg)`: the
@@ -433,18 +450,126 @@ module Phlex
433
450
  "in: — got #{predicates.keys.inspect}"
434
451
  end
435
452
 
436
- key, value = predicates.first
453
+ key, value = normalize_show_predicate(predicates, "reactive_show(#{field.inspect})").first
437
454
  data = { reactive_show_field: field.to_s }
438
- if key == :in
439
- list = Array(value).map(&:to_s)
440
- raise ArgumentError, "reactive_show(#{field.inspect}) in: needs at least one value" if list.empty?
455
+ data[:"reactive_show_#{key}"] = key == "in" ? value.to_json : value
456
+
457
+ mix({ data: }, attrs)
458
+ end
459
+
460
+ # Client-side option filtering for the searchable combobox (issue #163)
461
+ # — the "preload + type to narrow" half of #72's keyboard nav, entirely
462
+ # client-side. Spread onto the ROOT (mix with reactive_root); it names
463
+ # the input whose value drives the filter and the option elements to
464
+ # show/hide, and the generic controller toggles `hidden` on every
465
+ # keystroke by substring-matching each option's haystack — no round
466
+ # trip, no token, no bespoke per-feature controller:
467
+ #
468
+ # div(**mix(reactive_root, reactive_filter(
469
+ # input: "#exercise-search",
470
+ # option: "[role=option]",
471
+ # group: "[data-filter-group]", # optional: collapse empty group headers
472
+ # empty: "#no-matches" # optional: reveal when 0 match
473
+ # ))) { … }
474
+ #
475
+ # Each option's haystack is its `data-reactive-filter-text` attribute
476
+ # (server-rendered — pack in synonyms/categories), falling back to the
477
+ # option's own text. Matching is a case-folded substring test — a
478
+ # DECLARED literal match, never an expression (no eval surface, the
479
+ # reactive_show posture). `group:` hides any group element whose every
480
+ # contained option is hidden; `empty:` reveals the no-matches node when
481
+ # 0 options are visible. The client seeds at connect and re-syncs after
482
+ # a morph; selectors resolve WITHIN this root only (#15 ownership).
483
+ #
484
+ # Filtering composes with reactive_listnav (Arrow/Enter/Escape skip
485
+ # hidden options) and each option's own on(:select, …) trigger —
486
+ # selection still round-trips as a signed action; only FILTERING is
487
+ # local. Blank selectors raise: a dead binding must fail at render.
488
+ def reactive_filter(input:, option:, group: nil, empty: nil)
489
+ data = {
490
+ reactive_filter_input: filter_selector!(:input, input),
491
+ reactive_filter_option: filter_selector!(:option, option)
492
+ }
493
+ data[:reactive_filter_group] = filter_selector!(:group, group) if group
494
+ data[:reactive_filter_empty] = filter_selector!(:empty, empty) if empty
495
+
496
+ { data: }
497
+ end
498
+
499
+ # STANDALONE combobox keyboard navigation (issue #163) — the same
500
+ # Arrow/Enter/Escape wiring `on(…, listnav:)` appends, without the
501
+ # dispatch descriptor. A preload-and-filter combobox input fires NO
502
+ # action (filtering is pure client), so it can't carry on(); spread
503
+ # this onto the input instead:
504
+ #
505
+ # input(id: "search", type: "search", **reactive_listnav("[role=option]"))
506
+ #
507
+ # Arrow keys move the client-side highlight among the (visible) options,
508
+ # Enter picks the highlighted one by clicking its own reactive trigger
509
+ # (selection stays a signed action), Escape clears. Combine with other
510
+ # attrs via mix so a caller's data-action token-joins, not clobbers.
511
+ def reactive_listnav(option_selector)
512
+ {
513
+ data: {
514
+ action: LISTNAV_ACTIONS.join(" "),
515
+ reactive_listnav_option_param: filter_selector!(:selector, option_selector)
516
+ }
517
+ }
518
+ end
519
+
520
+ # CROSS-ROOT value-conditional visibility (issue #164) — the visibility
521
+ # parallel to reactive_compute's `mirror:` (#159). A plain reactive_show
522
+ # is root-scoped by design (#15), so it can't express "this control
523
+ # drives elements ELSEWHERE on the page" — a nav tab, a panel in another
524
+ # tab pane, a sidebar note. reactive_show_targets is the declared,
525
+ # id-allowlisted escape: the component that OWNS the field declares
526
+ # which outside ids it governs. Spread it on the ROOT (mix alongside
527
+ # reactive_root — the client reads it off the controller element):
528
+ #
529
+ # div(**mix(reactive_root, reactive_show_targets(:mode,
530
+ # "#advanced-tab" => { equals: "advanced" },
531
+ # "#advanced-panel" => { equals: "advanced" },
532
+ # "#basic-note" => { not: "advanced" })))
533
+ #
534
+ # Same posture as mirror: — opt-in and declared, never implicit (a plain
535
+ # reactive_show stays root-isolated); targets are SINGLE ID SELECTORS
536
+ # only, enforced here at declare time AND warn-and-skipped by the client
537
+ # interpreter (two-sided default-deny); the predicate is the same
538
+ # literal-only reactive_show vocabulary (exactly one of equals:/not:/
539
+ # in:, no expressions); and the toggle is `hidden` only — no innerHTML,
540
+ # no attribute freedom. The FIELD read stays owned (#15): you can only
541
+ # drive outside visibility from a field this root owns. A target id not
542
+ # on the page is silently skipped (an unrendered tab pane is normal).
543
+ #
544
+ # ONE call per root. Phlex `mix` space-joins duplicate STRING data
545
+ # values, so a second call's JSON would concatenate into an unparseable
546
+ # attr and the client would drop BOTH maps (it warns when that
547
+ # happens). Several fields therefore go in ONE call via the hash form:
548
+ #
549
+ # reactive_show_targets(mode: { "#advanced-tab" => { equals: "advanced" } },
550
+ # kind: { "#premium-note" => { not: "basic" } })
551
+ def reactive_show_targets(field, targets = nil)
552
+ field_maps = targets.nil? ? field : { field => targets }
553
+ unless field_maps.is_a?(Hash) && field_maps.any?
554
+ raise ArgumentError,
555
+ "reactive_show_targets needs at least one target " \
556
+ "(:field, \"#id\" => { equals:/not:/in: ... }), got #{field_maps.inspect}"
557
+ end
558
+
559
+ normalized = field_maps.to_h do |name, map|
560
+ # Catch the forgotten-field-name misuse — reactive_show_targets(
561
+ # "#id" => {…}) — before the per-target validation turns it into a
562
+ # baffling "predicate" error.
563
+ if name.to_s.start_with?("#")
564
+ raise ArgumentError,
565
+ "reactive_show_targets: #{name.inspect} looks like a target selector, not a field " \
566
+ "name — call reactive_show_targets(:field, #{name.inspect} => { ... })"
567
+ end
441
568
 
442
- data[:reactive_show_in] = list.to_json
443
- else
444
- data[:"reactive_show_#{key}"] = value.to_s
569
+ [name.to_s, normalize_show_target_map(name, map)]
445
570
  end
446
571
 
447
- mix({ data: }, attrs)
572
+ { data: { reactive_show_targets: normalized.to_json } }
448
573
  end
449
574
 
450
575
  # Scoped busy indicator (issue #99). Marks an element so the generic
@@ -549,6 +674,66 @@ module Phlex
549
674
 
550
675
  private
551
676
 
677
+ # A reactive_filter/reactive_listnav selector, validated non-blank and
678
+ # stringified (issue #163). A blank selector is a dead binding — the
679
+ # client would silently match nothing — so it fails loudly at render,
680
+ # like reactive_show's predicate validation.
681
+ def filter_selector!(name, value)
682
+ selector = value.to_s
683
+ if selector.strip.empty?
684
+ raise ArgumentError,
685
+ "reactive_filter/reactive_listnav #{name}: needs a CSS selector, got #{value.inspect}"
686
+ end
687
+
688
+ selector
689
+ end
690
+
691
+ # Normalize + validate ONE field's target map (issue #164): each key a
692
+ # single id selector (loud raise — the declare-time half of the
693
+ # two-sided default-deny), each value one literal predicate. Shared by
694
+ # both reactive_show_targets call forms.
695
+ def normalize_show_target_map(field, targets)
696
+ unless targets.is_a?(Hash) && targets.any?
697
+ raise ArgumentError,
698
+ "reactive_show_targets(#{field.inspect}) needs at least one target " \
699
+ "(\"#id\" => { equals:/not:/in: ... }), got #{targets.inspect}"
700
+ end
701
+
702
+ targets.to_h do |selector, predicate|
703
+ selector = selector.to_s
704
+ unless selector.match?(DSL::MIRROR_ID_SELECTOR)
705
+ raise ArgumentError,
706
+ "reactive_show_targets(#{field.inspect}) target #{selector.inspect} must be a single " \
707
+ "ID selector (\"#id\") — cross-root visibility is id-allowlisted, like mirror: (#159)"
708
+ end
709
+
710
+ context = "reactive_show_targets(#{field.inspect}) target #{selector.inspect}"
711
+ [selector, normalize_show_predicate(predicate.is_a?(Hash) ? predicate : {}, context)]
712
+ end
713
+ end
714
+
715
+ # Validate ONE declared literal show predicate (issues #161/#164) and
716
+ # return its wire form — { "equals" => "v" } / { "not" => "v" } /
717
+ # { "in" => ["a", …] } (a real array: reactive_show JSON-encodes it into
718
+ # its own attr; the reactive_show_targets map embeds it directly).
719
+ # Shared by both helpers so the vocabulary and the loud validation can
720
+ # never drift. `context` names the call site in the error.
721
+ def normalize_show_predicate(predicates, context)
722
+ unless predicates.size == 1 && SHOW_PREDICATE_KEYS.include?(predicates.keys.first)
723
+ raise ArgumentError,
724
+ "#{context} needs exactly one predicate — equals:, not:, or in: — " \
725
+ "got #{predicates.keys.inspect}"
726
+ end
727
+
728
+ key, value = predicates.first
729
+ return { key.to_s => value.to_s } unless key == :in
730
+
731
+ list = Array(value).map(&:to_s)
732
+ raise ArgumentError, "#{context} in: needs at least one value" if list.empty?
733
+
734
+ { "in" => list }
735
+ end
736
+
552
737
  # True when the hint declares checked: :keep — the click-bound
553
738
  # checkbox/radio case that must SKIP the forced type="button" so the native
554
739
  # control (and its native flip) survives (issue #98). Accepts symbol or
@@ -55,6 +55,15 @@ module Phlex
55
55
  # declared, the token carries just {c} — enough to mount, but with no
56
56
  # identity to round-trip, so declare reactive_state for a draft you sync.
57
57
  def reactive_token
58
+ Phlex::Reactive.sign(reactive_identity_payload)
59
+ end
60
+
61
+ # The UNSIGNED identity payload — extracted from reactive_token (issue
62
+ # #165) so the defer machinery mints its purpose-scoped defer tokens
63
+ # from the exact same shape and the two token families can never drift.
64
+ # Still THE hot path (reactive_token calls it on every render); the
65
+ # split adds one method dispatch, verified flat by the token bench.
66
+ def reactive_identity_payload
58
67
  klass = self.class
59
68
  payload = { "c" => klass.name }
60
69
 
@@ -75,7 +84,7 @@ module Phlex
75
84
  payload["s"] = state
76
85
  end
77
86
 
78
- Phlex::Reactive.sign(payload)
87
+ payload
79
88
  end
80
89
 
81
90
  # A record's gid is signable unless it is an unsaved AR-like draft
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phlex
4
+ module Reactive
5
+ module Component
6
+ # Lazy initial mount (issue #165): a `reactive_lazy` component's FIRST
7
+ # (page-embedded) render emits a placeholder SHELL — the root id, the
8
+ # generic controller, the pending markers, and a defer token ON the root
9
+ # — and the client fetches the real content through the same defer
10
+ # machinery on connect (the Livewire #[Lazy] shape).
11
+ #
12
+ # Lazy applies ONLY to the initial mount. Every render that goes through
13
+ # the reactive machinery — an action reply's self-replace, a broadcast,
14
+ # the defer endpoint/job, the class stream builders — runs inside
15
+ # Defer.with_real_render, so it renders the REAL template. Anything else
16
+ # would make an action on a lazy component cost two round trips.
17
+ module Lazy
18
+ extend ActiveSupport::Concern
19
+
20
+ private
21
+
22
+ # Phlex 2's render hook: the template runs inside the block. Overridden
23
+ # (not yield-then-decorate) so the lazy shell can REPLACE the template
24
+ # entirely on the initial mount. A component's own around_template
25
+ # override composes via normal method lookup as long as it calls super.
26
+ def around_template(&)
27
+ klass = self.class
28
+ if klass.respond_to?(:reactive_lazy?) && klass.reactive_lazy? && !Phlex::Reactive::Defer.real_render?
29
+ render_defer_shell
30
+ else
31
+ super
32
+ end
33
+ end
34
+
35
+ # The shell: owns the component's id (the arrival replaces it by that
36
+ # id), mounts the generic controller, and carries the defer token as a
37
+ # ROOT attribute — the controller's connect() probes it and enters the
38
+ # same module-level fetch path a reply directive uses. Pending markers
39
+ # + the .reactive-defer-placeholder class are the CSS hooks.
40
+ def render_defer_shell
41
+ public_send(
42
+ self.class.reactive_lazy_tag,
43
+ id:,
44
+ class: "reactive-defer-placeholder",
45
+ aria: { busy: "true" },
46
+ data: {
47
+ controller: "reactive",
48
+ reactive_defer_pending: "true",
49
+ # UNBOUND (issue #165 security): a lazy shell renders during the
50
+ # page render — on a fresh visit the session doesn't exist yet, so
51
+ # the token can't be actor-bound (it would 400 at the endpoint,
52
+ # which by then IS bound). It lives in the actor's own page (a
53
+ # small leak surface); the TTL + `authorize!` are its bound. Only
54
+ # reply.defer tokens (in action responses that can transit proxies)
55
+ # are actor-bound.
56
+ reactive_defer_token: Phlex::Reactive.sign_defer(reactive_identity_payload, unbound: true)
57
+ }
58
+ ) { render_deferred_placeholder_content }
59
+ end
60
+
61
+ # Same content contract as reply.defer's placeholder: true — the
62
+ # component's deferred_placeholder returns a Phlex component instance
63
+ # (rendered), an html_safe String (raw), or a plain String (escaped
64
+ # text — data, not markup). No method / nil → the empty shell.
65
+ def render_deferred_placeholder_content
66
+ return unless respond_to?(:deferred_placeholder, true)
67
+
68
+ value = send(:deferred_placeholder)
69
+ case value
70
+ when nil then nil
71
+ when ::Phlex::SGML then render(value)
72
+ else
73
+ value.html_safe? ? raw(value) : plain(value.to_s)
74
+ end
75
+ end
76
+ end
77
+ end
78
+ end
79
+ end
@@ -50,7 +50,13 @@ module Phlex
50
50
  state_keys: :@reactive_own_state_keys,
51
51
  collections: :@reactive_own_collections,
52
52
  computes: :@reactive_own_computes,
53
- record_key: :@reactive_own_record_key
53
+ record_key: :@reactive_own_record_key,
54
+ # Deferred reply segments — reactive_lazy (issue #165).
55
+ lazy: :@reactive_own_lazy,
56
+ # verify_authorized opt-out (issue #168): a scalar bare flag (skip the
57
+ # WHOLE component) plus a list of named actions.
58
+ skip_all: :@reactive_own_skip_all,
59
+ skip_actions: :@reactive_own_skip_actions
54
60
  }.freeze
55
61
 
56
62
  # The identity-side hot-path memos swept by bump! (see the contract
@@ -114,6 +114,7 @@ module Phlex
114
114
  include DSL
115
115
  include Identity
116
116
  include Helpers
117
+ include Lazy
117
118
  end
118
119
  end
119
120
  end