phlex-reactive 0.11.4 → 0.11.6

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.
@@ -35,6 +35,26 @@
35
35
  # flag only controls the gem's own log lines. See the README Observability section.
36
36
  # Phlex::Reactive.log_events = true
37
37
 
38
+ # Turnkey APM integration. Set to :appsignal / :sentry / :datadog and each
39
+ # reactive action shows in your APM as its OWN transaction ("Counter#increment"),
40
+ # not one blurry ActionsController#create — and an action-body crash is reported
41
+ # to the tracker WITH component/action tags (plus a flash to the user if you set
42
+ # error_flash below). The SDK is runtime-detected: if it isn't loaded, this logs
43
+ # one warning and no-ops (no gem dependency is added). Pass a custom object
44
+ # responding to record_action(payload, duration_ms) / record_error(error, payload)
45
+ # to integrate any other tool.
46
+ # Phlex::Reactive.apm = :appsignal
47
+ #
48
+ # For a tracker with no built-in adapter, report errors yourself — this fires on
49
+ # any previously-uncaught action-body error, with the name-only context:
50
+ # Phlex::Reactive.on_action_error do |error, ctx|
51
+ # Honeybadger.notify(error, context: { component: ctx[:component], action: ctx[:action] })
52
+ # end
53
+ #
54
+ # Show the user a flash when an action crashes (500) — the SAME hook the 4xx
55
+ # errors already use; `kind` is :error for a crash:
56
+ # Phlex::Reactive.error_flash = ->(kind) { "Something went wrong — please retry." }
57
+
38
58
  # Client debug mode (devtools-lite). When on, every reactive root carries
39
59
  # data-reactive-debug="true" and the browser console.groups EVERY dispatch —
