phlex-reactive 0.9.3 → 0.9.5

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 +212 -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 +659 -10
  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 +348 -21
  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
@@ -424,27 +441,152 @@ module Phlex
424
441
  # after a morph; render the initial `hidden:` yourself (from the same
425
442
  # server state that renders the field) to avoid a first-paint flash.
426
443
  # Extra attrs deep-merge over the binding (mix), like reactive_field.
427
- def reactive_show(field, **options)
428
- predicates = options.slice(*SHOW_PREDICATE_KEYS)
429
- attrs = options.except(*SHOW_PREDICATE_KEYS)
430
- unless predicates.size == 1
444
+ # Compound forms (issue #176 part A) — visibility that depends on MORE
445
+ # THAN ONE field, or a threshold, staying inside the eval-free literal
446
+ # contract. `all:`/`any:` fold a list of per-field terms (each the same
447
+ # equals:/not:/in:/gte:/gt:/lte:/lt: vocabulary) with one fixed
448
+ # connective; the compound has no single controlling field, so it
449
+ # serializes as ONE JSON attr (data-reactive-show) like
450
+ # reactive_show_targets rather than the flat data-reactive-show-* attrs:
451
+ #
452
+ # # visible while type == "individual" AND country != "domestic"
453
+ # div(**reactive_show(all: [
454
+ # { field: :type, equals: "individual" },
455
+ # { field: :country, not: "domestic" }
456
+ # ]))
457
+ # # visible while director OR shareholder is checked
458
+ # div(**reactive_show(any: [
459
+ # { field: :director, equals: true },
460
+ # { field: :shareholder, equals: true }
461
+ # ]))
462
+ # # a numeric threshold, single field or as a compound term
463
+ # div(**reactive_show(:quantity, gte: 10)) # visible while qty >= 10
464
+ # div(**reactive_show(all: [{ field: :amount, gte: 5000 }, ...]))
465
+ #
466
+ # `all:`/`any:` are additive and mutually exclusive with the single-field
467
+ # form and with each other — enforced loudly at render, like the
468
+ # one-predicate rule. A single-field `all:` with one term degrades to the
469
+ # flat form's behaviour client-side.
470
+ def reactive_show(field = nil, **options)
471
+ connectives = options.slice(*SHOW_CONNECTIVE_KEYS)
472
+ return reactive_show_compound(field, connectives, options) if connectives.any?
473
+
474
+ reactive_show_single(field, options)
475
+ end
476
+
477
+ # Client-side option filtering for the searchable combobox (issue #163)
478
+ # — the "preload + type to narrow" half of #72's keyboard nav, entirely
479
+ # client-side. Spread onto the ROOT (mix with reactive_root); it names
480
+ # the input whose value drives the filter and the option elements to
481
+ # show/hide, and the generic controller toggles `hidden` on every
482
+ # keystroke by substring-matching each option's haystack — no round
483
+ # trip, no token, no bespoke per-feature controller:
484
+ #
485
+ # div(**mix(reactive_root, reactive_filter(
486
+ # input: "#exercise-search",
487
+ # option: "[role=option]",
488
+ # group: "[data-filter-group]", # optional: collapse empty group headers
489
+ # empty: "#no-matches" # optional: reveal when 0 match
490
+ # ))) { … }
491
+ #
492
+ # Each option's haystack is its `data-reactive-filter-text` attribute
493
+ # (server-rendered — pack in synonyms/categories), falling back to the
494
+ # option's own text. Matching is a case-folded substring test — a
495
+ # DECLARED literal match, never an expression (no eval surface, the
496
+ # reactive_show posture). `group:` hides any group element whose every
497
+ # contained option is hidden; `empty:` reveals the no-matches node when
498
+ # 0 options are visible. The client seeds at connect and re-syncs after
499
+ # a morph; selectors resolve WITHIN this root only (#15 ownership).
500
+ #
501
+ # Filtering composes with reactive_listnav (Arrow/Enter/Escape skip
502
+ # hidden options) and each option's own on(:select, …) trigger —
503
+ # selection still round-trips as a signed action; only FILTERING is
504
+ # local. Blank selectors raise: a dead binding must fail at render.
505
+ def reactive_filter(input:, option:, group: nil, empty: nil)
506
+ data = {
507
+ reactive_filter_input: filter_selector!(:input, input),
508
+ reactive_filter_option: filter_selector!(:option, option)
509
+ }
510
+ data[:reactive_filter_group] = filter_selector!(:group, group) if group
511
+ data[:reactive_filter_empty] = filter_selector!(:empty, empty) if empty
512
+
513
+ { data: }
514
+ end
515
+
516
+ # STANDALONE combobox keyboard navigation (issue #163) — the same
517
+ # Arrow/Enter/Escape wiring `on(…, listnav:)` appends, without the
518
+ # dispatch descriptor. A preload-and-filter combobox input fires NO
519
+ # action (filtering is pure client), so it can't carry on(); spread
520
+ # this onto the input instead:
521
+ #
522
+ # input(id: "search", type: "search", **reactive_listnav("[role=option]"))
523
+ #
524
+ # Arrow keys move the client-side highlight among the (visible) options,
525
+ # Enter picks the highlighted one by clicking its own reactive trigger
526
+ # (selection stays a signed action), Escape clears. Combine with other
527
+ # attrs via mix so a caller's data-action token-joins, not clobbers.
528
+ def reactive_listnav(option_selector)
529
+ {
530
+ data: {
531
+ action: LISTNAV_ACTIONS.join(" "),
532
+ reactive_listnav_option_param: filter_selector!(:selector, option_selector)
533
+ }
534
+ }
535
+ end
536
+
537
+ # CROSS-ROOT value-conditional visibility (issue #164) — the visibility
538
+ # parallel to reactive_compute's `mirror:` (#159). A plain reactive_show
539
+ # is root-scoped by design (#15), so it can't express "this control
540
+ # drives elements ELSEWHERE on the page" — a nav tab, a panel in another
541
+ # tab pane, a sidebar note. reactive_show_targets is the declared,
542
+ # id-allowlisted escape: the component that OWNS the field declares
543
+ # which outside ids it governs. Spread it on the ROOT (mix alongside
544
+ # reactive_root — the client reads it off the controller element):
545
+ #
546
+ # div(**mix(reactive_root, reactive_show_targets(:mode,
547
+ # "#advanced-tab" => { equals: "advanced" },
548
+ # "#advanced-panel" => { equals: "advanced" },
549
+ # "#basic-note" => { not: "advanced" })))
550
+ #
551
+ # Same posture as mirror: — opt-in and declared, never implicit (a plain
552
+ # reactive_show stays root-isolated); targets are SINGLE ID SELECTORS
553
+ # only, enforced here at declare time AND warn-and-skipped by the client
554
+ # interpreter (two-sided default-deny); the predicate is the same
555
+ # literal-only reactive_show vocabulary (exactly one of equals:/not:/
556
+ # in:, no expressions); and the toggle is `hidden` only — no innerHTML,
557
+ # no attribute freedom. The FIELD read stays owned (#15): you can only
558
+ # drive outside visibility from a field this root owns. A target id not
559
+ # on the page is silently skipped (an unrendered tab pane is normal).
560
+ #
561
+ # ONE call per root. Phlex `mix` space-joins duplicate STRING data
562
+ # values, so a second call's JSON would concatenate into an unparseable
563
+ # attr and the client would drop BOTH maps (it warns when that
564
+ # happens). Several fields therefore go in ONE call via the hash form:
565
+ #
566
+ # reactive_show_targets(mode: { "#advanced-tab" => { equals: "advanced" } },
567
+ # kind: { "#premium-note" => { not: "basic" } })
568
+ def reactive_show_targets(field, targets = nil)
569
+ field_maps = targets.nil? ? field : { field => targets }
570
+ unless field_maps.is_a?(Hash) && field_maps.any?
431
571
  raise ArgumentError,
