phlex-reactive 0.11.3 → 0.11.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 23b25b41281afc59503c63e9e4fbcee696d1265b96682c1c97bfd0adf385055d
4
- data.tar.gz: 7204885d357720c85e57c8b44192d6e000f91c985346177a7ddcefc28f7451cc
3
+ metadata.gz: a7758f95f9e1a35e71bf870416d6e042367b7ca19bb2a1c3b618939e72aa5775
4
+ data.tar.gz: 2203a04fcf550a95e828945e56d06ed1b335fc430a1fdb8669f566893aa17c7d
5
5
  SHA512:
6
- metadata.gz: 2afb0dfef67df5cc60f176db516ca7c4ed5553e7f24a9df9ff098dc7a897e1085594c698661d437f8ea336b4eab5d6bc76b884073f0f52b37b09a8f5fe4bd711
7
- data.tar.gz: 411a32490ecdc9224d18e5db37b443286d1d3e47210e733e2cb9e6083aac5af2ac5ead6f96e54ab1cf0629483addd3f888de209976a6b2161259c5a6d9c0a93e
6
+ metadata.gz: 311c87cf131ec0db83082089133b32dcc0583aa49203950ff59495929a2b512d72bedff7ced52747b2074bbce16a6b32eac99249e2b985cb19acfb9afce3a1ab
7
+ data.tar.gz: 5856533fe3e2207246082a56f6709d732f32db50b8a8c6f1e2f3c8bd74b0f5b2c2330440e0c2b4c9bc078d785d33b85e95ae22f7edc91f746df959f86819fa37
data/CHANGELOG.md CHANGED
@@ -8,6 +8,73 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
8
8
 
9
9
  ### Added
10
10
 