40
60
  # action, param/collected field NAMES (never values), request encoding, HTTP
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phlex
4
+ module Reactive
5
+ module APM
6
+ # The adapter contract (issue #207). A custom APM integration need only be an
7
+ # object responding to these two methods — it does NOT have to subclass this;
8
+ # the class documents the interface and gives the built-in vendor adapters a
9
+ # shared home for the small helpers they all use.
10
+ #
11
+ # record_action(payload, duration_ms)
12
+ # payload is the name-only action/defer event payload
13
+ # ({ component:, action:, outcome: } — action:/nil for defer). Name the
14
+ # APM transaction and tag the outcome. Called on EVERY reactive action.
15
+ #
16
+ # record_error(error, payload)
17
+ # error is the raised exception; payload is the same name-only hash. Report
18
+ # the exception to the tracker WITH component/action tags. Called only on a
19
+ # previously-uncaught action-body error (a 500), just before the re-raise.
20
+ #
21
+ # A built-in adapter also answers `.available?` (a runtime `defined?(SDK)`
22
+ # probe) so the resolver can no-op when the SDK isn't loaded.
23
+ class Adapter
24
+ # The APM transaction name for a payload: "Component#action", or just the
25
+ # component when there's no action (a defer event), or nil when there's no
26
+ # trusted component (an invalid_token event carries none).
27
+ def transaction_name(payload)
28
+ component = payload[:component]
29
+ return nil unless component
30
+
31
+ action = payload[:action]
32
+ action ? "#{component}##{action}" : component
33
+ end
34
+
35
+ # Present but not enforced — a bare Adapter is not a usable APM. The vendor
36
+ # subclasses override all three.
37
+ def self.available? = false
38
+ def record_action(_payload, _duration_ms) = nil
39
+ def record_error(_error, _payload) = nil
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phlex
4
+ module Reactive
5
+ module APM
6
+ # AppSignal adapter (issue #207). Activates only when `::Appsignal` is loaded
7
+ # — never a gemspec dependency. Names the current transaction `Component#action`
8
+ # (so reactive traffic stops rolling into one ActionsController#create blob) and
9
+ # tags component/action/outcome; reports an action-body error with those tags.
10
+ class Appsignal < Adapter
11
+ def self.available? = defined?(::Appsignal) ? true : false
12
+
13
+ def record_action(payload, _duration_ms)
14
+ name = transaction_name(payload)
15
+ return unless name
16
+
17
+ ::Appsignal.set_action(name)
18
+ ::Appsignal.add_tags(tags(payload))
19
+ end
20
+
21
+ # Report the error with tags. AppSignal 4.x removed the positional
22
+ # tags/namespace args from set_error and requires the BLOCK form
23
+ # (set_error(error) { add_tags(...) }); 3.x accepts the positional hash.
24
+ # Branch on the method's arity so the adapter works on both majors without
25
+ # pinning a version — the capability-detection posture applied within a gem.
26
+ def record_error(error, payload)
27
+ if set_error_takes_tags?
28
+ ::Appsignal.set_error(error, tags(payload))
29
+ else
30
+ ::Appsignal.set_error(error) { ::Appsignal.add_tags(tags(payload)) }
31
+ end
32
+ end
33
+
34
+ private
35
+
36
+ # True on AppSignal 3.x, where set_error accepts a positional tags arg
37
+ # (arity != 1). On 4.x set_error takes only the error (arity 1) + a block.
38
+ def set_error_takes_tags?
39
+ ::Appsignal.method(:set_error).arity != 1
40
+ end
41
+
42
+ def tags(payload)
43
+ {
44
+ "reactive_component" => payload[:component],
45
+ "reactive_action" => payload[:action],
46
+ "reactive_outcome" => payload[:outcome]&.to_s
47
+ }.compact
48
+ end
49
+ end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phlex
4
+ module Reactive
5
+ module APM
6
+ # Datadog adapter (issue #207, dd-trace-rb). Activates only when
7
+ # `::Datadog::Tracing` is loaded. Renames the ACTIVE span `Component#action`
8
+ # + tags it, so the reactive action shows as its own resource in the trace;
9
+ # marks the active span errored on an action-body crash. No-ops with no
10
+ # active span (never opens one itself — that's the app's tracer's job).
11
+ class Datadog < Adapter
12
+ def self.available?
13
+ return false unless defined?(::Datadog::Tracing)
14
+
15
+ ::Datadog.respond_to?(:configuration)
16
+ end
17
+
18
+ def record_action(payload, _duration_ms)
19
+ span = active_span
20
+ return unless span
21
+
22
+ name = transaction_name(payload)
23
+ span.resource = name if name
24
+ span.set_tag("reactive.component", payload[:component]) if payload[:component]
25
+ span.set_tag("reactive.action", payload[:action]) if payload[:action]
26
+ span.set_tag("reactive.outcome", payload[:outcome].to_s) if payload[:outcome]
27
+ end
28
+
29
+ def record_error(error, payload)
30
+ span = active_span
31
+ return unless span
32
+
33
+ span.set_error(error)
34
+ span.set_tag("reactive.component", payload[:component]) if payload[:component]
35
+ span.set_tag("reactive.action", payload[:action]) if payload[:action]
36
+ end
37
+
38
+ private
39
+
40
+ def active_span = ::Datadog::Tracing.active_span
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phlex
4
+ module Reactive
5
+ module APM
6
+ # Sentry adapter (issue #207). Activates only when `::Sentry` is loaded AND
7
+ # initialized. Sentry's chief value here is error reporting: an action-body
8
+ # crash is captured WITH component/action tags. record_action names the
9
+ # transaction on the current scope for the successful path.
10
+ class Sentry < Adapter
11
+ def self.available?
12
+ return false unless defined?(::Sentry) && ::Sentry.respond_to?(:initialized?)
13
+
14
+ ::Sentry.initialized?
15
+ end
16
+
17
+ def record_action(payload, _duration_ms)
18
+ name = transaction_name(payload)
19
+ return unless name
20
+
21
+ ::Sentry.configure_scope { it.set_transaction_name(name) }
22
+ end
23
+
24
+ def record_error(error, payload)
25
+ ::Sentry.capture_exception(error, tags: tags(payload))
26
+ end
27
+
28
+ private
29
+
30
+ def tags(payload)
31
+ {
32
+ reactive_component: payload[:component],
33
+ reactive_action: payload[:action],
34
+ reactive_outcome: payload[:outcome]&.to_s
35
+ }.compact
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phlex
4
+ module Reactive
5
+ module APM
6
+ # The ASN-driven half of the APM integration (issue #207). Subscribes to
7
+ # action.phlex_reactive / defer.phlex_reactive and hands each event's
8
+ # name-only payload + duration to the resolved adapter's record_action —
9
+ # so the APM names the transaction `Component#action` and tags the outcome.
10
+ #
11
+ # A plain ActiveSupport::Notifications subscriber (NOT a LogSubscriber): a
12
+ # LogSubscriber dispatches by the FULL event method, but we want the SAME
13
+ # record_action for both action and defer events and to hold a reference to
14
+ # the adapter instance. install/uninstall manage exactly one subscription so
15
+ # a re-attach with the same adapter never double-reports.
16
+ class Subscriber
17
+ # The events whose duration+outcome we forward to the APM as a named
18
+ # transaction. render/broadcast are left to the app (a Datadog child-span
19
+ # adapter can subscribe to them itself) — the action IS the transaction.
20
+ EVENTS = /\A(?:action|defer)\.phlex_reactive\z/
21
+
22
+ class << self
23
+ # Install a single bus subscription for `adapter`. Idempotent for the
24
+ # SAME adapter: a repeat call is a no-op. A DIFFERENT adapter replaces
25
+ # the previous subscription (an app that swaps apm at boot wins last).
26
+ def install(adapter)
27
+ return if @adapter.equal?(adapter) && @subscription
28
+
29
+ uninstall
30
+ @adapter = adapter
31
+ subscriber = new(adapter)
32
+ @subscription = ::ActiveSupport::Notifications.subscribe(EVENTS) do |*args|
33
+ subscriber.call(::ActiveSupport::Notifications::Event.new(*args))
34
+ end
35
+ end
36
+
37
+ def uninstall
38
+ ::ActiveSupport::Notifications.unsubscribe(@subscription) if @subscription
39
+ @subscription = nil
40
+ @adapter = nil
41
+ end
42
+
43
+ def installed? = !@subscription.nil?
44
+ end
45
+
46
+ def initialize(adapter)
47
+ @adapter = adapter
48
+ end
49
+
50
+ # Route an already-built Event to record_action. Named after the event's
51
+ # leading segment (action/defer) so the synthesized-event unit spec can
52
+ # call subscriber.action(event) exactly like the LogSubscriber spec.
53
+ def call(event)
54
+ @adapter.record_action(event.payload, event.duration)
55
+ end
56
+ alias action call
57
+ alias defer call
58
+ end
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phlex
4
+ module Reactive
5
+ # Turnkey APM integration (issue #207). The gem already emits `*.phlex_reactive`
6
+ # ActiveSupport::Notifications events (issue #107); this namespace turns them
7
+ # into per-component visibility in AppSignal / Sentry / Datadog with ONE config
8
+ # line — `Phlex::Reactive.apm = :appsignal` — instead of every app hand-writing
9
+ # the subscribe block and each vendor's transaction-naming / error-tagging API.
10
+ #
11
+ # The vendor adapters are RUNTIME capability-detected (`defined?(::Appsignal)`
12
+ # etc.), never a gemspec dependency — the same optionality invariant as pgbus.
13
+ # A set-but-absent SDK logs one warning and no-ops.
14
+ module APM
15
+ # Symbol => built-in adapter class. Resolved lazily (at attach time), so the
16
+ # adapter class is only referenced when an app actually opts into it.
17
+ BUILT_INS = {
18
+ appsignal: "Phlex::Reactive::APM::Appsignal",
19
+ sentry: "Phlex::Reactive::APM::Sentry",
20
+ datadog: "Phlex::Reactive::APM::Datadog"
21
+ }.freeze
22
+
23
+ class << self
24
+ # Resolve `Phlex::Reactive.apm` to a live adapter instance, or nil.
25
+ # * nil -> nil (off; no warning)
26
+ # * an object -> returned verbatim (a custom adapter)
27
+ # * a known Symbol -> the built-in adapter instance IF its SDK is loaded,
28
+ # else nil + one warning
29
+ # * an unknown Symbol -> nil + one warning
30
+ def detect(apm)
31
+ return nil if apm.nil?
32
+ return apm unless apm.is_a?(Symbol)
33
+
34
+ klass_name = BUILT_INS[apm]
35
+ unless klass_name
36
+ known = BUILT_INS.keys.map(&:inspect).join(", ")
37
+ return warn_and_nil("apm = #{apm.inspect} — unknown APM flavour (known: #{known})")
38
+ end
39
+
40
+ klass = klass_name.constantize
41
+ # Memoize the instance PER SYMBOL so repeated detect/attach! calls return
42
+ # the SAME object — Subscriber.install keys idempotency on adapter
43
+ # identity (equal?), so a fresh klass.new each time would double-subscribe.
44
+ return (built_in_instances[apm] ||= klass.new) if klass.available?
45
+
46
+ warn_and_nil("apm = #{apm.inspect} set but #{apm} is not loaded — no-op. " \
47
+ "Require the SDK (or remove the setting).")
48
+ end
49
+
50
+ # Attach the APM Subscriber to the notification bus if an adapter resolves.
51
+ # Called once from the engine's after_initialize when Phlex::Reactive.apm
52
+ # is set. Idempotent within a boot: a second call with the SAME adapter is
53
+ # a no-op. Returns the attached adapter (or nil when nothing resolved).
54
+ def attach!(apm = Phlex::Reactive.apm)
55
+ adapter = detect(apm)
56
+ return nil unless adapter
57
+
58
+ Subscriber.install(adapter)
59
+ # Hold the adapter so the endpoint's error seam (report_error) can reach
60
+ # record_error without re-running detection per request.
61
+ Phlex::Reactive.resolved_apm_adapter = adapter
62
+ adapter
63
+ end
64
+
65
+ # Drop the installed subscriber + adapter AND the memoized built-in
66
+ # instances. Tests only (so a stubbed SDK doesn't leak a stale adapter into
67
+ # the next example).
68
+ def reset!
69
+ Subscriber.uninstall
70
+ Phlex::Reactive.resolved_apm_adapter = nil
71
+ @built_in_instances = nil
72
+ end
73
+
74
+ private
75
+
76
+ # Symbol => memoized built-in adapter instance. One instance per flavour for
77
+ # the life of the process (cleared only by reset! in tests), so detect is
78
+ # identity-stable and attach! stays idempotent.
79
+ def built_in_instances
80
+ @built_in_instances ||= {}
81
+ end
82
+
83
+ def warn_and_nil(message)
84
+ logger = Phlex::Reactive.send(:default_logger)
85
+ logger&.warn("[phlex-reactive] #{message}")
86
+ nil
87
+ end
88
+ end
89
+ end
90
+ end
91
+ end
@@ -475,10 +475,19 @@ module Phlex
475
475
  kwargs = {}