432
- "reactive_show(#{field.inspect}) needs exactly one predicate — equals:, not:, or " \
433
- "in: got #{predicates.keys.inspect}"
572
+ "reactive_show_targets needs at least one target " \
573
+ "(:field, \"#id\" => { equals:/not:/in: ... }), got #{field_maps.inspect}"
434
574
  end
435
575
 
436
- key, value = predicates.first
437
- 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?
576
+ normalized = field_maps.to_h do |name, map|
577
+ # Catch the forgotten-field-name misuse — reactive_show_targets(
578
+ # "#id" => {…}) — before the per-target validation turns it into a
579
+ # baffling "predicate" error.
580
+ if name.to_s.start_with?("#")
581
+ raise ArgumentError,
582
+ "reactive_show_targets: #{name.inspect} looks like a target selector, not a field " \
583
+ "name — call reactive_show_targets(:field, #{name.inspect} => { ... })"
584
+ end
441
585
 
442
- data[:reactive_show_in] = list.to_json
443
- else
444
- data[:"reactive_show_#{key}"] = value.to_s
586
+ [name.to_s, normalize_show_target_map(name, map)]
445
587
  end
446
588
 
447
- mix({ data: }, attrs)
589
+ { data: { reactive_show_targets: normalized.to_json } }
448
590
  end