11
+ - **Turnkey APM adapters — `Phlex::Reactive.apm = :appsignal` (#207).** The gem
12
+ already emits `*.phlex_reactive` `ActiveSupport::Notifications` events (#107), but
13
+ every reactive action still POSTs to the same endpoint, so an APM rolled all
14
+ reactive traffic into one blurry `ActionsController#create` transaction and an
15
+ action-body crash surfaced as an anonymous 500. One config line closes both gaps:
16
+ - **`Phlex::Reactive.apm = :appsignal` (or `:sentry`, `:datadog`, or a custom
17
+ object)** names each action as its own `Component#action` transaction/span in the
18
+ APM, tagged with the component, action, and outcome. The vendor SDK is
19
+ **runtime-detected** (`defined?(::Appsignal)` …) — never a gemspec dependency, the
20
+ same optionality invariant as pgbus — so a set-but-absent SDK logs one warning and
21
+ no-ops. A custom object need only respond to `record_action(payload, duration_ms)`
22
+ and `record_error(error, payload)`.
23
+ - **Action-body errors are reported with context.** A previously-uncaught error from
24
+ an action body (a genuine 500, not a registered 4xx) is now OBSERVED at the
25
+ endpoint: the `action.phlex_reactive` event is tagged `outcome: :error` (filling the
26
+ #107 nil-outcome gap), the exception is reported to the APM adapter AND every
27
+ registered `Phlex::Reactive.on_action_error { |error, ctx| … }` hook WITH the
28
+ name-only `{ component:, action: }` context, the `error_flash` renders so the actor
29
+ sees a flash on a crash (500s now flow through the same path the 4xx errors used) —
30
+ and THEN the error is **re-raised unchanged**, so Rails' own error reporting and the
31
+ app's middleware fire exactly as before. Each reporter is isolated: a broken reporter
32
+ (or a raising `error_flash` lambda) can never turn one 500 into a different 500 or
33
+ swallow the original. The status never changes; the existing 4xx rescues, their
34
+ statuses, and their outcomes (`invalid_token`/`denied_undeclared`/`not_found`/
35
+ `unauthorized`/`unverified`) are untouched. Payloads and error tags stay name-only —
36
+ never the token, params, or state.
37
+
38
+ - **Draft nested-attribute rows — the "new parent + child rows" form (#208).**
39
+ A collection whose PARENT isn't persisted yet (a new order accumulating line
40
+ items) can't be a reactive collection — an unsaved parent has no gid to sign.
41
+ The pre-save window is now a client-side primitive, the `reactive_tags`
42
+ posture: `reactive_nested_list(:assoc)` / `reactive_nested_template(:assoc)`
43
+ mark the container + the server-owned `<template>` holding ONE row's markup;
44
+ `reactive_nested_add(:assoc)` clones it, swapping every `NEW_ROW` placeholder
45
+ in the clone's `name`/`id`/`for` for a fresh unique index (clock-seeded
46
+ monotonic — server-rendered `0..n` and same-millisecond double clicks can't
47
+ collide) and focusing the new row's first field; `reactive_nested_remove`
48
+ deletes a draft row from the DOM, or `_destroy`-marks + hides a row carrying
49
+ a hidden `[_destroy]` input (an edit form's persisted row) so Rails destroys
50
+ it on save. `nested_field_name(:assoc, :field, index:)` builds the
51
+ scope-aware Rails wire name (`order[line_items_attributes][i][quantity]`).
52
+ Zero round trips, no token (works in a `ClientBindings` component); the REAL
53
+ form submit reconciles parent + rows in ONE request via
54
+ `accepts_nested_attributes_for`. Several collections can share a root
55
+ (association-keyed); the row markup is authored ONCE and serves the template,
56
+ server-rendered edit rows, and the persisted `reactive_collection` flow.
57
+
58
+ - **`reactive_tags` — the tag-chip input primitive (#203).** The composed
59
+ combobox/tags widget (preload suggestions, type to narrow, Enter/click to add,
60
+ remove chips) with zero bespoke JavaScript and zero round trips. The value is
61
+ FORM state in a hidden **comma-joined** field the root names
62
+ (`reactive_tags(:tags)`, scope-aware like `reactive_filter`); the chip list is
63
+ a client projection of that field, rebuilt per change by cloning a
64
+ server-owned `<template>` chip (`textContent` writes only — never
65
+ `innerHTML`). Three client-only triggers: `reactive_tags_add` (Enter adds the
66
+ typed text — composed after `reactive_listnav`, a highlighted option wins and
67
+ Enter never submits the enclosing form), `reactive_tags_option(tag)` (click a
68
+ suggestion to add its declared tag), `reactive_tags_remove(tag)` (a chip's
69
+ remove button; no arg inside the template — the client fills it per chip).
70
+ Tags dedupe case-insensitively; a comma-separated paste splits; a declared tag
71
+ containing a comma raises at render. Selected suggestions hide + mark
72
+ `data-reactive-tags-selected` (`reactive_filter` keeps them hidden through
73
+ re-filters) and resurface on removal; a morph re-projects the chips from the
74
+ field's server value; every write dispatches a real `input` event on the
75
+ hidden field so `reactive_dirty`/`reactive_show`/`reactive_compute` see it.
76
+ Works in a token-less `ClientBindings` component — nothing here POSTs.
77
+
11
78
  - **A global reactive-activity signal — the analogue of Turbo's progress bar (#201).**
12
79
  The client now maintains a document-level count of in-flight reactive operations
13
80
  across ALL roots — incremented when a dispatch round trip or a deferred render
@@ -47,6 +114,16 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
47
114
 
48
115
  ### Fixed
49
116
 
117
+ - **A draft (unsaved-parent) token can now round-trip real server actions (#208).**
118
+ `Component::Identity` already signed a gid-less `{c, state}` token for an
119
+ unpersisted (or nil) record, but `from_identity` still `fetch`ed the absent
120
+ `gid` — the FIRST action against a draft crashed with an uncaught `KeyError`
121
+ (500). It now rebuilds through the record kwarg's **initialize default**
122
+ (e.g. `def initialize(order: Order.new, …)`), with the declared
123
+ `reactive_state` riding the token as before; a component whose initialize
124
+ REQUIRES the record kwarg raises a guided `Phlex::Reactive::Error` naming the
125
+ fix instead of a bare missing-keyword `ArgumentError`.
126
+
50
127
  - **`have_reactive_value` now reads the field's `.value` property, so it can verify a
51
128
  reducer-set disabled/computed field (#204).** The matcher (added in #201) delegated to
52
129
  Capybara's `have_field(with:)`, which matches the value **attribute** — but a
data/README.md CHANGED
@@ -390,10 +390,15 @@ Use in controllers: `render turbo_stream: Counter.replace(counter)`.
390
390
  | `reactive_show_targets(:field, "#id" => value)` | **Cross-root visibility**: the component that owns the field declares which **outside**, id-allowlisted elements it governs (a nav tab, a panel in another pane) — the visibility parallel of `mirror:`. Spread on the **root** via `mix(reactive_root, …)`, **once per root** — several fields go in one call via the hash form. The value uses the same `where`-style vocabulary (`"advanced"`, `%w[a b]`, `10..`). Id selectors only (raise at render + client warn-skip); toggles `hidden` only. See [Value-conditional visibility](#value-conditional-visibility-reactive_show). |
391
391
  | `reactive_filter(:field, option: nil, group: nil, empty: nil)` | **Client-side option filtering** for a preloaded combobox: spread onto the root and name the **field** that drives it — `reactive_filter(:q)` compiles `:q` to `[name="q"]` (scope-aware) and typing shows/hides the options by their `data-reactive-filter-text` haystack, **zero round trips**. `option:` defaults to `[role=option]`; optional `group:` collapses an all-hidden group header; `empty:` reveals a no-matches node. See [Client-side option filtering](#client-side-option-filtering-reactive_filter). |
392
392
  | `reactive_listnav("[role=option]")` | The **standalone** combobox keyboard wiring (Arrow/Enter/Escape) for an input that fires **no action** — the preload-and-filter case. Same behavior as `on(…, listnav:)`, minus the POST. |
393
+ | `reactive_tags(:tags)` | **Tag-chip input** (the combobox/tags widget): spread onto the root and name the hidden field that stores the **comma-joined** value — the client maintains that field + the chip list entirely client-side (form state, zero round trips), rebuilding chips from your server-owned `<template>`. Composes with `reactive_filter` (type to narrow) and `reactive_listnav` (Enter picks the highlighted option). See [Tag-chip input](#tag-chip-input-reactive_tags). |
394
+ | `reactive_tags_add` / `reactive_tags_option(tag)` / `reactive_tags_remove(tag)` | The tags triggers, all **client-only**: `reactive_tags_add` on the query input adds the typed text on Enter (mix it **after** `reactive_listnav`; Enter never submits the enclosing form); `reactive_tags_option` makes a preloaded suggestion add its declared tag on click; `reactive_tags_remove` is a chip's remove button (no arg inside the template — the client fills the tag per chip). |
393
395
  | `reactive_compute :name, inputs: { title: :string, qty: :number }, outputs:` | **Typed** inputs: a `:string` reaches the JS reducer raw, a `:number` is coerced through `Number`. The array form (`inputs: %i[a b]`) stays all-numeric; the **permit-style** form (`inputs: [:qty, title: :string]`) mixes both — bare symbols default `:number`, a trailing hash types the exceptions. `outputs:` is the field allowlist; a reducer-result key also paints any owned `reactive_text` node by presence and any `mirror:` id, so an `outputs:` entry that exists only to reach a text node is redundant (harmless — a widening). |
394
396
  | `reactive_compute :name, ..., mirror: { sum: "#summary-sum" }` | **Cross-root text mirrors**: paint a compute value into declared, id-allowlisted nodes **outside** the reactive root (a recap in another tab pane) via `textContent` — no bespoke listener. See [Cross-root mirrors](#cross-root-mirrors-mirror--painting-a-recap-outside-the-root). |
395
397
  | `reactive_dirty` / `reactive_dirty warn_unsaved: true` / `reactive_dirty only: %i[...]` | **Dirty tracking**, declared once at the class level, against the DOM's own `defaultValue`/`defaultChecked`/`defaultSelected` — no client state. Marks changed fields + the root `data-reactive-dirty`; `warn_unsaved:` arms a `beforeunload`/`turbo:before-visit` guard; `only:` scopes tracking to named fields. Style with `[data-reactive-dirty]`. See [Dirty-field tracking](#dirty-field-tracking-reactive_dirty). |
396
398
  | `nested_update!(:assoc, attrs)` | Map a nested param onto `<assoc>_attributes` with id preservation; update the record. |
399
+ | `reactive_nested_list(:assoc)` / `reactive_nested_template(:assoc)` / `reactive_nested_row` | **Draft nested-attribute rows** (the "new parent + child rows" form): the container rows land in, the `<template>` holding ONE row's markup, and the row wrapper marker — all **client-only** form state, keyed by association (several collections per root). See [Draft rows for a new parent](#draft-rows-for-a-new-parent-reactive_nested_). |
400
+ | `reactive_nested_add(:assoc)` / `reactive_nested_remove` | The row triggers, client-only (zero round trips): add clones the template and renumbers its placeholder index; remove deletes a draft row from the DOM, or `_destroy`-marks + hides a persisted row (a hidden `[_destroy]` input present). |
401
+ | `nested_field_name(:assoc, :field, index: nil)` | The Rails `accepts_nested_attributes_for` wire name for one row field — `order[line_items_attributes][NEW_ROW][quantity]` (the template placeholder) by default, a real index when given. Scope-aware under `reactive_scope`. |
397
402
  | `reactive_collection :name, item:, container:, count:, empty:, size:` | Declare an add/remove-row list once; actions call `reply.append`/`prepend`/`remove`. See [Reactive collections](#reactive-collections-addremove-rows--count--empty-state). |
398
403
  | `reply.replace` / `.morph` / `.update` / `.remove` / `.redirect(url)` / `.with(*)` / `.js(ops)` | Return from an action to control the reply (flash, remove, redirect, multi-stream, server-pushed client ops). See [Controlling the action's reply](#reply--controlling-the-actions-reply). |
399
404
  | `reply.append(name, model)` / `.prepend(...)` / `.remove(name, model)` | Add/remove a row in a declared `reactive_collection` (row + count + empty-state in one reply). |
@@ -1194,6 +1199,148 @@ stays its own signed `on(:select)` trigger. Only *filtering* is client-side —
1194
1199
  selection still round-trips as a real signed action. Blank selectors raise at
1195
1200
  render: a dead binding must fail loudly, not no-op in the browser.
1196
1201
 
1202
+ ### Tag-chip input (`reactive_tags`)
1203
+
1204
+ The composed combobox/tags widget: preload suggestions, type to narrow, Enter or
1205
+ click to add, remove chips — the classic bespoke `tags` Stimulus controller,
1206
+ gone. The value is **form state** (like text in an input), never component
1207
+ state: it lives in a hidden **comma-joined** field inside your form, the client
1208
+ maintains it with **zero round trips**, and the surrounding form submit carries
1209
+ it to the server (`tags.split(",")` on your side). Because nothing here needs a
1210
+ signed action, the widget works in a token-less `ClientBindings` component too.
1211
+
1212
+ ```ruby
1213
+ div(**mix(reactive_root, reactive_tags(:tags), reactive_filter(:tag_query))) do
1214
+ input(type: :hidden, **reactive_field(:tags), value: @post.tags.join(","))
1215
+
1216
+ div(data: { reactive_tags_list: true }) do # chips render here
1217
+ @post.tags.each { chip(it) } # server-rendered first paint
1218
+ end
1219
+ template(data: { reactive_tags_template: true }) { chip } # the chip markup, server-owned
1220
+
1221
+ # listnav FIRST, tags_add second: Enter picks the highlighted option;
1222
+ # free text adds only when nothing is highlighted. Never submits the form.
1223
+ input(name: "tag_query", **mix(reactive_listnav, reactive_tags_add))
1224
+
1225
+ SUGGESTIONS.each do |tag, haystack|
1226
+ button(**mix(reactive_tags_option(tag), # click → add (client-only)
1227
+ data: { reactive_filter_text: haystack })) { tag }
1228
+ end
1229
+ end
1230
+
1231
+ # One chip method serves both forms: with a tag it's the server-rendered chip,
1232
+ # without it's the template prototype (the client fills text + remove param).
1233
+ def chip(tag = nil)
1234
+ span(class: "chip", data: { reactive_tag: tag }) do
1235
+ span(data: { reactive_tag_text: true }) { tag }
1236
+ button(**(tag ? reactive_tags_remove(tag) : reactive_tags_remove)) { "×" }
1237
+ end
1238
+ end
1239
+ ```
1240
+
1241
+ The chip list is a **client projection of the hidden field** — the field is the
1242
+ single source of truth. Every change re-clones your `<template>` per tag,
1243
+ writing the tag through `textContent` only (never `innerHTML` — the XSS-safe
1244
+ `reactive_text` posture) and stamping the remove button's tag param. Tags dedupe
1245
+ case-insensitively (first casing wins); a comma-separated paste splits into
1246
+ individual tags; a declared tag containing a comma **raises at render** (it
1247
+ would corrupt the joined value). An already-selected suggestion is hidden and
1248
+ marked `data-reactive-tags-selected` — `reactive_filter` keeps it hidden through
1249
+ re-filters — and resurfaces when its chip is removed. A server re-render/morph
1250
+ re-projects the chips from the field's fresh server value, and each write
1251
+ dispatches a real `input` event on the hidden field, so `reactive_dirty`,
1252
+ `reactive_show`, and `reactive_compute` all see it.
1253
+
1254
+ The styled, form-builder-integrated version of this widget (label/errors/
1255
+ theming) belongs in your form layer — these helpers are deliberately the
1256
+ unstyled primitives, like `reactive_filter` before them.
1257
+
1258
+ ### Draft rows for a new parent (`reactive_nested_*`)
1259
+
1260
+ The "new order + line items" form: the user builds up child rows **before the
1261
+ parent exists**. An unsaved parent has no GlobalID to sign, so this pre-save
1262
+ window can't be a reactive collection — and every app ends up hand-writing the
1263
+ same imperative JS (clone a row, renumber indexes, serialize on submit).
1264
+ `reactive_nested_*` is that pattern as a primitive, the `reactive_tags`
1265
+ posture: the rows are **form state**, add/remove run **entirely client-side**
1266
+ (zero round trips, no token — works in a `ClientBindings` component), and the
1267
+ surrounding **real form submit** posts standard Rails
1268
+ `parent[assoc_attributes][<index>][field]` names, so
1269
+ `accepts_nested_attributes_for` creates the parent + rows in **one request**.
1270
+
1271
+ ```ruby
1272
+ class DraftOrderForm < ApplicationComponent
1273
+ include Phlex::Reactive::ClientBindings
1274
+
1275
+ reactive_scope :order # names post as order[line_items_attributes][…]
1276
+
1277
+ def view_template
1278
+ div(**reactive_root(id: "draft_order_form")) do
1279
+ form(action: orders_path, method: "post", data: { turbo: "false" }) do
1280
+ input(**reactive_field(:total, type: "number", value: "0"))
1281
+
1282
+ div(**reactive_nested_list(:line_items)) { } # rows land here
1283
+ template(**reactive_nested_template(:line_items)) { row_fields }
1284
+ button(**reactive_nested_add(:line_items)) { "Add item" }
1285
+
1286
+ button(type: "submit") { "Create order" }
1287
+ end
1288
+ end
1289
+ end
1290
+
1291
+ private
1292
+
1293
+ # ONE row's markup, authored once. The default renders the template
1294
+ # prototype (NEW_ROW placeholder names — the client swaps in a fresh index
1295
+ # per add); pass index: to server-render an edit form's persisted rows.
1296
+ def row_fields(index: nil)
1297
+ kwargs = index.nil? ? {} : { index: }
1298
+ div(**reactive_nested_row) do
1299
+ input(name: nested_field_name(:line_items, :quantity, **kwargs), type: "number")
1300
+ input(name: nested_field_name(:line_items, :price, **kwargs), type: "number")
1301
+ button(**reactive_nested_remove) { "×" }
1302
+ end
1303
+ end
1304
+ end
1305
+ ```
1306
+
1307
+ ```ruby
1308
+ # The model + controller side is plain Rails — the reconcile is one create:
1309
+ class Order < ApplicationRecord
1310
+ has_many :line_items, dependent: :destroy
1311
+ accepts_nested_attributes_for :line_items, allow_destroy: true
1312
+ end
1313
+
1314
+ Order.create!(params.require(:order).permit(:total,
1315
+ line_items_attributes: %i[id quantity price _destroy]))
1316
+ ```
1317
+
1318
+ Clicking add clones your `<template>` row and swaps every `NEW_ROW` in the
1319
+ clone's `name`/`id`/`for` attributes for a fresh unique index (clock-seeded and
1320
+ strictly monotonic — server-rendered `0..n` indexes and same-millisecond double
1321
+ clicks can't collide), then focuses the new row's first field. Remove on a
1322
+ draft row deletes it from the DOM — it was never persisted, so removing its
1323
+ fields *is* the removal. Remove on a row carrying a hidden `[_destroy]` input
1324
+ (an **edit** form's persisted row, rendered with `nested_field_name(:line_items,
1325
+ :_destroy, index: i)`) marks it `"1"` and hides the row instead — Rails destroys
1326
+ it on save. Several collections can share one root (everything is keyed by the
1327
+ association name); nesting a collection inside another's template is not
1328
+ supported.
1329
+
1330
+ Two boundaries to respect: the DOM is the single source of truth for unsent
1331
+ draft rows, so a **server re-render of the root replaces them** — keep
1332
+ replace-shaped actions out of a root holding unsent rows. And once the parent
1333
+ is saved, the persisted flow takes over: the same row markup renders with real
1334
+ indexes, or the list graduates to a [reactive collection](#reactive-collections-addremove-rows--count--empty-state)
1335
+ (`reactive_collection` + `reply.append`/`reply.remove`).
1336
+
1337
+ Relatedly, a **draft parent can now run real server actions too** (issue #208):
1338
+ an unsaved record signs a gid-less `{c, state}` token, and the endpoint rebuilds
1339
+ the component through the record kwarg's **initialize default** —
1340
+ `def initialize(order: Order.new, …)` — with the declared `reactive_state`
1341
+ riding the token. A component whose initialize *requires* the record kwarg
1342
+ raises a guided error on the first draft action, telling you to add the default.
1343
+
1197
1344
  **Combining `on(...)` / `reactive_attrs` with your own attributes.** Both return
1198
1345
  a hash that includes a `data:` key. Spreading them *and* passing another `data:`
1199
1346
  (or `class:`, `id:`) would clobber it — use Phlex's `mix` to deep-merge. For the
@@ -1944,10 +2091,11 @@ Phlex::Reactive.action_path = "/_r/actions"
1944
2091
  Phlex::Reactive.verbose_errors = true
1945
2092
 
1946
2093
  # User-visible flash on endpoint failures (default nil = off). When set, every
1947
- # rescue path (400/403/404) ALSO renders a turbo-stream flash the user sees — at
1948
- # the SAME status it returns today (statuses never change). The lambda receives
1949
- # the failure kind (:tampered/:unknown_class/:not_reactive_class/:forbidden/
1950
- # :not_found), so you can map it to a friendly message:
2094
+ # rescue path (400/403/404 AND a 500 crash) ALSO renders a turbo-stream flash the
2095
+ # user sees — at the SAME status it returns today (statuses never change). The
2096
+ # lambda receives the failure kind (:tampered/:unknown_class/:not_reactive_class/
2097
+ # :forbidden/:not_found, or :error for an action-body crash), so you can map it to
2098
+ # a friendly message:
1951
2099
  Phlex::Reactive.error_flash = ->(kind) do
1952
2100
  case kind
1953
2101
  when :not_found then "That item is no longer available."
@@ -1956,6 +2104,17 @@ Phlex::Reactive.error_flash = ->(kind) do
1956
2104
  end
1957
2105
  end
1958
2106
 
2107
+ # Turnkey APM integration. Names each action Component#action in AppSignal/Sentry/
2108
+ # Datadog and reports action-body crashes with component/action tags. SDK is
2109
+ # runtime-detected (no gem dependency); a custom object responding to
2110
+ # record_action/record_error works too. See "Observability" above.
2111
+ Phlex::Reactive.apm = :appsignal
2112
+
2113
+ # Report action-body crashes to any tracker yourself (the DIY escape hatch):
2114
+ Phlex::Reactive.on_action_error do |error, ctx|
2115
+ Honeybadger.notify(error, context: { component: ctx[:component], action: ctx[:action] })
2116
+ end
2117
+
1959
2118
  # Component-aware wrapper around every action (audit / rate-limit / assert).
1960
2119
  # Sees the resolved component, action name, and COERCED params; runs inside
1961
2120
  # the connection-id scope but OUTSIDE the transaction. See "Two seams" below.
@@ -2235,6 +2394,50 @@ The events fire whether or not you enable the LogSubscriber; the flag only
2235
2394
  controls the gem's own log lines. See
2236
2395
  [docs/performance.md](https://phlex-reactive.zoolutions.llc/docs/performance).
2237
2396
 
2397
+ ### Turnkey APM adapters (AppSignal, Sentry, Datadog)
2398
+
2399
+ Subscribing by hand is the DIY path. For the common trackers there's a one-liner
2400
+ that both names the transaction and reports errors — so reactive traffic stops
2401
+ rolling into one blurry `ActionsController#create` and a crash arrives at your
2402
+ tracker with context:
2403
+
2404
+ ```ruby
2405
+ # config/initializers/phlex_reactive.rb
2406
+ Phlex::Reactive.apm = :appsignal # or :sentry, :datadog, or a custom object
2407
+ ```
2408
+
2409
+ With it set:
2410
+
2411
+ - Each reactive action shows in the APM as its OWN transaction/span —
2412
+ `Counter#increment`, not `Phlex::Reactive::ActionsController#create` — tagged
2413
+ with the component, action, and outcome.
2414
+ - An action body that raises a genuine error (a 500, not a registered 4xx) is
2415
+ **reported to the tracker with `component`/`action` tags**, then re-raised
2416
+ unchanged so Rails' own error reporting still fires.
2417
+
2418
+ The SDK is **runtime-detected** — no gem dependency is added. If the named SDK
2419
+ isn't loaded, `apm =` logs one warning at boot and no-ops (the same optionality
2420
+ invariant as pgbus). A custom object works too — anything responding to
2421
+ `record_action(payload, duration_ms)` and `record_error(error, payload)`.
2422
+
2423
+ For a tracker with no built-in adapter, report errors yourself — this hook fires
2424
+ on any previously-uncaught action-body error, with the name-only context:
2425
+
2426
+ ```ruby
2427
+ Phlex::Reactive.on_action_error do |error, ctx|
2428
+ Honeybadger.notify(error, context: { component: ctx[:component], action: ctx[:action] })
2429
+ end
2430
+ ```
2431
+
2432
+ And to show the user a flash when an action crashes (the SAME hook the 4xx errors
2433
+ already use — `kind` is `:error` for a crash):
2434
+
2435
+ ```ruby
2436
+ Phlex::Reactive.error_flash = ->(kind) { "Something went wrong — please retry." }
2437
+ ```
2438
+
2439
+ See [docs/observability.md](https://phlex-reactive.zoolutions.llc/docs/observability).
2440
+
2238
2441
  ### Client debug mode (devtools-lite)
2239
2442
 
2240
2443
  The `LogSubscriber` above is the **server** lens. The client lens is
@@ -96,6 +96,13 @@ module Phlex
96
96
  rescue *authorization_errors => e
97
97
  event[:outcome] = :unauthorized
98
98
  reactive_error(:forbidden, deferred_authorization_message(e, component_class), kind: :forbidden)
99
+ rescue => e # rubocop:disable Style/RescueStandardError
100
+ # Issue #207, the defer (read) leg: a render that raises uncaught is
101
+ # OBSERVED (tag + report + flash) then re-raised unchanged, mirroring the
102
+ # action leg. The defer payload has no `action:` — report_error/the
103
+ # adapters treat a nil action gracefully (transaction is the component).
104
+ report_action_error(e, event)
105
+ raise
99
106
  end
100
107
 
101
108
  def verified_defer_payload
@@ -156,6 +163,42 @@ module Phlex
156
163
  rescue *authorization_errors => e
157
164
  event[:outcome] = :unauthorized
158
165
  reactive_error(:forbidden, authorization_error_message(e, component_class, action_def), kind: :forbidden)
166
+ rescue => e # rubocop:disable Style/RescueStandardError
167
+ # Turnkey APM error reporting (issue #207). A previously-UNCAUGHT
168
+ # action-body error — NOT one of the specific 4xx cases above (those still
169
+ # win by ordering) — is OBSERVED here and then re-raised UNCHANGED, so
170
+ # Rails' own error reporting and the app's middleware fire exactly as
171
+ # today. The status never changes: this catch adds no new 4xx.
172
+ # 1. tag the event outcome (fills the #107 nil-outcome gap),
173
+ # 2. report to the APM adapter + on_action_error hooks WITH the name-only
174
+ # component/action context (each reporter isolated — see report_error),
175
+ # 3. render the error_flash for the crash so the actor SEES a flash (500s
176
+ # now flow through the same error_flash path 4xx already used), THEN
177
+ # 4. re-raise. The flash is built but MUST NOT swallow the raise.
178
+ report_action_error(e, event)
179
+ raise
180
+ end
181
+
182
+ # OBSERVE a previously-uncaught action-body error (issue #207) without
183
+ # altering what propagates. Tag the outcome, fan the error out to the APM
184
+ # adapter + on_action_error hooks (report_error isolates each reporter), and
185
+ # render the error_flash for the crash. Every step is best-effort and MUST
186
+ # NOT raise (the caller re-raises the ORIGINAL error immediately after): a
187
+ # broken reporter is swallowed inside report_error; the flash render is
188
+ # guarded here. The flash reuses the SAME 4xx machinery — error_flash_stream
189
+ # degrades to nil on its own failure, and we render at :internal_server_error
190
+ # so the body (if any) matches the 500 the re-raise ultimately yields.
191
+ def report_action_error(error, event)
192
+ event[:outcome] = :error
193
+ Phlex::Reactive.report_error(error, event)
194
+
195
+ flash = error_flash_stream(:error)
196
+ render turbo_stream: flash, status: :internal_server_error if flash
197
+ rescue => e # rubocop:disable Style/RescueStandardError
198
+ # The observation path itself failed — log it, but NEVER let it replace the
199
+ # action-body error the caller is about to re-raise.
200
+ ::Rails.logger&.warn("[phlex-reactive] error observation failed: #{e.class}: #{e.message}") if
201
+ defined?(::Rails) && ::Rails.respond_to?(:logger)
159
202
  end
160
203
 
161
204
  # Reply to an endpoint failure. The status NEVER changes with any flag —