476
476
 
477
477
  if reactive_record_key
478
- record = GlobalID::Locator.locate(payload.fetch("gid"))
479
- raise(ActiveRecord::RecordNotFound, "reactive record missing") unless record
480
-
481
- kwargs[reactive_record_key] = record
478
+ if (gid = payload["gid"])
479
+ record = GlobalID::Locator.locate(gid)
480
+ raise(ActiveRecord::RecordNotFound, "reactive record missing") unless record
481
+
482
+ kwargs[reactive_record_key] = record
483
+ else
484
+ # A DRAFT token (issue #208): Component::Identity signs no gid
485
+ # for an unsaved (or nil) record. Omit the kwarg — the
486
+ # component's initialize default seeds a fresh draft, mirroring
487
+ # first render — so a draft parent can run real server actions
488
+ # (its transient identity rides in the signed state instead).
489
+ ensure_draft_default!
490
+ end
482
491
  end
483
492
 
484
493
  if reactive_state_keys.any?
@@ -496,6 +505,20 @@ module Phlex
496
505
 
497
506
  new(**kwargs)
498
507
  end
508
+
509
+ # A draft token can only rebuild through the record kwarg's initialize
510
+ # default (issue #208). A component whose initialize REQUIRES the
511
+ # record can't render drafts — teach the fix instead of exploding
512
+ # with a bare missing-keyword ArgumentError deep in new(**kwargs).
513
+ def ensure_draft_default!
514
+ return unless instance_method(:initialize).parameters.include?([:keyreq, reactive_record_key])
515
+
516
+ raise Phlex::Reactive::Error,
517
+ "#{self}: the action token carries no record gid (the #{reactive_record_key} was unsaved " \
518
+ "when rendered — a draft), but initialize requires `#{reactive_record_key}:`. Give the " \
519
+ "kwarg a default (e.g. `#{reactive_record_key}: nil` or a fresh record) so the draft " \
520
+ "can rebuild through it."
521
+ end
499
522
  end