449
591
 
450
592
  # Scoped busy indicator (issue #99). Marks an element so the generic
@@ -533,12 +675,33 @@ module Phlex
533
675
  reactive_record_for_nested.update!(**nested_attributes(association, attrs), **extra)
534
676
  end
535
677
 
536
- # The declared reactive_show predicates (issue #161): a literal match on
537
- # the controlling field's current value. Exactly one per binding —
538
- # enforced loudly at render (a dead binding must not no-op in the
539
- # browser). `not`/`in` are Ruby keywords, so they arrive via **options
540
- # rather than named kwargs.
541
- SHOW_PREDICATE_KEYS = %i[equals not in].freeze
678
+ # The declared reactive_show predicates: a literal match on the
679
+ # controlling field's current value. Exactly one per binding — enforced
680
+ # loudly at render (a dead binding must not no-op in the browser).
681
+ # `not`/`in` are Ruby keywords, so they arrive via **options rather than
682
+ # named kwargs. Issue #161 shipped the string-literal trio; issue #176
683
+ # adds the numeric-threshold quartet below.
684
+ SHOW_LITERAL_KEYS = %i[equals not in].freeze
685
+
686
+ # The numeric-threshold predicates (issue #176 part B): gte/gt/lte/lt
687
+ # compare the field value coerced to a Number against a literal number.
688
+ # Still a declared literal — the RHS is a number baked into the
689
+ # attribute, never an expression — the only new capability is ordered
690
+ # comparison. The RHS must be an actual Numeric in Ruby (stricter, so a
691
+ # typo like gte: "10" fails at render, not silently in the browser).
692
+ SHOW_NUMERIC_KEYS = %i[gte gt lte lt].freeze
693
+
694
+ # Every predicate key a reactive_show / reactive_show_targets binding may
695
+ # declare — the literal trio plus the numeric quartet. Exactly one of
696
+ # these decides a single binding; a compound term declares one too.
697
+ SHOW_PREDICATE_KEYS = (SHOW_LITERAL_KEYS + SHOW_NUMERIC_KEYS).freeze
698
+
699
+ # The compound connectives (issue #176 part A): fold a list of per-field
700
+ # literal/numeric terms with ONE fixed keyword. Not an expression surface
701
+ # — the connective is one of two fixed keywords, each term is the same
702
+ # declared predicate vocabulary. Mutually exclusive with a single field
703
+ # and with each other (enforced loudly at render).
704
+ SHOW_CONNECTIVE_KEYS = %i[all any].freeze
542
705
 
543
706
  # The declared optimistic-hint class ops (issue #98): the cosmetic class
544
707
  # vocabulary the client applies instantly and reverts on failure. Enforced
@@ -549,6 +712,170 @@ module Phlex
549
712
 
550
713
  private
551
714
 
