phlex-reactive 0.9.2 → 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 +213 -0
  3. data/README.md +333 -1
  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 +651 -2
  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 +240 -0
  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
@@ -399,6 +416,162 @@ module Phlex
399
416
  span(**mix({ data: { reactive_text: name.to_s } }, attrs)) { initial }
400
417
  end
401
418
 
419
+ # Value-conditional visibility (issue #161) — the x-show / data-show /
420
+ # wire:show equivalent, entirely client-side. Spread onto the element to
421
+ # show/hide; it declares which OWNED field controls it plus ONE literal
422
+ # predicate, and the generic controller toggles the `hidden` attribute
423
+ # from the field's CURRENT value on every input/change — no round trip,
424
+ # no token, no bespoke Stimulus controller:
425
+ #
426
+ # div(**reactive_show(:mode, not: "off")) { "details" }
427
+ # div(**reactive_show(:kind, equals: "premium")) { "premium panel" }
428
+ # div(**reactive_show(:size, in: %w[l xl])) { "surcharge note" }
429
+ # div(**reactive_show(:gift, equals: true)) { "gift message" }
430
+ #
431
+ # Exactly one predicate — equals:, not:, or in: (a list) — and every
432
+ # value is STRINGIFIED for a literal match against the field's value
433
+ # (a checkbox compares its checked state as "true"/"false", so
434
+ # `equals: true` is the checkbox form; a radio group reads the checked
435
+ # radio's value). This is a DECLARED LITERAL MATCH, never an expression
436
+ # — there is no eval surface (default-deny, like the op vocabulary).
437
+ #
438
+ # Scope: presentational only, strictly less powerful than the js ops —
439
+ # it reads an owned field (#15 ownership) and toggles `hidden` on an
440
+ # owned element. The client seeds visibility at connect and reconciles
441
+ # after a morph; render the initial `hidden:` yourself (from the same
442
+ # server state that renders the field) to avoid a first-paint flash.
443
+ # Extra attrs deep-merge over the binding (mix), like reactive_field.
444
+ def reactive_show(field, **options)
445
+ predicates = options.slice(*SHOW_PREDICATE_KEYS)
446
+ attrs = options.except(*SHOW_PREDICATE_KEYS)
447
+ unless predicates.size == 1
448
+ raise ArgumentError,
449
+ "reactive_show(#{field.inspect}) needs exactly one predicate — equals:, not:, or " \
450
+ "in: — got #{predicates.keys.inspect}"
451
+ end
452
+
453
+ key, value = normalize_show_predicate(predicates, "reactive_show(#{field.inspect})").first
454
+ data = { reactive_show_field: field.to_s }
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
568
+
569
+ [name.to_s, normalize_show_target_map(name, map)]
570
+ end
571
+
572
+ { data: { reactive_show_targets: normalized.to_json } }
573
+ end
574
+
402
575
  # Scoped busy indicator (issue #99). Marks an element so the generic
403
576
  # controller toggles `data-reactive-busy` on it ONLY while `action` is in
404
577
  # flight — the scoped sibling of the always-on `data-reactive-busy` the
@@ -485,6 +658,13 @@ module Phlex
485
658
  reactive_record_for_nested.update!(**nested_attributes(association, attrs), **extra)
486
659
  end
487
660
 
661
+ # The declared reactive_show predicates (issue #161): a literal match on
662
+ # the controlling field's current value. Exactly one per binding —
663
+ # enforced loudly at render (a dead binding must not no-op in the
664
+ # browser). `not`/`in` are Ruby keywords, so they arrive via **options
665
+ # rather than named kwargs.
666
+ SHOW_PREDICATE_KEYS = %i[equals not in].freeze
667
+
488
668
  # The declared optimistic-hint class ops (issue #98): the cosmetic class
489
669
  # vocabulary the client applies instantly and reverts on failure. Enforced
490
670
  # at build time in optimistic_hint_json (default-deny — a dead hint fails
@@ -494,6 +674,66 @@ module Phlex
494
674
 
495
675
  private
496
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
+
497
737
  # True when the hint declares checked: :keep — the click-bound
498
738
  # checkbox/radio case that must SKIP the forced type="button" so the native
499
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