500
523
  end
501
524
  end
@@ -724,6 +724,139 @@ module Phlex
724
724
  attrs
725
725
  end
726
726
 
727
+ # Draft nested-attribute rows (issue #208) — the "new parent + child
728
+ # rows" primitive. A form that builds child rows BEFORE the parent
729
+ # exists (a new order accumulating line items) can't be a reactive
730
+ # collection: an unsaved parent has no gid to sign, so there is nothing
731
+ # to round-trip. These helpers run that pre-save window entirely
732
+ # CLIENT-SIDE, the reactive_tags posture: the rows are FORM state (like
733
+ # text in an input), never component state — add/remove round-trips
734
+ # nothing, and the surrounding REAL form submit carries Rails'
735
+ # accepts_nested_attributes_for names so the server reconciles parent +
736
+ # rows in ONE create.
737
+ #
738
+ # The wiring (all inside one reactive root, itself inside the real
739
+ # <form> that will POST the parent):
740
+ #
741
+ # div(**reactive_root) do
742
+ # div(**reactive_nested_list(:line_items)) { } # rows land here
743
+ # template(**reactive_nested_template(:line_items)) do # ONE row's markup (server-owned)
744
+ # div(**reactive_nested_row) do
745
+ # input(name: nested_field_name(:line_items, :quantity)) # …[NEW_ROW][quantity]
746
+ # button(**reactive_nested_remove) { "×" }
747
+ # end
748
+ # end
749
+ # button(**reactive_nested_add(:line_items)) { "Add row" }
750
+ # end
751
+ #
752
+ # Clicking add clones the template and swaps every NEW_ROW in the
753
+ # clone's name/id/for attributes for a fresh unique index, so each row
754
+ # posts as its own `…_attributes[<index>][field]` group. Remove on a
755
+ # draft row deletes it from the DOM; remove on a row carrying a hidden
756
+ # `[_destroy]` input (a persisted row in an edit form, rendered with
757
+ # nested_field_name(index: item_index)) marks it "1" and hides the row
758
+ # — Rails destroys it on save. The DOM is the single source of truth;
759
+ # a server re-render of the root REPLACES the rows, so keep replace-
760
+ # shaped actions out of a root holding unsent draft rows.
761
+ #
762
+ # Several collections can share one root — every marker/trigger is
763
+ # keyed by the association name. Nesting a collection INSIDE another's
764
+ # template is not supported (the placeholder swap would hit both).
765
+ # Once the parent is saved, the persisted flow takes over: the same row
766
+ # markup renders with real indexes, or graduates to a reactive
767
+ # collection (reactive_collection + reply.append/remove).
768
+
769
+ # The index placeholder a template row carries in its field names; the
770
+ # client swaps it for a fresh unique index on every add. Referenced by
771
+ # both sides of the wire — change it nowhere.
772
+ NESTED_NEW_ROW = "NEW_ROW"
773
+
774
+ # The Rails nested-attributes wire name for one row field — the name
775
+ # accepts_nested_attributes_for expects. Defaults to the template
776
+ # placeholder; pass index: for a server-rendered row. Scope-aware: the
777
+ # `<association>_attributes` base goes through reactive_scope FIRST, so
778
+ # a `reactive_scope :order` component emits
779
+ # order[line_items_attributes][3][quantity] (never a nested-bracket
780
+ # corruption of the scope wrap).
781
+ def nested_field_name(association, field, index: NESTED_NEW_ROW)
782
+ nested_identifier!(:nested_field_name, :association, association)
783
+ nested_identifier!(:nested_field_name, :field, field)
784
+ unless index.to_s == NESTED_NEW_ROW || index.to_s.match?(/\A\d+\z/)
785
+ raise ArgumentError,
786
+ "nested_field_name index: must be an integer or the NEW_ROW placeholder, " \
787
+ "got #{index.inspect} — anything else corrupts the bracketed wire name"
788
+ end
789
+
790
+ "#{scoped_field_name(:"#{association}_attributes")}[#{index}][#{field}]"
791
+ end
792
+
793
+ # The container cloned rows land in — one per association, inside the
794
+ # root. Server-rendered rows (an edit form's persisted children) render
795
+ # inside it too.
796
+ #
797
+ # `as: :json` (issue #208) switches the SUBMIT wire from Rails'
798
+ # accepts_nested_attributes_for names to ONE hidden JSON field — for an
799
+ # app whose controller already parses a serialized JSON param
800
+ # (`JSON.parse(params[:order][:todos])`) instead of nested attributes.
801
+ # The container keeps its plain marker (nestedAdd/Remove still key on
802
+ # it), and gains data-reactive-nested-json plus a scope-aware selector
803
+ # naming the hidden field the client mirrors the rows into on every
804
+ # add/remove/input. The default (:attributes) is unchanged — the plain
805
+ # accepts_nested_attributes_for wire.
806
+ def reactive_nested_list(association, as: :attributes)
807
+ name = nested_identifier!(:reactive_nested_list, :association, association)
808
+ data = { reactive_nested_list: name }
809
+ return { data: } if as == :attributes
810
+
811
+ unless as == :json
812
+ raise ArgumentError,
813
+ "reactive_nested_list(as:) takes :attributes (the default, Rails nested-attribute names) " \
814
+ "or :json (serialize the rows into one hidden JSON field), got #{as.inspect}"
815
+ end
816
+
817
+ # JSON mode: mark the container and name the hidden field the client
818
+ # keeps in sync — a scope-aware [name="…"] selector, the same
819
+ # convention reactive_tags/reactive_filter use so the field resolves
820
+ # under reactive_scope too.
821
+ data[:reactive_nested_json] = name
822
+ data[:reactive_nested_json_field] = %([name="#{scoped_field_name(association)}"])
823
+ { data: }
824
+ end
825
+
826
+ # The <template> holding ONE row's markup (server-owned, inert until
827
+ # cloned). Field names inside use nested_field_name's placeholder form.
828
+ def reactive_nested_template(association)
829
+ { data: { reactive_nested_template: nested_identifier!(:reactive_nested_template, :association,
830
+ association) } }
831
+ end
832
+
833
+ # The row wrapper marker — what nestedRemove resolves from its trigger
834
+ # (closest). Spread on the template row's outermost element AND on
835
+ # server-rendered rows, so both remove the same way.
836
+ def reactive_nested_row
837
+ { data: { reactive_nested_row: true } }
838
+ end
839
+
840
+ # The add-a-row trigger — CLIENT-ONLY (no dispatch descriptor, no
841
+ # POST). Forced type="button": a bare button inside the surrounding
842
+ # real <form> would submit it.
843
+ def reactive_nested_add(association)
844
+ {
845
+ type: "button",
846
+ data: {
847
+ action: "click->reactive#nestedAdd",
848
+ reactive_association_param: nested_identifier!(:reactive_nested_add, :association, association)
849
+ }
850
+ }
851
+ end
852
+
853
+ # A row's remove trigger — client-only. Draft rows leave the DOM;
854
+ # persisted rows (a hidden [_destroy] input present) are marked and
855
+ # hidden instead, so Rails destroys them on save.
856
+ def reactive_nested_remove
857
+ { type: "button", data: { action: "click->reactive#nestedRemove" } }
858
+ end
859
+
727
860
  # CROSS-ROOT value-conditional visibility (issue #164) — the visibility