715
+ # A reactive_filter/reactive_listnav selector, validated non-blank and
716
+ # stringified (issue #163). A blank selector is a dead binding — the
717
+ # client would silently match nothing — so it fails loudly at render,
718
+ # like reactive_show's predicate validation.
719
+ def filter_selector!(name, value)
720
+ selector = value.to_s
721
+ if selector.strip.empty?
722
+ raise ArgumentError,
723
+ "reactive_filter/reactive_listnav #{name}: needs a CSS selector, got #{value.inspect}"
724
+ end
725
+
726
+ selector
727
+ end
728
+
729
+ # Normalize + validate ONE field's target map (issue #164): each key a
730
+ # single id selector (loud raise — the declare-time half of the
731
+ # two-sided default-deny), each value one literal predicate. Shared by
732
+ # both reactive_show_targets call forms.
733
+ def normalize_show_target_map(field, targets)
734
+ unless targets.is_a?(Hash) && targets.any?
735
+ raise ArgumentError,
736
+ "reactive_show_targets(#{field.inspect}) needs at least one target " \
737
+ "(\"#id\" => { equals:/not:/in: ... }), got #{targets.inspect}"
738
+ end
739
+
740
+ targets.to_h do |selector, predicate|
741
+ selector = selector.to_s
742
+ unless selector.match?(DSL::MIRROR_ID_SELECTOR)
743
+ raise ArgumentError,
744
+ "reactive_show_targets(#{field.inspect}) target #{selector.inspect} must be a single " \
745
+ "ID selector (\"#id\") — cross-root visibility is id-allowlisted, like mirror: (#159)"
746
+ end
747
+
748
+ context = "reactive_show_targets(#{field.inspect}) target #{selector.inspect}"
749
+ [selector, normalize_show_predicate(predicate.is_a?(Hash) ? predicate : {}, context)]
750
+ end
751
+ end
752
+
753
+ # Validate ONE declared show predicate (issues #161/#164/#176) and
754
+ # return its wire form — { "equals" => "v" } / { "not" => "v" } /
755
+ # { "in" => ["a", …] } / { "gte" => 10 } (a numeric RHS stays a real
756
+ # Number so the wire carries a JSON number, not a string). reactive_show
757
+ # JSON-encodes an in: array into its own attr; the reactive_show_targets
758
+ # map and a compound term embed the predicate directly. Shared by every
759
+ # helper so the vocabulary and the loud validation can never drift.
760
+ # `context` names the call site in the error.
761
+ def normalize_show_predicate(predicates, context)
762
+ unless predicates.size == 1 && SHOW_PREDICATE_KEYS.include?(predicates.keys.first)
763
+ raise ArgumentError,
764
+ "#{context} needs exactly one predicate — equals:, not:, in:, or " \
765
+ "gte:/gt:/lte:/lt: — got #{predicates.keys.inspect}"
766
+ end
767
+
768
+ key, value = predicates.first
769
+ return normalize_show_numeric(key, value, context) if SHOW_NUMERIC_KEYS.include?(key)
770
+ return { key.to_s => value.to_s } unless key == :in
771
+
772
+ list = Array(value).map(&:to_s)
773
+ raise ArgumentError, "#{context} in: needs at least one value" if list.empty?
774
+
775
+ { "in" => list }
776
+ end
777
+
778
+ # Validate a numeric threshold predicate (issue #176 part B): the RHS
779
+ # must be an actual Numeric (a String "10" is a typo caught here, not a
780
+ # silent NaN in the browser). It rides the wire as a JSON number so the
781
+ # client compares Number(value) against it directly.
782
+ def normalize_show_numeric(key, value, context)
783
+ unless value.is_a?(Numeric)
784
+ raise ArgumentError,
785
+ "#{context} #{key}: needs a number (an ordered comparison against a literal), " \
786
+ "got #{value.inspect}"
787
+ end
788
+
789
+ { key.to_s => value }
790
+ end
791
+
792
+ # The single-field reactive_show form (issue #161, extended for numeric
793
+ # thresholds in #176): one owned field + one predicate → flat
794
+ # data-reactive-show-* attrs. An in: list JSON-encodes into its own attr;
795
+ # a numeric threshold or a literal stringifies into the flat attr and the
796
+ # client Number()-coerces the numeric case back on read.
797
+ def reactive_show_single(field, options)
798
+ if field.nil?
799
+ raise ArgumentError,
800
+ "reactive_show needs a field (reactive_show(:mode, equals: …)) or a compound " \
801
+ "connective (reactive_show(all: [...]) / any:) — got neither"
802
+ end
803
+
804
+ predicates = options.slice(*SHOW_PREDICATE_KEYS)
805
+ attrs = options.except(*SHOW_PREDICATE_KEYS)
806
+ unless predicates.size == 1
807
+ raise ArgumentError,
808
+ "reactive_show(#{field.inspect}) needs exactly one predicate — equals:, not:, in:, " \
809
+ "or gte:/gt:/lte:/lt: — got #{predicates.keys.inspect}"
810
+ end
811
+
812
+ key, value = normalize_show_predicate(predicates, "reactive_show(#{field.inspect})").first
813
+ data = { reactive_show_field: field.to_s }
814
+ # in: → JSON array; a numeric threshold or a literal → the value as-is.
815
+ # Phlex stringifies it into the flat attr; the client re-reads via
816
+ # getAttribute (always a string) and Number()-coerces the numeric case.
817
+ data[:"reactive_show_#{key}"] = key == "in" ? value.to_json : value.to_s
818
+
819
+ mix({ data: }, attrs)
820
+ end
821
+
822
+ # The compound reactive_show form (issue #176 part A): `all:`/`any:` fold
823
+ # a list of per-field terms with one fixed connective, serialized as one
824
+ # JSON attr (data-reactive-show). A field alongside a connective, or two
825
+ # connectives, is a contradiction — raise. Each term is validated through
826
+ # the SAME predicate normalizer, so a term's vocabulary never drifts from
827
+ # the single-field form.
828
+ def reactive_show_compound(field, connectives, options)
829
+ unless field.nil?
830
+ raise ArgumentError,
831
+ "reactive_show got a field AND all:/any: — the compound and single-field forms are " \
832
+ "mutually exclusive; use one flat binding OR a compound list, not both"
833
+ end
834
+ unless connectives.size == 1
835
+ raise ArgumentError,
836
+ "reactive_show needs exactly one of all:/any: (one fixed connective), " \
837
+ "got #{connectives.keys.inspect}"
838
+ end
839
+
840
+ connective, terms = connectives.first
841
+ # A top-level predicate alongside a connective (reactive_show(all: [...],
842
+ # equals: "x")) is a misuse — predicates belong INSIDE terms. Catch it
843
+ # loudly at render rather than leaking `equals="x"` as a bogus HTML attr
844
+ # (the mix at the tail treats every leftover kwarg as a literal
845
+ # attribute). Same default-deny posture as the single-field path.
846
+ if (stray = options.slice(*SHOW_PREDICATE_KEYS)).any?
847
+ raise ArgumentError,
848
+ "reactive_show #{connective}: got a top-level predicate #{stray.keys.inspect} — " \
849
+ "predicates belong INSIDE each term ({ field: …, #{stray.keys.first}: … }), not beside the connective"
850
+ end
851
+
852
+ attrs = options.except(*SHOW_CONNECTIVE_KEYS)
853
+ unless terms.is_a?(Array) && terms.any?
854
+ raise ArgumentError,
855
+ "reactive_show #{connective}: needs at least one term " \
856
+ "({ field: …, equals:/not:/in:/gte:/… }), got #{terms.inspect}"
857
+ end
858
+
859
+ normalized = terms.map { normalize_show_term(connective, it) }
860
+ mix({ data: { reactive_show: { connective.to_s => normalized }.to_json } }, attrs)
861
+ end
862
+
863
+ # Normalize + validate ONE compound term (issue #176 part A): a Hash with
864
+ # a :field and exactly one predicate. The predicate goes through the
865
+ # shared normalizer; the field is stringified into the term. Returns
866
+ # { "field" => name, <predicate> } so the wire is uniform with the
867
+ # reactive_show_targets embedded-predicate shape.
868
+ def normalize_show_term(connective, term)
869
+ context = "reactive_show #{connective}: term"
870
+ unless term.is_a?(Hash) && term.key?(:field)
871
+ raise ArgumentError, "#{context} — each term needs a field (got #{term.inspect})"
872
+ end
873
+
874
+ field = term[:field]
875
+ predicate = normalize_show_predicate(term.except(:field), "#{context} (field #{field.inspect})")
876
+ { "field" => field.to_s }.merge(predicate)
877
+ end
878
+
552
879
  # True when the hint declares checked: :keep — the click-bound
553
880
  # checkbox/radio case that must SKIP the forced type="button" so the native
554
881
  # 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