728
861
  # parallel to reactive_compute's `mirror:` (#159). A plain reactive_show
729
862
  # is root-scoped by design (#15), so it can't express "this control
@@ -757,6 +890,22 @@ module Phlex
757
890
  #
758
891
  # reactive_show_targets(mode: { "#advanced-tab" => "advanced" },
759
892
  # kind: { "#premium-note" => %w[gold platinum] })
893
+ #
894
+ # MULTI-FIELD targets (issue #209): a "#id" KEY takes a full
895
+ # if:/if_any:/unless: conditions Hash — the SAME language reactive_show
896
+ # speaks, so a cross-root target can finally read a COMBINATION of
897
+ # owned fields (the last case forcing a bespoke JS listener):
898
+ #
899
+ # reactive_show_targets("#trade-warning" => {
900
+ # if: { type: "trade", price: ..0 } # type == "trade" AND price <= 0
901
+ # })
902
+ #
903
+ # Target-keyed and field-keyed entries mix in the ONE call (a "#" key is
904
+ # unambiguous — a field name may never start with "#"). The client folds
905
+ # the target's DNF payload with the same per-term field reads as an
906
+ # in-root reactive_show: every referenced field must be OWNED by this
907
+ # root (a missing owned field reads as blank, fail-closed); a target
908
+ # whose fields are ALL unowned is left alone, like the single-field skip.
760
909
  def reactive_show_targets(field, targets = nil)
761
910
  field_maps = targets.nil? ? field : { field => targets }
762
911
  unless field_maps.is_a?(Hash) && field_maps.any?
@@ -766,16 +915,12 @@ module Phlex
766
915
  end
767
916
 
768
917
  normalized = field_maps.to_h do |name, map|
769
- # Catch the forgotten-field-name misuse — reactive_show_targets(
770
- # "#id" => {…}) — before the per-target validation turns it into a
771
- # baffling "predicate" error.
772
918
  if name.to_s.start_with?("#")
773
- raise ArgumentError,
774
- "reactive_show_targets: #{name.inspect} looks like a target selector, not a field " \
775
- "name — call reactive_show_targets(:field, #{name.inspect} => { ... })"
919
+ # Target-keyed conditions (issue #209): "#id" => { if:/if_any:/unless: }.
920
+ [name.to_s, normalize_show_target_conditions(name.to_s, map)]
921
+ else
922
+ [name.to_s, normalize_show_target_map(name, map)]
776
923
  end
777
-
778
- [name.to_s, normalize_show_target_map(name, map)]
779
924
  end
780
925
 
781
926
  { data: { reactive_show_targets: normalized.to_json } }
@@ -924,6 +1069,18 @@ module Phlex
924
1069
  value
925
1070
  end
926
1071
 
1072
+ # An association/field name for the nested-rows wire (issue #208),
1073
+ # validated to a plain Ruby identifier at render — it becomes an
1074
+ # attribute value, a CSS selector fragment, AND a bracketed param name,
1075
+ # so anything else is a dead (or corrupting) binding.
1076
+ def nested_identifier!(helper, kind, name)
1077
+ value = name.to_s
1078
+ return value if value.match?(/\A[a-z_][a-z0-9_]*\z/)
1079
+
1080
+ raise ArgumentError,
1081
+ "#{helper} needs a plain #{kind} name (e.g. :line_items), got #{name.inspect}"
1082
+ end
1083
+
927
1084
  # Issue #179: apply a confirm: gate to a trigger's data hash. A String is
928
1085
  # the static #52/#55 form (data-reactive-confirm-param, unchanged). A Hash
929
1086
  # is the CONDITIONAL form (data-reactive-confirm-when-param, JSON) — warn
@@ -1003,6 +1160,40 @@ module Phlex
1003
1160
  end
1004
1161
  end
1005
1162
 
1163
+ # Normalize + validate ONE target-keyed entry (issue #209): the "#id"
1164
+ # key is a single id selector (the same declare-time guard as
1165
+ # field-keyed targets), the value a full if:/if_any:/unless: conditions
1166
+ # Hash compiled by ShowConditions into the SAME { "any" => groups } DNF
1167
+ # payload reactive_show emits. Anything else — a bare value, unknown
1168
+ # keys, empty conditions — raises a guided error at render (a dead
1169
+ # binding must never reach the browser).
1170
+ def normalize_show_target_conditions(selector, conditions)
1171
+ unless selector.match?(DSL::MIRROR_ID_SELECTOR)
1172
+ raise ArgumentError,
1173
+ "reactive_show_targets target #{selector.inspect} must be a single " \
1174
+ "ID selector (\"#id\") — cross-root visibility is id-allowlisted, like mirror: (#159)"
1175
+ end
1176
+ unless conditions.is_a?(Hash) && conditions.any?
1177
+ raise ArgumentError,
1178
+ "reactive_show_targets: a target key takes a conditions Hash — " \
1179
+ "reactive_show_targets(#{selector.inspect} => { if: { field: value, ... } }); " \
1180
+ "to key by field instead: reactive_show_targets(:field, #{selector.inspect} => value). " \
1181
+ "Got #{selector.inspect} => #{conditions.inspect}"
1182
+ end
1183
+ # Unknown keys are reported BEFORE the presence check so { bogus: 1 }
1184
+ # names its offender instead of the generic shape message (specific
1185
+ # beats generic). A non-empty hash surviving this subtraction holds
1186
+ # only condition keys, so no separate presence check remains.
1187
+ if (unknown = conditions.keys - SHOW_CONDITION_KEYS).any?
1188
+ raise ArgumentError,
1189
+ "reactive_show_targets #{selector.inspect}: unknown conditions key(s) " \
1190
+ "#{unknown.map(&:inspect).join(", ")} — a target's conditions Hash takes only " \
1191
+ "if:/if_any:/unless: (the reactive_show language)"
1192
+ end
1193
+
1194
+ { "any" => Phlex::Reactive::ShowConditions.normalize(**conditions) }
1195
+ end
1196
+
1006
1197
  # Reject the removed 0.9.5 reactive_show surface (issue #180 clean break)
1007
1198
  # with a GUIDED error printing the if:/if_any:/unless: rewrite. A
1008
1199
  # positional field, a predicate kwarg (equals:/not:/in:/gte:/…), or a
@@ -122,6 +122,12 @@ module Phlex
122
122
  # once per boot; the events fire for APMs regardless of this flag.
123
123
  Phlex::Reactive::LogSubscriber.attach_to(:phlex_reactive) if Phlex::Reactive.log_events
124
124
 
125
+ # Attach the turnkey APM adapter (issue #207) when an app set
126
+ # Phlex::Reactive.apm. Resolution is deferred to HERE — after initializers
127
+ # ran, so the vendor SDK (if any) is loaded — and no-ops with one warning
128
+ # when the named SDK is absent (the pgbus optionality invariant). Idempotent.
129
+ Phlex::Reactive::APM.attach! if Phlex::Reactive.apm
130
+
125
131
  # Freeze the param-type registry (issue #109): custom types register in
126
132
  # an initializer, which has run by now, so no further registration is
127
133
  # accepted. A schema referencing a type is validated at declaration; the
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Phlex
4
4
  module Reactive
5
- VERSION = "0.11.4"
5
+ VERSION = "0.11.6"
6
6
  end
7
7
  end